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 } }