mirror of https://github.com/icsharpcode/ILSpy.git
Browse Source
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 Codepull/4063/head
5 changed files with 777 additions and 16 deletions
@ -0,0 +1,386 @@ |
|||||||
|
// Copyright (c) 2026 Siegfried Pammer
|
||||||
|
//
|
||||||
|
// Permission is hereby granted, free of charge, to any person obtaining a copy of this
|
||||||
|
// software and associated documentation files (the "Software"), to deal in the Software
|
||||||
|
// without restriction, including without limitation the rights to use, copy, modify, merge,
|
||||||
|
// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
|
||||||
|
// to whom the Software is furnished to do so, subject to the following conditions:
|
||||||
|
//
|
||||||
|
// The above copyright notice and this permission notice shall be included in all copies or
|
||||||
|
// substantial portions of the Software.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
|
||||||
|
// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
|
||||||
|
// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
|
||||||
|
// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
||||||
|
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||||
|
// DEALINGS IN THE SOFTWARE.
|
||||||
|
|
||||||
|
using System; |
||||||
|
using System.Collections.Generic; |
||||||
|
using System.Collections.Immutable; |
||||||
|
using System.Reflection.Metadata; |
||||||
|
using System.Text; |
||||||
|
|
||||||
|
using ICSharpCode.Decompiler.Metadata; |
||||||
|
using ICSharpCode.Decompiler.TypeSystem; |
||||||
|
|
||||||
|
namespace ICSharpCode.Decompiler.Documentation |
||||||
|
{ |
||||||
|
/// <summary>
|
||||||
|
/// Finds the entities a hand-written documentation id was aiming at.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The id grammar is exact, and <see cref="IdStringProvider"/> implements it exactly: that is
|
||||||
|
/// what keeps cref-following honest, because an id in a documentation file is machine-written
|
||||||
|
/// and means one member. This is the other half - the ids people and tools type by hand at a
|
||||||
|
/// command line, where being made to spell out a parameter list or a generic arity means
|
||||||
|
/// knowing the answer before asking the question.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Matching is a ladder, loosening one thing at a time and stopping at the first rung that
|
||||||
|
/// matches anything:
|
||||||
|
/// </para>
|
||||||
|
/// <list type="number">
|
||||||
|
/// <item>the exact id, as the grammar defines it;</item>
|
||||||
|
/// <item>the id without its parameter list, naming a member group;</item>
|
||||||
|
/// <item>the id with generic arities left off the declaring type, the member, or both.</item>
|
||||||
|
/// </list>
|
||||||
|
/// <para>
|
||||||
|
/// A rung can match several entities, and every one of them is returned: which to present is
|
||||||
|
/// the caller's decision, and hiding the others would hide that the id was ambiguous. An id
|
||||||
|
/// that does spell out a signature never leaves the first rung, so stating a signature no
|
||||||
|
/// member has still finds nothing rather than drifting to a same-named sibling.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class DocumentationIdSearch |
||||||
|
{ |
||||||
|
/// <summary>
|
||||||
|
/// Finds every entity the given id names, in the first module that matches at all.
|
||||||
|
/// Returns an empty group if no module does.
|
||||||
|
/// </summary>
|
||||||
|
public static (MetadataFile Module, ImmutableArray<EntityHandle> Handles) Find(string idString, IReadOnlyList<MetadataFile> modules) |
||||||
|
{ |
||||||
|
if (idString == null) |
||||||
|
throw new ArgumentNullException(nameof(idString)); |
||||||
|
if (modules == null) |
||||||
|
throw new ArgumentNullException(nameof(modules)); |
||||||
|
|
||||||
|
var (exactModule, exactHandle) = TryExact(idString, modules); |
||||||
|
if (!exactHandle.IsNil) |
||||||
|
return (exactModule, ImmutableArray.Create(exactHandle)); |
||||||
|
|
||||||
|
var queries = Parse(idString); |
||||||
|
if (queries.Count == 0) |
||||||
|
return (null, ImmutableArray<EntityHandle>.Empty); |
||||||
|
|
||||||
|
// Rung 2 requires the id to name the declaring type in full; rung 3 lets it name only
|
||||||
|
// the tail of that path, so "Dictionary.Add" finds the member without the namespace.
|
||||||
|
foreach (bool suffixMatch in new[] { false, true }) |
||||||
|
{ |
||||||
|
foreach (var module in modules) |
||||||
|
{ |
||||||
|
if (module == null) |
||||||
|
continue; |
||||||
|
var matches = ImmutableArray.CreateBuilder<EntityHandle>(); |
||||||
|
foreach (var query in queries) |
||||||
|
CollectInModule(module, query, suffixMatch, matches); |
||||||
|
if (matches.Count > 0) |
||||||
|
return (module, Distinct(matches)); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return (null, ImmutableArray<EntityHandle>.Empty); |
||||||
|
} |
||||||
|
|
||||||
|
static (MetadataFile, EntityHandle) TryExact(string idString, IReadOnlyList<MetadataFile> modules) |
||||||
|
{ |
||||||
|
// Only a well-formed id can be exact, and IdStringProvider throws rather than
|
||||||
|
// returning nothing for one that is not.
|
||||||
|
if (idString.Length < 2 || idString[1] != ':') |
||||||
|
return (null, default); |
||||||
|
try |
||||||
|
{ |
||||||
|
return IdStringProvider.FindEntity(idString, modules); |
||||||
|
} |
||||||
|
catch (ReflectionNameParseException) |
||||||
|
{ |
||||||
|
return (null, default); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
static ImmutableArray<EntityHandle> Distinct(ImmutableArray<EntityHandle>.Builder matches) |
||||||
|
{ |
||||||
|
var seen = new HashSet<EntityHandle>(); |
||||||
|
var result = ImmutableArray.CreateBuilder<EntityHandle>(matches.Count); |
||||||
|
foreach (var handle in matches) |
||||||
|
{ |
||||||
|
if (seen.Add(handle)) |
||||||
|
result.Add(handle); |
||||||
|
} |
||||||
|
return result.ToImmutable(); |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary>A name with the generic arity the id stated for it, or -1 for none.</summary>
|
||||||
|
readonly struct Part |
||||||
|
{ |
||||||
|
public Part(string name, int arity) |
||||||
|
{ |
||||||
|
Name = name; |
||||||
|
Arity = arity; |
||||||
|
} |
||||||
|
|
||||||
|
public string Name { get; } |
||||||
|
public int Arity { get; } |
||||||
|
} |
||||||
|
|
||||||
|
struct Query |
||||||
|
{ |
||||||
|
public char Kind { get; set; } |
||||||
|
public Part[] TypePath { get; set; } |
||||||
|
public string MemberName { get; set; } |
||||||
|
public int MemberArity { get; set; } |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Turns an id into the queries it could plausibly mean. A missing "X:" prefix leaves the
|
||||||
|
/// kind open, and the final dot is then ambiguous between a nested type name and a member
|
||||||
|
/// name - both readings are returned, and both are searched.
|
||||||
|
/// </summary>
|
||||||
|
static List<Query> Parse(string idString) |
||||||
|
{ |
||||||
|
var queries = new List<Query>(); |
||||||
|
string rest = idString; |
||||||
|
char kind = '\0'; |
||||||
|
if (rest.Length > 2 && rest[1] == ':') |
||||||
|
{ |
||||||
|
kind = rest[0]; |
||||||
|
if (kind is not ('T' or 'M' or 'P' or 'F' or 'E')) |
||||||
|
return queries; |
||||||
|
rest = rest.Substring(2); |
||||||
|
} |
||||||
|
|
||||||
|
// An id that spells out a signature is exact or nothing, and exact has been tried.
|
||||||
|
if (rest.IndexOf('(') >= 0 || rest.IndexOf('~') >= 0) |
||||||
|
return queries; |
||||||
|
|
||||||
|
var path = SplitPath(rest); |
||||||
|
if (path.Count == 0) |
||||||
|
return queries; |
||||||
|
|
||||||
|
if (kind is '\0' or 'T') |
||||||
|
queries.Add(new Query { Kind = 'T', TypePath = path.ToArray() }); |
||||||
|
|
||||||
|
if (kind != 'T' && path.Count >= 2) |
||||||
|
{ |
||||||
|
var member = path[path.Count - 1]; |
||||||
|
var declaring = path.GetRange(0, path.Count - 1).ToArray(); |
||||||
|
// '#ctor'/'#cctor' are the id spelling of the metadata names '.ctor'/'.cctor'.
|
||||||
|
string memberName = member.Name.Replace('#', '.'); |
||||||
|
foreach (char memberKind in kind == '\0' ? new[] { 'M', 'P', 'F', 'E' } : new[] { kind }) |
||||||
|
{ |
||||||
|
queries.Add(new Query { |
||||||
|
Kind = memberKind, |
||||||
|
TypePath = declaring, |
||||||
|
MemberName = memberName, |
||||||
|
MemberArity = member.Arity, |
||||||
|
}); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return queries; |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Splits a dotted name into parts, reading the generic arity of each from whichever
|
||||||
|
/// spelling it was given: the id form (<c>Dictionary`2</c>, <c>M``1</c>) or the cref and
|
||||||
|
/// C# forms (<c>Dictionary{TKey,TValue}</c>, <c>Dictionary<TKey,TValue></c>). The
|
||||||
|
/// bracketed forms are what a person reaches for, and unlike a backtick they survive being
|
||||||
|
/// typed at a shell prompt.
|
||||||
|
/// </summary>
|
||||||
|
static List<Part> SplitPath(string text) |
||||||
|
{ |
||||||
|
var parts = new List<Part>(); |
||||||
|
int i = 0; |
||||||
|
while (i <= text.Length) |
||||||
|
{ |
||||||
|
int start = i; |
||||||
|
int arity = -1; |
||||||
|
var name = new StringBuilder(); |
||||||
|
while (i < text.Length && text[i] != '.') |
||||||
|
{ |
||||||
|
char c = text[i]; |
||||||
|
if (c == '`') |
||||||
|
{ |
||||||
|
int digits = i + 1; |
||||||
|
while (digits < text.Length && text[digits] == '`') |
||||||
|
digits++; |
||||||
|
int numberStart = digits; |
||||||
|
while (digits < text.Length && char.IsDigit(text[digits])) |
||||||
|
digits++; |
||||||
|
if (digits == numberStart) |
||||||
|
return new List<Part>(); |
||||||
|
arity = int.Parse(text.Substring(numberStart, digits - numberStart)); |
||||||
|
i = digits; |
||||||
|
continue; |
||||||
|
} |
||||||
|
if (c is '{' or '<') |
||||||
|
{ |
||||||
|
int close = MatchingBracket(text, i); |
||||||
|
if (close < 0) |
||||||
|
return new List<Part>(); |
||||||
|
arity = CountArguments(text, i + 1, close); |
||||||
|
i = close + 1; |
||||||
|
continue; |
||||||
|
} |
||||||
|
name.Append(c); |
||||||
|
i++; |
||||||
|
} |
||||||
|
if (name.Length == 0 && start == i) |
||||||
|
return new List<Part>(); |
||||||
|
parts.Add(new Part(name.ToString(), arity)); |
||||||
|
if (i >= text.Length) |
||||||
|
break; |
||||||
|
i++; // the '.'
|
||||||
|
} |
||||||
|
return parts; |
||||||
|
} |
||||||
|
|
||||||
|
static int MatchingBracket(string text, int open) |
||||||
|
{ |
||||||
|
int depth = 0; |
||||||
|
for (int i = open; i < text.Length; i++) |
||||||
|
{ |
||||||
|
if (text[i] is '{' or '<') |
||||||
|
depth++; |
||||||
|
else if (text[i] is '}' or '>' && --depth == 0) |
||||||
|
return i; |
||||||
|
} |
||||||
|
return -1; |
||||||
|
} |
||||||
|
|
||||||
|
static int CountArguments(string text, int start, int end) |
||||||
|
{ |
||||||
|
if (start >= end) |
||||||
|
return 0; |
||||||
|
int depth = 0, count = 1; |
||||||
|
for (int i = start; i < end; i++) |
||||||
|
{ |
||||||
|
if (text[i] is '{' or '<') |
||||||
|
depth++; |
||||||
|
else if (text[i] is '}' or '>') |
||||||
|
depth--; |
||||||
|
else if (text[i] == ',' && depth == 0) |
||||||
|
count++; |
||||||
|
} |
||||||
|
return count; |
||||||
|
} |
||||||
|
|
||||||
|
static void CollectInModule(MetadataFile module, Query query, bool suffixMatch, ImmutableArray<EntityHandle>.Builder matches) |
||||||
|
{ |
||||||
|
var metadata = module.Metadata; |
||||||
|
foreach (var typeHandle in metadata.TypeDefinitions) |
||||||
|
{ |
||||||
|
var typeDef = metadata.GetTypeDefinition(typeHandle); |
||||||
|
if (!TypeMatches(metadata, typeDef, query.TypePath, suffixMatch)) |
||||||
|
continue; |
||||||
|
if (query.Kind == 'T') |
||||||
|
matches.Add(typeHandle); |
||||||
|
else |
||||||
|
CollectMembers(metadata, typeDef, query, matches); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Compares a type's namespace-and-nesting path against the id's, part by part. A part
|
||||||
|
/// that states an arity must match it; one that leaves it off matches any, which is what
|
||||||
|
/// lets "Dictionary" find "Dictionary`2". With <paramref name="suffixMatch"/> the id need
|
||||||
|
/// only name the tail of the path, so a namespace may be shortened or dropped.
|
||||||
|
/// </summary>
|
||||||
|
static bool TypeMatches(MetadataReader metadata, TypeDefinition typeDef, Part[] wanted, bool suffixMatch) |
||||||
|
{ |
||||||
|
var actual = new List<Part>(); |
||||||
|
var current = typeDef; |
||||||
|
while (true) |
||||||
|
{ |
||||||
|
actual.Insert(0, SplitArity(metadata.GetString(current.Name))); |
||||||
|
var declaring = current.GetDeclaringType(); |
||||||
|
if (declaring.IsNil) |
||||||
|
break; |
||||||
|
current = metadata.GetTypeDefinition(declaring); |
||||||
|
} |
||||||
|
string ns = metadata.GetString(current.Namespace); |
||||||
|
if (ns.Length > 0) |
||||||
|
{ |
||||||
|
var namespaceParts = ns.Split('.'); |
||||||
|
for (int i = namespaceParts.Length - 1; i >= 0; i--) |
||||||
|
actual.Insert(0, new Part(namespaceParts[i], -1)); |
||||||
|
} |
||||||
|
|
||||||
|
if (suffixMatch ? actual.Count < wanted.Length : actual.Count != wanted.Length) |
||||||
|
return false; |
||||||
|
int offset = actual.Count - wanted.Length; |
||||||
|
for (int i = 0; i < wanted.Length; i++) |
||||||
|
{ |
||||||
|
var a = actual[offset + i]; |
||||||
|
if (a.Name != wanted[i].Name) |
||||||
|
return false; |
||||||
|
if (wanted[i].Arity >= 0 && a.Arity != wanted[i].Arity) |
||||||
|
return false; |
||||||
|
} |
||||||
|
return true; |
||||||
|
} |
||||||
|
|
||||||
|
static Part SplitArity(string metadataName) |
||||||
|
{ |
||||||
|
int tick = metadataName.IndexOf('`'); |
||||||
|
if (tick < 0) |
||||||
|
return new Part(metadataName, 0); |
||||||
|
return int.TryParse(metadataName.Substring(tick + 1), out int arity) |
||||||
|
? new Part(metadataName.Substring(0, tick), arity) |
||||||
|
: new Part(metadataName, 0); |
||||||
|
} |
||||||
|
|
||||||
|
static void CollectMembers(MetadataReader metadata, TypeDefinition typeDef, Query query, ImmutableArray<EntityHandle>.Builder matches) |
||||||
|
{ |
||||||
|
bool NameMatches(StringHandle candidate) => metadata.StringComparer.Equals(candidate, query.MemberName); |
||||||
|
|
||||||
|
switch (query.Kind) |
||||||
|
{ |
||||||
|
case 'F': |
||||||
|
foreach (var handle in typeDef.GetFields()) |
||||||
|
{ |
||||||
|
if (NameMatches(metadata.GetFieldDefinition(handle).Name)) |
||||||
|
matches.Add(handle); |
||||||
|
} |
||||||
|
break; |
||||||
|
case 'M': |
||||||
|
foreach (var handle in typeDef.GetMethods()) |
||||||
|
{ |
||||||
|
var method = metadata.GetMethodDefinition(handle); |
||||||
|
if (!NameMatches(method.Name)) |
||||||
|
continue; |
||||||
|
if (query.MemberArity >= 0 && method.GetGenericParameters().Count != query.MemberArity) |
||||||
|
continue; |
||||||
|
matches.Add(handle); |
||||||
|
} |
||||||
|
break; |
||||||
|
case 'P': |
||||||
|
foreach (var handle in typeDef.GetProperties()) |
||||||
|
{ |
||||||
|
if (NameMatches(metadata.GetPropertyDefinition(handle).Name)) |
||||||
|
matches.Add(handle); |
||||||
|
} |
||||||
|
break; |
||||||
|
case 'E': |
||||||
|
foreach (var handle in typeDef.GetEvents()) |
||||||
|
{ |
||||||
|
if (NameMatches(metadata.GetEventDefinition(handle).Name)) |
||||||
|
matches.Add(handle); |
||||||
|
} |
||||||
|
break; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue