Skip to content

Accept member IDs without a signature in --navigateto - #4063

Merged
siegfriedpammer merged 2 commits into
masterfrom
fix/navigateto-short-form
Aug 29, 2026
Merged

siegfriedpammer merged 2 commits into
masterfrom
fix/navigateto-short-form

Conversation

@siegfriedpammer

@siegfriedpammer siegfriedpammer commented Aug 27, 2026

Copy link
Copy Markdown
Member

Found while trying to open a member from the command line: --navigateto "M:Class148.DefaultCheckCopyed"
found nothing, because the id left the parameter list off.

Why a ladder, and why not in the resolver

The obvious fix is to make IdStringProvider tolerate a missing parameter list. I did that first, and
it was wrong. Measuring against Roslyn (probe sources in the linked analysis):

  • Roslyn's own DocumentationCommentId resolver accepts no abbreviation at all. Parameter list
    omitted, type arity omitted, method arity omitted, wrong arity, C# syntax, missing namespace,
    indexer without parameters - every one resolves to nothing. The id grammar is exact.
  • The compiler never emits a short form. A cref is a different grammar: the compiler binds it
    and rewrites it into a full id, warning CS0419 and picking one member when the cref names several.
  • A prefixed cref is copied through unvalidated. cref="M:Widget.M1``9" lands in the XML verbatim,
    so an id in a documentation file is only as trustworthy as the human who typed it.

So accepting abbreviations is a convenience for people typing, not a correctness fix, and it does
not belong in a resolver whose other consumer is cref-following - where strictness is exactly what
keeps a doc link honest.

The evidence that this matters is not hypothetical. An exhaustive round-trip over every id Roslyn
generates for the test fixture (rather than a hand-picked list) caught the in-resolver version
mis-resolving an id Roslyn itself writes into documentation XML:

M:RecordTests.RecordStruct.#ctor  ->  M:RecordTests.RecordStruct.#ctor(System.Int32,System.Double)

A record struct's implicit parameterless constructor never reaches metadata, so the fallback drifted to
the primary constructor - a wrong answer where the previous behaviour was no answer. Keeping the
resolver strict dissolves that, and dissolves @christophwille's stated-but-wrong-arity finding with it,
rather than patching either.

IdStringProvider is therefore unchanged by this PR. The tolerance lives in the new
DocumentationIdSearch, a ladder that loosens one thing at a time and stops at the first rung that
matches anything:

  1. the exact id, as the grammar defines it;
  2. the id without its parameter list;
  3. the id without generic arities.

Two rules hold across the ladder. Leaving a detail out asks for any; stating one wrongly asks for
something that does not exist
- so Dictionary9.Add` still finds nothing. And a rung may match
several members, and all of them are returned
, because which to present is the caller's decision and
returning one would hide that the id was ambiguous.

Why arity too

Because it is the same problem one level up - you cannot write Dictionary`2 without already
knowing the answer - and because the exact spelling is hostile to the place it gets typed:

$ echo "M:System.Collections.Generic.Dictionary`2.Add"
/bin/bash: eval: line 1: unexpected EOF while looking for matching ``'

A backtick inside double quotes is command substitution, so the correct id kills the shell before
ilspycmd runs. The cref spelling {TKey,TValue} is accepted too, and it survives quoting.

What the front ends do

Everything below now works, against System.Collections.dll:

