// 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 { /// /// Finds the entities a hand-written documentation id was aiming at. /// /// /// /// The id grammar is exact, and 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. /// /// /// Matching is a ladder, loosening one thing at a time and stopping at the first rung that /// matches anything: /// /// /// the exact id, as the grammar defines it; /// the id without its parameter list, naming a member group; /// the id with generic arities left off the declaring type, the member, or both. /// /// /// 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. /// /// public static class DocumentationIdSearch { /// /// Finds every entity the given id names, in the first module that matches at all. /// Returns an empty group if no module does. /// public static (MetadataFile Module, ImmutableArray Handles) Find(string idString, IReadOnlyList 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.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(); foreach (var query in queries) CollectInModule(module, query, suffixMatch, matches); if (matches.Count > 0) return (module, Distinct(matches)); } } return (null, ImmutableArray.Empty); } static (MetadataFile, EntityHandle) TryExact(string idString, IReadOnlyList 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 Distinct(ImmutableArray.Builder matches) { var seen = new HashSet(); var result = ImmutableArray.CreateBuilder(matches.Count); foreach (var handle in matches) { if (seen.Add(handle)) result.Add(handle); } return result.ToImmutable(); } /// A name with the generic arity the id stated for it, or -1 for none. 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; } } /// /// 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. /// static List Parse(string idString) { var queries = new List(); 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; } /// /// Splits a dotted name into parts, reading the generic arity of each from whichever /// spelling it was given: the id form (Dictionary`2, M``1) or the cref and /// C# forms (Dictionary{TKey,TValue}, Dictionary<TKey,TValue>). The /// bracketed forms are what a person reaches for, and unlike a backtick they survive being /// typed at a shell prompt. /// static List SplitPath(string text) { var parts = new List(); 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(); 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(); arity = CountArguments(text, i + 1, close); i = close + 1; continue; } name.Append(c); i++; } if (name.Length == 0 && start == i) return new List(); 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.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); } } /// /// 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 the id need /// only name the tail of the path, so a namespace may be shortened or dropped. /// static bool TypeMatches(MetadataReader metadata, TypeDefinition typeDef, Part[] wanted, bool suffixMatch) { var actual = new List(); 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.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; } } } }