From c17c6af1e977ca57dcb2c0a635612e8158d1cfb5 Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Tue, 28 Jul 2026 08:49:39 +0200 Subject: [PATCH] Add a metadata-level IdStringProvider.FindEntity Resolve an ID string to a (module, handle) pair by scanning metadata directly: namespace/type-name splits are tried at every dot (the format does not mark the boundary), nested types and type forwarders are walked, and members are matched by regenerating each candidate's ID string instead of parsing the signature portion, which keeps resolution in sync with generation by construction. This gives navigation a resolution path that needs no type system and works on assemblies exactly as their metadata spells them. Inherent format limitations (metadata names containing ID string special characters; function pointer types rendering empty, so such overloads share an ID) are documented on the method. Assisted-by: Claude:claude-fable-5:Claude Code --- .../Documentation/IdStringProviderTests.cs | 127 +++++++ .../Documentation/IdStringProvider.cs | 347 ++++++++++++++++++ 2 files changed, 474 insertions(+) diff --git a/ICSharpCode.Decompiler.Tests/Documentation/IdStringProviderTests.cs b/ICSharpCode.Decompiler.Tests/Documentation/IdStringProviderTests.cs index c2af1bb5b..2b167fa69 100644 --- a/ICSharpCode.Decompiler.Tests/Documentation/IdStringProviderTests.cs +++ b/ICSharpCode.Decompiler.Tests/Documentation/IdStringProviderTests.cs @@ -38,6 +38,8 @@ using Microsoft.CodeAnalysis.CSharp; using NUnit.Framework; +using DecompilerSymbolKind = ICSharpCode.Decompiler.TypeSystem.SymbolKind; + namespace ICSharpCode.Decompiler.Tests.Documentation { [TestFixture] @@ -648,6 +650,7 @@ namespace ModreqParams string.Join("\n", roslynIdMap.Keys .Where(k => k.Contains(entity.Name)) .Take(10))); + AssertIdentifiesEntity(entity, decompilerId); } /// @@ -661,6 +664,71 @@ namespace ModreqParams $"is the expected string correct?"); string decompilerId = IdStringProvider.GetIdString(entity.ParentModule.MetadataFile, entity.MetadataToken); Assert.That(decompilerId, Is.EqualTo(expectedId), "Decompiler ID mismatch"); + AssertIdentifiesEntity(entity, decompilerId); + } + + /// + /// Assert that names and not some + /// other member. The Roslyn ID map spans every referenced assembly, so mere membership + /// in it also accepts the ID of an unrelated member that happens to exist - an ID naming + /// the wrong overload, the wrong arity or a member of a different type would pass. Both + /// directions are checked: the symbol Roslyn files under the ID has to be of the + /// entity's kind, and resolving the ID back through FindEntity has to land on exactly + /// this entity's metadata token - or, where the format cannot tell two members apart, + /// on a member that carries the very same ID. + /// + private void AssertIdentifiesEntity(IEntity entity, string idString) + { + var module = entity.ParentModule.MetadataFile; + var roslynSymbol = roslynIdMap[idString]; + Assert.That(roslynSymbol.Kind, Is.EqualTo(ExpectedRoslynKind(entity.SymbolKind)), + $"ID '{idString}' is filed by Roslyn under a {roslynSymbol.Kind} " + + $"('{roslynSymbol.ToDisplayString()}'), but it was generated for the " + + $"{entity.SymbolKind} '{entity.FullName}'."); + + var (resolvedModule, resolvedHandle) = IdStringProvider.FindEntity(idString, new[] { module }); + Assert.That(resolvedHandle.IsNil, Is.False, + $"ID '{idString}' generated for '{entity.FullName}' does not resolve back to any member."); + Assert.That(resolvedModule, Is.SameAs(module)); + if (resolvedHandle.Equals(entity.MetadataToken)) + return; + + // Some IDs cannot name a single member because the format cannot express the + // signature: Roslyn renders a function-pointer parameter as nothing at all, so + // TakesFnPtr(delegate*) and TakesFnPtr(delegate*) both come + // out as 'M:C.TakesFnPtr()'. Resolution can then only return the first member + // carrying the key, so require that the member it returned really does carry it; + // a resolution that picked a member with a different ID is still a failure. + string resolvedId = IdStringProvider.GetIdString(module, resolvedHandle); + Assert.That(resolvedId, Is.EqualTo(idString), + $"ID '{idString}' was generated for '{entity.FullName}' " + + $"(token {MetadataTokens.GetToken(entity.MetadataToken):X8}) but names " + + $"'{DescribeHandle(module, resolvedHandle)}' " + + $"(token {MetadataTokens.GetToken(resolvedHandle):X8}), whose own ID is " + + $"'{resolvedId}'."); + } + + /// The Roslyn symbol kind an entity of the given kind must be filed under. + private static Microsoft.CodeAnalysis.SymbolKind ExpectedRoslynKind(DecompilerSymbolKind kind) => kind switch { + DecompilerSymbolKind.TypeDefinition => Microsoft.CodeAnalysis.SymbolKind.NamedType, + DecompilerSymbolKind.Field => Microsoft.CodeAnalysis.SymbolKind.Field, + DecompilerSymbolKind.Property or DecompilerSymbolKind.Indexer => Microsoft.CodeAnalysis.SymbolKind.Property, + DecompilerSymbolKind.Event => Microsoft.CodeAnalysis.SymbolKind.Event, + _ => Microsoft.CodeAnalysis.SymbolKind.Method, + }; + + /// Names the member a handle points at, for assertion messages. + private static string DescribeHandle(MetadataFile module, EntityHandle handle) + { + var metadata = module.Metadata; + return handle.Kind switch { + HandleKind.TypeDefinition => metadata.GetString(metadata.GetTypeDefinition((TypeDefinitionHandle)handle).Name), + HandleKind.MethodDefinition => metadata.GetString(metadata.GetMethodDefinition((MethodDefinitionHandle)handle).Name), + HandleKind.FieldDefinition => metadata.GetString(metadata.GetFieldDefinition((FieldDefinitionHandle)handle).Name), + HandleKind.PropertyDefinition => metadata.GetString(metadata.GetPropertyDefinition((PropertyDefinitionHandle)handle).Name), + HandleKind.EventDefinition => metadata.GetString(metadata.GetEventDefinition((EventDefinitionHandle)handle).Name), + _ => handle.Kind.ToString(), + }; } #region Types @@ -1148,6 +1216,23 @@ namespace ModreqParams } } + [Test] + public void FunctionPointerParameters_ShareOneIdString() + { + // Roslyn renders a function-pointer parameter as nothing at all, so overloads that + // differ only in one collapse onto a single key with an empty parameter list. The + // generator reproduces that instead of inventing a distinguishable key: this is the + // key the C# compiler writes into the documentation file, so it is the one a lookup + // has to produce and a cref has to resolve against. + var overloads = FindType("FnPtrs.FnPtrParameters").Methods + .Where(m => m.Name == "TakesFnPtr") + .ToList(); + Assert.That(overloads, Has.Count.EqualTo(2), + "the fixture declares two overloads differing only in their function-pointer parameter"); + foreach (var overload in overloads) + AssertIdString(overload, "M:FnPtrs.FnPtrParameters.TakesFnPtr()"); + } + [Test] public void AllFields_MatchRoslyn() { @@ -1192,6 +1277,48 @@ namespace ModreqParams #endregion + #region FindEntity round-trip + + [TestCase("T:Color")] + [TestCase("T:Acme.Widget")] + [TestCase("T:Acme.Widget.NestedClass")] + [TestCase("T:Acme.MyList`1")] + [TestCase("T:Acme.MyList`1.Helper`2")] + [TestCase("F:Acme.Widget.message")] + [TestCase("F:Acme.Widget.PI")] + [TestCase("M:Acme.Widget.#ctor")] + [TestCase("M:Acme.Widget.#ctor(System.String)")] + [TestCase("M:Acme.Widget.#cctor")] + [TestCase("M:Acme.Widget.Finalize")] + [TestCase("M:Acme.Widget.M0")] + [TestCase("M:Acme.Widget.M1(System.Char,System.Single@,Acme.ValueType@,System.Int32@)")] + [TestCase("M:Acme.Widget.M2(System.Int16[],System.Int32[0:,0:],System.Int64[][])")] + [TestCase("M:Acme.Widget.M3(System.Int64[][],Acme.Widget[0:,0:,0:][])")] + [TestCase("M:Acme.Widget.M6(System.Int32,System.Object[])")] + [TestCase("M:Acme.MyList`1.Test(`0)")] + [TestCase("M:Acme.UseList.Process(Acme.MyList{System.Int32})")] + [TestCase("M:Acme.UseList.GetValues``1(``0)")] + [TestCase("P:Acme.Widget.Width")] + [TestCase("P:Acme.Widget.Item(System.Int32)")] + [TestCase("P:Acme.Widget.Item(System.String,System.Int32)")] + [TestCase("E:Acme.Widget.AnEvent")] + [TestCase("M:Acme.Widget.op_UnaryPlus(Acme.Widget)")] + [TestCase("M:Acme.Widget.op_Addition(Acme.Widget,Acme.Widget)")] + [TestCase("M:Acme.Widget.op_Explicit(Acme.Widget)~System.Int32")] + [TestCase("M:Acme.Widget.op_Implicit(Acme.Widget)~System.Int64")] + [TestCase("M:NestedGenericInstantiations.Consumer.TakesInner(NestedGenericInstantiations.Outer{System.Int32}.Inner)")] + [TestCase("M:NestedGenericInstantiations.Consumer.TakesInner2(NestedGenericInstantiations.Outer{System.Int32}.Inner2{System.String})")] + [TestCase("M:CheckedOperators.Money.op_CheckedExplicit(CheckedOperators.Money)~System.Int32")] + public void FindEntity_RoundTrip(string idString) + { + var (_, handle) = IdStringProvider.FindEntity(idString, new[] { decompilerTypeSystem.MainModule.MetadataFile }); + Assert.That(handle.IsNil, Is.False, $"FindEntity returned null for '{idString}'"); + Assert.That(IdStringProvider.GetIdString(decompilerTypeSystem.MainModule.MetadataFile, handle), Is.EqualTo(idString), + "GetIdString on found entity does not match the input ID string"); + } + + #endregion + #region ParseTypeName [TestCase("System.Int32")] diff --git a/ICSharpCode.Decompiler/Documentation/IdStringProvider.cs b/ICSharpCode.Decompiler/Documentation/IdStringProvider.cs index e08bd48bc..881762a07 100644 --- a/ICSharpCode.Decompiler/Documentation/IdStringProvider.cs +++ b/ICSharpCode.Decompiler/Documentation/IdStringProvider.cs @@ -804,6 +804,353 @@ namespace ICSharpCode.Decompiler.Documentation return ParseMemberIdString(idString).Resolve(context); } } + + /// + /// Finds the entity with the given ID string in the provided modules. + /// + /// ID string of the entity (e.g., "T:System.String", "M:System.String.Contains(System.String)"). + /// The list of modules to search, in priority order. + /// + /// A tuple of (MetadataFile, EntityHandle) for the found entity. + /// Returns default if the entity is not found. + /// + /// The syntax of the ID string is invalid. + /// + /// + /// The ID string format cannot represent all names valid in metadata: GetIdString + /// emits raw metadata names, but a name that itself contains ID string special + /// characters (e.g. a dot in a type name) is ambiguous when parsed back, because + /// namespace/type-name splits are only tried at dots. Function pointer parameter + /// types render as empty (matching Roslyn), so overloads differing only by a + /// function pointer type share an ID and resolve to the first candidate. + /// + /// + /// A type only present as a type forwarder is returned as its ExportedType handle; + /// members of such a type are not followed into the target assembly unless that + /// assembly is itself part of . + /// + /// + public static (MetadataFile Module, EntityHandle Handle) FindEntity(string idString, IReadOnlyList modules) + { + if (idString == null) + throw new ArgumentNullException(nameof(idString)); + if (modules == null) + throw new ArgumentNullException(nameof(modules)); + if (idString.Length < 2 || idString[1] != ':') + throw new ReflectionNameParseException(0, "Missing type tag"); + + char typeChar = idString[0]; + + if (typeChar == 'T') + { + return FindTypeDefinition(idString.Substring(2), modules); + } + else + { + return FindMember(typeChar, idString, modules); + } + } + + /// + /// Resolves a type name from an ID string to a TypeDefinitionHandle or ExportedTypeHandle. + /// Tries all possible namespace/type-name boundary splits (mirrors the algorithm from + /// GetPotentiallyNestedClassTypeReference.ResolveInPEFile). + /// + static (MetadataFile, EntityHandle) FindTypeDefinition(string typeName, IReadOnlyList modules) + { + var parts = ParseTypeNameParts(typeName); + + foreach (var module in modules) + { + if (module == null) + continue; + var result = ResolveTypeInModule(parts, module); + if (!result.IsNil) + return (module, result); + } + + return default; + } + + /// + /// Finds a member (field, method, property, event) by its ID string. + /// First resolves the declaring type, then enumerates candidate members + /// and compares their computed ID strings. + /// + /// + /// Finds the '.' separating the declaring type name from the member name: the last + /// '.' before '(' or '~' or end-of-string. Returns a negative value if there is none. + /// + static int FindMemberNameDot(string idString) + { + int parenPos = idString.IndexOf('('); + if (parenPos < 0) + parenPos = idString.LastIndexOf('~'); + if (parenPos < 0) + parenPos = idString.Length; + return idString.LastIndexOf('.', parenPos - 1); + } + + static (MetadataFile, EntityHandle) FindMember(char typeChar, string idString, IReadOnlyList modules) + { + int dotPos = FindMemberNameDot(idString); + if (dotPos < 0) + throw new ReflectionNameParseException(0, "Could not find '.' separating type name from member name"); + + // The type name portion is from index 2 (after "X:") to dotPos. + string typeName = idString.Substring(2, dotPos - 2); + var typeParts = ParseTypeNameParts(typeName); + + foreach (var module in modules) + { + if (module == null) + continue; + + var typeHandle = ResolveTypeInModule(typeParts, module); + if (typeHandle.IsNil || typeHandle.Kind != HandleKind.TypeDefinition) + continue; + + var typeDef = module.Metadata.GetTypeDefinition((TypeDefinitionHandle)typeHandle); + EntityHandle memberHandle = FindMemberInType(module, typeDef, typeChar, idString); + if (!memberHandle.IsNil) + return (module, memberHandle); + } + + return default; + } + + /// + /// Searches for a member within a resolved type definition by computing + /// the ID string of each candidate and comparing. + /// + static EntityHandle FindMemberInType(MetadataFile module, TypeDefinition typeDef, char typeChar, string idString) + { + switch (typeChar) + { + case 'F': + foreach (var handle in typeDef.GetFields()) + { + if (GetIdString(module, handle) == idString) + return handle; + } + break; + + case 'M': + foreach (var handle in typeDef.GetMethods()) + { + if (GetIdString(module, handle) == idString) + return handle; + } + break; + + case 'P': + foreach (var handle in typeDef.GetProperties()) + { + if (GetIdString(module, handle) == idString) + return handle; + } + break; + + case 'E': + foreach (var handle in typeDef.GetEvents()) + { + if (GetIdString(module, handle) == idString) + return handle; + } + break; + } + + return default; + } + #endregion + + #region Type Name Parsing and Resolution + /// + /// Represents a parsed segment of a potentially nested type name in an ID string. + /// The first part's Name may contain dots (namespace + top-level type name); + /// subsequent parts are nested type names without dots. + /// + struct TypeNamePart + { + public string Name; + public int TypeParameterCount; + } + + /// + /// Parses a type name (without the "T:" prefix) into its constituent parts, + /// handling nested types separated by '.', and generic arity via `n or {args}. + /// + /// The first part's Name contains the full dotted name (namespace + top-level type), + /// because we don't know where the namespace ends. Resolution will try all splits. + /// + /// Examples: + /// "System.Collections.Generic.Dictionary`2.KeyCollection" + /// → [{Name="System.Collections.Generic.Dictionary", TPC=2}, {Name="KeyCollection", TPC=0}] + /// + /// "Outer.Inner{System.Int32}" + /// → [{Name="Outer", TPC=0}, {Name="Inner", TPC=1}] + /// + static List ParseTypeNameParts(string typeName) + { + var parts = new List(); + int pos = 0; + + string firstName = ReadTypeNameSegment(typeName, ref pos, allowDots: true); + int firstTpc = ReadTypeParameterCountFromIdString(typeName, ref pos); + parts.Add(new TypeNamePart { Name = firstName, TypeParameterCount = firstTpc }); + + while (pos < typeName.Length && typeName[pos] == '.') + { + pos++; + string nestedName = ReadTypeNameSegment(typeName, ref pos, allowDots: false); + int nestedTpc = ReadTypeParameterCountFromIdString(typeName, ref pos); + parts.Add(new TypeNamePart { Name = nestedName, TypeParameterCount = nestedTpc }); + } + + return parts; + } + + /// + /// Reads a type name segment (no special characters). If allowDots is true, + /// dots are included in the segment (for the top-level name which includes namespace). + /// + static string ReadTypeNameSegment(string typeName, ref int pos, bool allowDots) + { + int start = pos; + while (pos < typeName.Length) + { + char c = typeName[pos]; + if (IsIDStringSpecialCharacter(c)) + break; + if (!allowDots && c == '.') + break; + pos++; + } + if (pos == start) + throw new ReflectionNameParseException(pos, "Expected type name"); + return typeName.Substring(start, pos - start); + } + + /// + /// Reads a type parameter count from the current position in an ID string. + /// Handles both `n (unbound) and {T1,T2,...} (bound) syntax. + /// For bound syntax, counts the arguments without fully parsing them + /// (we only need the arity for type definition lookup). + /// + static int ReadTypeParameterCountFromIdString(string typeName, ref int pos) + { + if (pos >= typeName.Length) + return 0; + + if (typeName[pos] == '`') + { + pos++; + return ReflectionHelper.ReadTypeParameterCount(typeName, ref pos); + } + else if (typeName[pos] == '{') + { + int count = 1; + int depth = 0; + pos++; // skip '{' + while (pos < typeName.Length) + { + char c = typeName[pos]; + if (c == '{') + depth++; + else if (c == '}') + { + if (depth == 0) + { + pos++; + break; + } + depth--; + } + else if (c == ',' && depth == 0) + { + count++; + } + pos++; + } + return count; + } + + return 0; + } + + /// + /// Attempts to resolve a parsed type name within a single module. + /// The first part's Name is a dotted name like "A.B.C", and we try all possible + /// splits between namespace and top-level type name, from right to left. + /// For each candidate top-level type, we walk the nested types. + /// Also checks type forwarders. + /// + static EntityHandle ResolveTypeInModule(List parts, MetadataFile module) + { + var metadata = module.Metadata; + string topLevelDottedName = parts[0].Name; + string[] dotParts = topLevelDottedName.Split('.'); + + for (int i = dotParts.Length - 1; i >= 0; i--) + { + string ns = string.Join(".", dotParts, 0, i); + string name = dotParts[i]; + int topLevelTpc = (i == dotParts.Length - 1) ? parts[0].TypeParameterCount : 0; + var topLevelName = new TopLevelTypeName(ns, name, topLevelTpc); + + var typeHandle = module.GetTypeDefinition(topLevelName); + + // Walk remaining dotParts as nested types, then explicit nested parts + for (int j = i + 1; j < dotParts.Length && !typeHandle.IsNil; j++) + { + int tpc = (j == dotParts.Length - 1 && parts.Count == 1) ? parts[0].TypeParameterCount : 0; + typeHandle = FindNestedType(metadata, typeHandle, dotParts[j], tpc); + } + + // Walk explicit nested parts (from '.' after `n or {args}) + for (int j = 1; j < parts.Count && !typeHandle.IsNil; j++) + { + typeHandle = FindNestedType(metadata, typeHandle, parts[j].Name, parts[j].TypeParameterCount); + } + + if (!typeHandle.IsNil) + return typeHandle; + + // Try as type forwarder with the same structure + FullTypeName fullTypeName = topLevelName; + for (int j = i + 1; j < dotParts.Length; j++) + { + int tpc = (j == dotParts.Length - 1 && parts.Count == 1) ? parts[0].TypeParameterCount : 0; + fullTypeName = fullTypeName.NestedType(dotParts[j], tpc); + } + for (int j = 1; j < parts.Count; j++) + { + fullTypeName = fullTypeName.NestedType(parts[j].Name, parts[j].TypeParameterCount); + } + var exportedType = module.GetTypeForwarder(fullTypeName); + if (!exportedType.IsNil) + return exportedType; + } + + return default; + } + + /// + /// Finds a nested type by name and type parameter count within a type definition. + /// Returns a nil handle if not found. + /// + static TypeDefinitionHandle FindNestedType(MetadataReader metadata, TypeDefinitionHandle declaringTypeHandle, string name, int typeParameterCount) + { + var typeDef = metadata.GetTypeDefinition(declaringTypeHandle); + string lookupName = typeParameterCount > 0 ? name + "`" + typeParameterCount : name; + foreach (var nestedHandle in typeDef.GetNestedTypes()) + { + var nestedDef = metadata.GetTypeDefinition(nestedHandle); + if (metadata.StringComparer.Equals(nestedDef.Name, lookupName)) + return nestedHandle; + } + return default; + } #endregion } }