M:System.Collections.Generic.Dictionary`2.Add(`0,`1)     exact
M:System.Collections.Generic.Dictionary.Add              type arity omitted
System.Collections.Generic.Dictionary.Add                prefix omitted
M:Dictionary.Add                                         namespace omitted
Generic.Dictionary.Add                                   namespace shortened
M:...Dictionary{TKey,TValue}.Add                         cref-style arity
Dictionary<TKey,TValue>.Add                              C#-style arity
Dictionary.KeyCollection                                 nested type, both arities omitted

Without a prefix, A.B genuinely reads as either a nested type or a member; both are searched and both
reported rather than one being guessed at.

  • ilspycmd -m decompiles every member of the group, headed by a comment naming the ambiguity.
    Making the user re-run with a full signature would defeat the point of accepting the short form.
    --ilast -m likewise dumps each body instead of silently taking the first.
  • The UI selects all the matching members. Landing on one would hide the choice, and falling back
    to the declaring type would bury the group in a large type's decompilation - Enumerable.Where would
    decompile some two hundred members to show four. The tree already multi-selects.

Note this deliberately diverges from Roslyn, which warns and picks one. That is right for an authoring
tool, which must end up with a single id in the XML; a navigation tool can show all of them.

An unresolvable target left the tree empty

Supplying --navigateto also suppresses the single-assembly selection that opening a file otherwise
makes, so an ID that resolved to nothing left the tree on an empty selection with no indication of what
had happened. NavigateOnLaunchAsync now reports whether it navigated, and the caller fills in the
selection when it did not. The none sentinel still counts as handled, since the VS add-in uses it to
deliberately leave the tree empty.

Tests

Written red-first. IdStringProviderTests keeps the resolver honest -
FindEntity_ResolvesOnlyWhatTheIdExactlyNames pins eleven negatives, including every abbreviation - and
FindEntity_RoundTripsEveryIdRoslynGenerates drives a round-trip over the whole Roslyn id map, which is
what caught the record-struct case. The ladder has its own cases for each rung, the prefix and namespace
omissions, bracketed arity, and nesting three levels deep with arities dropped at any level.

Two fixture facts worth recording, because both contradicted my expectations:

  • A group containing a parameterless member is not ambiguous through the short form:
    M:Acme.Widget.#ctor is that constructor's own exact id, so rung 1 wins.
  • A checked operator gets its own metadata name (op_CheckedExplicit), so it is not part of the
    unchecked operator's group.

ICSharpCode.Decompiler.Tests 3539 / 0 failed / 45 skipped; ILSpy.Tests 1243 / 0 / 3;
ICSharpCode.ILSpyCmd.Tests 33 / 0 / 0.

Review

@christophwille - your arity finding is a large part of why this got restructured; thank you. All three
points are addressed, though two by deletion rather than by fix: the code carrying the redundant
nameFilter guard and the duplicated HasSignature parsing no longer exists, and the licence
spelling went with the test that contained it. One note in fairness: the nameFilter != null at
IdStringProvider.cs:954 is master's own pre-existing line and part of your nit arguably applies to it
too, but this PR no longer touches that method, so I have left it alone rather than adding unrelated
churn.

Out of scope, but worth recording: ilspycmd --type has long accepted Dictionary via its own
arity-stripping ladder and reports candidates when several match, while -m was strict. That
inconsistency is what a user actually trips over, and this PR removes it only on the -m side.

Written by an AI agent (Claude) on Siegfried's behalf.

@christophwille christophwille left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review summary (high-effort, recall-biased)

Both changes are sound: the third pass only runs after both exact-match passes fail and only when no '(' / '~' is present, so every existing full-ID resolution is untouched, and the UI fallback correctly keeps 'none' as handled. Callers checked: both FindEntity overloads share FindMemberInType, so the ilspycmd -m claim holds; DecompilerTextView.ResolveDocReference (cref clicks) picks the fallback up too, which is a benefit.

Findings (one minor resolver bug, two nits), inline:

  1. HasSignature ignores the generic-arity marker, so a short form that does state an arity drifts to a same-named method of a different arity. Repro on the existing Acme fixture below.
  2. Test doc comment uses 'licence' (en-GB) - CLAUDE.md asks for en-US.
  3. HasSignature re-parses what GetApproximateMemberName already parsed; the nameFilter != null guard on the new line is redundant.

No findings on the AssemblyTreeModel change or the UI tests.

Comment thread ICSharpCode.Decompiler/Documentation/IdStringProvider.cs Outdated
Comment thread ICSharpCode.Decompiler/Documentation/IdStringProvider.cs Outdated
Comment thread ICSharpCode.Decompiler.Tests/Documentation/IdStringProviderTests.cs Outdated
@siegfriedpammer
siegfriedpammer force-pushed the fix/navigateto-short-form branch 2 times, most recently from c4ead78 to 100a131 Compare August 28, 2026 16:41
Typing "M:System.Linq.Enumerable.Where" at a command line is a reasonable thing
to do, and it found nothing: resolution compares the whole id string, so a form
without the parameter list only ever matched a member that genuinely takes none.
Spelling the signature out is no answer, because it means knowing the overload
count before asking. The same goes for a generic arity - and the exact spelling,
Dictionary`2, does not even survive an unquoted bash prompt, where a backtick
starts command substitution.

None of that makes the short form legal. Measured against Roslyn: its own
DocumentationCommentId resolver accepts no abbreviation at all, and the compiler
never emits one - a cref is a different grammar, which the compiler binds and
rewrites into a full id, warning CS0419 and picking one member when the cref is
ambiguous. A prefixed cref is copied through unvalidated, so an id in a
documentation file can be anything a human typed.

So the id grammar stays exact and IdStringProvider stays with it, which is what
lets cref-following trust its answer. The tolerance belongs to the callers that
serve people typing, and lives in DocumentationIdSearch as a ladder that loosens
one thing at a time: the exact id, then the id without its parameter list, then
without generic arities. Stating a detail wrongly still finds nothing; only
leaving one out asks for any. A rung may match several members and all of them
are returned, because which to present is the caller's decision and hiding the
rest would hide that the id was ambiguous.

ilspycmd shows every member of the group, headed by a comment naming the
ambiguity, and accepts the shapes people actually type: no prefix, a shortened
namespace, and arity written the cref or C# way.

Assisted-by: Claude:claude-opus-5[1m]:Claude Code
An ID that resolves to no member left the tree on an empty selection with no
indication of what happened, because supplying --navigateto also suppresses the
single-assembly selection that opening a file otherwise makes. Only a target
that actually resolved should claim the selection; "none" still counts as
handled, since the VS add-in uses it to deliberately leave the tree empty.

The target arrives from a command line, so it goes through the omission-tolerant
search rather than exact resolution, and that can name several members. All of
them are selected. Landing on one would hide that there was a choice, and
falling back to the declaring type would bury the group in a large type's
decompilation - Enumerable.Where would decompile some two hundred members to
show four. The tree already multi-selects, so the overloads appear together at
the level the ID was pointing at.

Assisted-by: Claude:claude-opus-5[1m]:Claude Code
@siegfriedpammer
siegfriedpammer force-pushed the fix/navigateto-short-form branch from 14f443e to ead3ee7 Compare August 29, 2026 09:59
@siegfriedpammer
siegfriedpammer merged commit 3687c06 into master Aug 29, 2026
15 checks passed
@siegfriedpammer
siegfriedpammer deleted the fix/navigateto-short-form branch August 29, 2026 10:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants