From 75e7c61e93606c8af45d37eb5fccfac17fd83af9 Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Tue, 28 Jul 2026 08:51:22 +0200 Subject: [PATCH] Fix #2728: render the MSVC C++/CLI ID string dialect MSVC documents C++/CLI members with ECMA-372-style ID strings that differ from Roslyn's in signatures: custom modifiers are rendered after the modified type (a 'const int' parameter becomes System.Int32!System.Runtime.CompilerServices.IsConst), arity markers stay on generic instantiations (List`1{System.Int32}.Enumerator), and the default indexed property is called 'default'. Roslyn ignores modifiers entirely, so one generated string cannot match both compilers' xml files: GetIdString keeps producing the C#/Roslyn form, and the new GetIdStringCandidates additionally yields the C++/CLI form, most specific first, for lookup code to try in order. The dialect is pinned by IdStringProbe.il/.xml, the trimmed disassembly of an MSVC-compiled probe assembly together with the unmodified xml MSVC generated for it. Notable observed deviations from MSVC's documented format: modreq is generated, but only modreq(IsVolatile) uses the documented '|'; modreq(IsByValue) on conversion operator operands is rendered with '!'. Assisted-by: Claude:claude-fable-5:Claude Code --- .../Documentation/.gitignore | 2 + .../Documentation/IdStringProbe.cpp | 81 ++++ .../Documentation/IdStringProbe.il | 351 ++++++++++++++++++ .../Documentation/IdStringProbe.xml | 68 ++++ .../Documentation/IdStringProviderTests.cs | 117 ++++++ .../Documentation/IdStringProvider.cs | 108 +++++- 6 files changed, 713 insertions(+), 14 deletions(-) create mode 100644 ICSharpCode.Decompiler.Tests/Documentation/.gitignore create mode 100644 ICSharpCode.Decompiler.Tests/Documentation/IdStringProbe.cpp create mode 100644 ICSharpCode.Decompiler.Tests/Documentation/IdStringProbe.il create mode 100644 ICSharpCode.Decompiler.Tests/Documentation/IdStringProbe.xml diff --git a/ICSharpCode.Decompiler.Tests/Documentation/.gitignore b/ICSharpCode.Decompiler.Tests/Documentation/.gitignore new file mode 100644 index 000000000..f0f1af068 --- /dev/null +++ b/ICSharpCode.Decompiler.Tests/Documentation/.gitignore @@ -0,0 +1,2 @@ +/IdStringProbe.dll +/IdStringProbe.pdb diff --git a/ICSharpCode.Decompiler.Tests/Documentation/IdStringProbe.cpp b/ICSharpCode.Decompiler.Tests/Documentation/IdStringProbe.cpp new file mode 100644 index 000000000..dfc719a9e --- /dev/null +++ b/ICSharpCode.Decompiler.Tests/Documentation/IdStringProbe.cpp @@ -0,0 +1,81 @@ +// Probe assembly for MSVC's xml doc ID string generator (the ECMA-372-style +// dialect ILSpy's IdStringProvider must reproduce for C++/CLI assemblies). +// Every documented member below lands in the generated .xml; the members are +// independent, so if one fails to compile, delete it and rebuild. +// +// Build (VS Developer Command Prompt): +// msbuild IdStringProbe.vcxproj -p:Configuration=Debug -p:Platform=x64 +// Return: x64\Debug\IdStringProbe.dll and x64\Debug\IdStringProbe.xml + +using namespace System; +using namespace System::Collections::Generic; + +/// Probe class. +public ref class IdProbe +{ +public: + /// const int parameter; known baseline: System.Int32!System.Runtime.CompilerServices.IsConst + static int ConstValue(const int x) { return x; } + + /// long parameter; modopt(IsLong) on Int32 + static void TakesLong(long x) { (void)x; } + + /// unsigned long parameter; modopt(IsLong) on UInt32 + static void TakesULong(unsigned long x) { (void)x; } + + /// char pointer parameter; modopt(IsSignUnspecifiedByte) under a pointer + static void TakesCharPtr(char* p) { (void)p; } + + /// const char pointer parameter; IsConst and IsSignUnspecifiedByte together (modifier ordering) + static void TakesConstCharPtr(const char* p) { (void)p; } + + /// volatile int pointer parameter; modreq(IsVolatile): the open question is whether MSVC renders '|', '!', or omits it + static void TakesVolatilePtr(volatile int* p) { (void)p; } + + /// const volatile int pointer parameter; modreq and modopt mixed on one type + static void TakesConstVolatilePtr(const volatile int* p) { (void)p; } + + /// tracking reference parameter; expected System.Int32@ + static void TakesTrackingRef(int% r) { r = 0; } + + /// const tracking reference parameter; modifier placement relative to the '@' + static void TakesConstTrackingRef(const int% r) { (void)r; } + + /// native reference parameter under /clr; representation evidence + static void TakesNativeRef(int& r) { r = 0; } + + /// two-dimensional managed array parameter; expected System.Int32[0:,0:] + static void TakesArray2(array^ a) { (void)a; } + + /// nested type of a generic instantiation; how does MSVC distribute the type arguments? + static void TakesEnumerator(List::Enumerator e) { (void)e; } + + /// generic method; expected arity marker and grave-accent parameter encoding + generic static void Gen(T t) { (void)t; } + + /// conversion operator with a by-value ref-class parameter; the MSVC docs show IdProbe!IsByValue and a '~' return type + static explicit operator int(IdProbe x) { (void)x; return 0; } + + /// indexed property with a long parameter; modopt inside the indexer parentheses + property int default[long] + { + int get(long i) { return (int)i; } + void set(long i, int value) { (void)i; (void)value; } + } +}; + +/// Generic ref class; arity in the type ID. +generic public ref class GBox +{ +public: + /// method on a generic type taking T; expected grave-accent zero + void Hold(T item) { (void)item; } +}; + +/// Consumer of an instantiated generic type. +public ref class GBoxUser +{ +public: + /// generic instantiation in a signature; expected GBox{System.Int32} + static void Use(GBox^ b) { (void)b; } +}; diff --git a/ICSharpCode.Decompiler.Tests/Documentation/IdStringProbe.il b/ICSharpCode.Decompiler.Tests/Documentation/IdStringProbe.il new file mode 100644 index 000000000..19b14bde2 --- /dev/null +++ b/ICSharpCode.Decompiler.Tests/Documentation/IdStringProbe.il @@ -0,0 +1,351 @@ +// Trimmed disassembly of an assembly compiled with MSVC from IdStringProbe.cpp +// (next to this file) with /clr /doc. The signatures are verbatim from the +// MSVC-produced metadata; the accompanying IdStringProbe.xml is the unmodified +// output of MSVC's xml doc generator for the same compilation and serves as the +// reference for the C++/CLI (ECMA-372-style) ID string dialect. + +.assembly extern mscorlib +{ + .ver 4:0:0:0 +} +.assembly IdStringProbe +{ + .ver 0:0:0:0 +} +.module IdStringProbe.dll + +.class public auto ansi beforefieldinit IdProbe + extends [mscorlib]System.Object +{ + .custom instance void [mscorlib]System.Reflection.DefaultMemberAttribute::.ctor(string) = ( + 01 00 04 49 74 65 6d 00 00 + ) + // Methods + .method public hidebysig static + int32 ConstValue ( + int32 modopt([mscorlib]System.Runtime.CompilerServices.IsConst) x + ) cil managed + { + // Method begins at RVA 0x10e0 + // Header size: 12 + // Code size: 4 (0x4) + .maxstack 1 + .locals ( + [0] int32 + ) + + IL_0000: ldarg.0 + IL_0001: stloc.0 + IL_0002: ldloc.0 + IL_0003: ret + } // end of method IdProbe::ConstValue + + .method public hidebysig static + void TakesLong ( + int32 modopt([mscorlib]System.Runtime.CompilerServices.IsLong) x + ) cil managed + { + // Method begins at RVA 0x10f0 + // Header size: 12 + // Code size: 1 (0x1) + .maxstack 0 + + IL_0000: ret + } // end of method IdProbe::TakesLong + + .method public hidebysig static + void TakesULong ( + uint32 modopt([mscorlib]System.Runtime.CompilerServices.IsLong) x + ) cil managed + { + // Method begins at RVA 0x1100 + // Header size: 12 + // Code size: 1 (0x1) + .maxstack 0 + + IL_0000: ret + } // end of method IdProbe::TakesULong + + .method public hidebysig static + void TakesCharPtr ( + int8 modopt([mscorlib]System.Runtime.CompilerServices.IsSignUnspecifiedByte)* p + ) cil managed + { + // Method begins at RVA 0x1110 + // Header size: 12 + // Code size: 1 (0x1) + .maxstack 0 + + IL_0000: ret + } // end of method IdProbe::TakesCharPtr + + .method public hidebysig static + void TakesConstCharPtr ( + int8 modopt([mscorlib]System.Runtime.CompilerServices.IsSignUnspecifiedByte) modopt([mscorlib]System.Runtime.CompilerServices.IsConst)* p + ) cil managed + { + // Method begins at RVA 0x1120 + // Header size: 12 + // Code size: 1 (0x1) + .maxstack 0 + + IL_0000: ret + } // end of method IdProbe::TakesConstCharPtr + + .method public hidebysig static + void TakesVolatilePtr ( + int32 modreq([mscorlib]System.Runtime.CompilerServices.IsVolatile)* p + ) cil managed + { + // Method begins at RVA 0x1130 + // Header size: 12 + // Code size: 1 (0x1) + .maxstack 0 + + IL_0000: ret + } // end of method IdProbe::TakesVolatilePtr + + .method public hidebysig static + void TakesConstVolatilePtr ( + int32 modreq([mscorlib]System.Runtime.CompilerServices.IsVolatile) modopt([mscorlib]System.Runtime.CompilerServices.IsConst)* p + ) cil managed + { + // Method begins at RVA 0x1140 + // Header size: 12 + // Code size: 1 (0x1) + .maxstack 0 + + IL_0000: ret + } // end of method IdProbe::TakesConstVolatilePtr + + .method public hidebysig static + void TakesTrackingRef ( + int32& r + ) cil managed + { + // Method begins at RVA 0x1150 + // Header size: 12 + // Code size: 4 (0x4) + .maxstack 2 + + IL_0000: ldarg.0 + IL_0001: ldc.i4.0 + IL_0002: stind.i4 + IL_0003: ret + } // end of method IdProbe::TakesTrackingRef + + .method public hidebysig static + void TakesConstTrackingRef ( + int32 modopt([mscorlib]System.Runtime.CompilerServices.IsConst)& r + ) cil managed + { + // Method begins at RVA 0x1160 + // Header size: 12 + // Code size: 1 (0x1) + .maxstack 0 + + IL_0000: ret + } // end of method IdProbe::TakesConstTrackingRef + + .method public hidebysig static + void TakesNativeRef ( + int32* modopt([mscorlib]System.Runtime.CompilerServices.IsImplicitlyDereferenced) r + ) cil managed + { + // Method begins at RVA 0x1170 + // Header size: 12 + // Code size: 4 (0x4) + .maxstack 2 + + IL_0000: ldarg.0 + IL_0001: ldc.i4.0 + IL_0002: stind.i4 + IL_0003: ret + } // end of method IdProbe::TakesNativeRef + + .method public hidebysig static + void TakesArray2 ( + int32[0..., 0...] a + ) cil managed + { + // Method begins at RVA 0x1180 + // Header size: 12 + // Code size: 1 (0x1) + .maxstack 0 + + IL_0000: ret + } // end of method IdProbe::TakesArray2 + + .method public hidebysig static + void TakesEnumerator ( + valuetype [mscorlib]System.Collections.Generic.List`1/Enumerator e + ) cil managed + { + // Method begins at RVA 0x1190 + // Header size: 12 + // Code size: 1 (0x1) + .maxstack 0 + + IL_0000: ret + } // end of method IdProbe::TakesEnumerator + + .method public hidebysig static + void Gen ( + !!T t + ) cil managed + { + // Method begins at RVA 0x11a0 + // Header size: 12 + // Code size: 3 (0x3) + .maxstack 1 + .locals ( + [0] !!T + ) + + IL_0000: ldarg.0 + IL_0001: stloc.0 + IL_0002: ret + } // end of method IdProbe::Gen + + .method public hidebysig specialname static + int32 op_Explicit ( + class IdProbe modreq([mscorlib]System.Runtime.CompilerServices.IsByValue) x + ) cil managed + { + // Method begins at RVA 0x11b0 + // Header size: 12 + // Code size: 4 (0x4) + .maxstack 1 + .locals ( + [0] int32 + ) + + IL_0000: ldc.i4.0 + IL_0001: stloc.0 + IL_0002: ldloc.0 + IL_0003: ret + } // end of method IdProbe::op_Explicit + + .method public hidebysig specialname + instance int32 get_Item ( + int32 modopt([mscorlib]System.Runtime.CompilerServices.IsLong) i + ) cil managed + { + // Method begins at RVA 0x11c0 + // Header size: 12 + // Code size: 4 (0x4) + .maxstack 1 + .locals ( + [0] int32 + ) + + IL_0000: ldarg.1 + IL_0001: stloc.0 + IL_0002: ldloc.0 + IL_0003: ret + } // end of method IdProbe::get_Item + + .method public hidebysig specialname + instance void set_Item ( + int32 modopt([mscorlib]System.Runtime.CompilerServices.IsLong) i, + int32 'value' + ) cil managed + { + // Method begins at RVA 0x11d0 + // Header size: 12 + // Code size: 1 (0x1) + .maxstack 0 + + IL_0000: ret + } // end of method IdProbe::set_Item + + .method public hidebysig specialname rtspecialname + instance void .ctor () cil managed + { + // Method begins at RVA 0x11e0 + // Header size: 12 + // Code size: 7 (0x7) + .maxstack 1 + + IL_0000: ldarg.0 + IL_0001: call instance void [mscorlib]System.Object::.ctor() + IL_0006: ret + } // end of method IdProbe::.ctor + + // Properties + .property instance int32 Item( + int32 modopt([mscorlib]System.Runtime.CompilerServices.IsLong) i + ) + { + .get instance int32 IdProbe::get_Item(int32 modopt([mscorlib]System.Runtime.CompilerServices.IsLong)) + .set instance void IdProbe::set_Item(int32 modopt([mscorlib]System.Runtime.CompilerServices.IsLong), int32) + } + +} // end of class IdProbe +.class public auto ansi beforefieldinit GBox`1 + extends [mscorlib]System.Object +{ + // Methods + .method public hidebysig + instance void Hold ( + !T item + ) cil managed + { + // Method begins at RVA 0x11f4 + // Header size: 12 + // Code size: 3 (0x3) + .maxstack 1 + .locals ( + [0] !T + ) + + IL_0000: ldarg.1 + IL_0001: stloc.0 + IL_0002: ret + } // end of method GBox`1::Hold + + .method public hidebysig specialname rtspecialname + instance void .ctor () cil managed + { + // Method begins at RVA 0x1204 + // Header size: 12 + // Code size: 7 (0x7) + .maxstack 1 + + IL_0000: ldarg.0 + IL_0001: call instance void [mscorlib]System.Object::.ctor() + IL_0006: ret + } // end of method GBox`1::.ctor + +} // end of class GBox`1 +.class public auto ansi beforefieldinit GBoxUser + extends [mscorlib]System.Object +{ + // Methods + .method public hidebysig static + void Use ( + class GBox`1 b + ) cil managed + { + // Method begins at RVA 0x1218 + // Header size: 12 + // Code size: 1 (0x1) + .maxstack 0 + + IL_0000: ret + } // end of method GBoxUser::Use + + .method public hidebysig specialname rtspecialname + instance void .ctor () cil managed + { + // Method begins at RVA 0x1228 + // Header size: 12 + // Code size: 7 (0x7) + .maxstack 1 + + IL_0000: ldarg.0 + IL_0001: call instance void [mscorlib]System.Object::.ctor() + IL_0006: ret + } // end of method GBoxUser::.ctor + +} // end of class GBoxUser diff --git a/ICSharpCode.Decompiler.Tests/Documentation/IdStringProbe.xml b/ICSharpCode.Decompiler.Tests/Documentation/IdStringProbe.xml new file mode 100644 index 000000000..07f746190 --- /dev/null +++ b/ICSharpCode.Decompiler.Tests/Documentation/IdStringProbe.xml @@ -0,0 +1,68 @@ + + + + "IdStringProbe" + + + + generic instantiation in a signature; expected GBox{System.Int32} + + + Consumer of an instantiated generic type. + + + method on a generic type taking T; expected grave-accent zero + + + Generic ref class; arity in the type ID. + + + indexed property with a long parameter; modopt inside the indexer parentheses + + + conversion operator with a by-value ref-class parameter; the MSVC docs show IdProbe!IsByValue and a '~' return type + + + generic method; expected arity marker and grave-accent parameter encoding + + + nested type of a generic instantiation; how does MSVC distribute the type arguments? + + + two-dimensional managed array parameter; expected System.Int32[0:,0:] + + + native reference parameter under /clr; representation evidence + + + const tracking reference parameter; modifier placement relative to the '@' + + + tracking reference parameter; expected System.Int32@ + + + const volatile int pointer parameter; modreq and modopt mixed on one type + + + volatile int pointer parameter; modreq(IsVolatile): the open question is whether MSVC renders '|', '!', or omits it + + + const char pointer parameter; IsConst and IsSignUnspecifiedByte together (modifier ordering) + + + char pointer parameter; modopt(IsSignUnspecifiedByte) under a pointer + + + unsigned long parameter; modopt(IsLong) on UInt32 + + + long parameter; modopt(IsLong) on Int32 + + + const int parameter; known baseline: System.Int32!System.Runtime.CompilerServices.IsConst + + + Probe class. + + + \ No newline at end of file diff --git a/ICSharpCode.Decompiler.Tests/Documentation/IdStringProviderTests.cs b/ICSharpCode.Decompiler.Tests/Documentation/IdStringProviderTests.cs index 2b167fa69..e065904ad 100644 --- a/ICSharpCode.Decompiler.Tests/Documentation/IdStringProviderTests.cs +++ b/ICSharpCode.Decompiler.Tests/Documentation/IdStringProviderTests.cs @@ -2041,6 +2041,63 @@ namespace ModreqParams Assert.That(idString, Is.EqualTo("M:Host.M(System.Int32[1:5,3:])")); } + [Test] + public void Modifier_Optional() + { + // The C#/Roslyn form ignores custom modifiers; the MSVC C++/CLI form renders + // modopt as '!' + modifier following the modified type, as C++/CLI 'const int' + // parameters show (see https://github.com/icsharpcode/ILSpy/issues/2728). + var pe = BuildAssemblyWithMethodSignature((metadata, parameter) => { + parameter.CustomModifiers().AddModifier( + AddCompilerServicesTypeRef(metadata, "IsConst"), isOptional: true); + parameter.Type().Int32(); + }); + Assert.That(pe.GetIdString(MetadataTokens.MethodDefinitionHandle(1)), + Is.EqualTo("M:Host.M(System.Int32)")); + Assert.That(pe.GetIdStringCandidates(MetadataTokens.MethodDefinitionHandle(1)), + Is.EqualTo(new[] { + "M:Host.M(System.Int32!System.Runtime.CompilerServices.IsConst)", + "M:Host.M(System.Int32)", + })); + } + + [Test] + public void Modifier_Required() + { + // The C#/Roslyn form ignores modreq (e.g. modreq(InAttribute) on virtual 'in' + // parameters is documented as T@); the MSVC form renders modreq(IsVolatile) + // with '|' as observed in MSVC-generated xml doc files. + var pe = BuildAssemblyWithMethodSignature((metadata, parameter) => { + parameter.CustomModifiers().AddModifier( + AddCompilerServicesTypeRef(metadata, "IsVolatile"), isOptional: false); + parameter.Type().Int32(); + }); + Assert.That(pe.GetIdString(MetadataTokens.MethodDefinitionHandle(1)), + Is.EqualTo("M:Host.M(System.Int32)")); + Assert.That(pe.GetIdStringCandidates(MetadataTokens.MethodDefinitionHandle(1)), + Is.EqualTo(new[] { + "M:Host.M(System.Int32|System.Runtime.CompilerServices.IsVolatile)", + "M:Host.M(System.Int32)", + })); + } + + [Test] + public void Modifier_OptionalUnderPointer() + { + // C++/CLI 'char*' emits int8 modopt(IsSignUnspecifiedByte)*, rendered by MSVC as + // System.SByte!System.Runtime.CompilerServices.IsSignUnspecifiedByte* + var pe = BuildAssemblyWithMethodSignature((metadata, parameter) => { + var pointee = parameter.Type().Pointer(); + pointee.CustomModifiers().AddModifier( + AddCompilerServicesTypeRef(metadata, "IsSignUnspecifiedByte"), isOptional: true); + pointee.SByte(); + }); + Assert.That(pe.GetIdString(MetadataTokens.MethodDefinitionHandle(1)), + Is.EqualTo("M:Host.M(System.SByte*)")); + Assert.That(pe.GetIdStringCandidates(MetadataTokens.MethodDefinitionHandle(1)), + Does.Contain("M:Host.M(System.SByte!System.Runtime.CompilerServices.IsSignUnspecifiedByte*)")); + } + [Test] public void Pinned_Suffix() { @@ -2057,8 +2114,68 @@ namespace ModreqParams #endregion + #region MSVC C++/CLI dialect fixture + + // IdStringProbe.il is the trimmed disassembly of an MSVC-compiled C++/CLI assembly + // and IdStringProbe.xml the unmodified xml doc file MSVC generated for it; every + // member key MSVC wrote must be reachable through the ID string candidates. + + static async Task AssembleIdStringProbe() + { + string dir = Path.Combine(Tester.TesterPath, "../../../../Documentation"); + string dll = await Tester.AssembleIL(Path.Combine(dir, "IdStringProbe.il"), AssemblerOptions.Library); + return new PEFile(dll); + } + + static HashSet CollectIdStringCandidates(PEFile pe) + { + var md = pe.Metadata; + var candidates = new HashSet(); + foreach (var th in md.TypeDefinitions) + { + var td = md.GetTypeDefinition(th); + if (md.GetString(td.Name) == "") + continue; + candidates.UnionWith(pe.GetIdStringCandidates(th)); + foreach (var h in td.GetMethods()) + candidates.UnionWith(pe.GetIdStringCandidates(h)); + foreach (var h in td.GetProperties()) + candidates.UnionWith(pe.GetIdStringCandidates(h)); + foreach (var h in td.GetEvents()) + candidates.UnionWith(pe.GetIdStringCandidates(h)); + foreach (var h in td.GetFields()) + candidates.UnionWith(pe.GetIdStringCandidates(h)); + } + return candidates; + } + + [Test] + public async Task MsvcCppCliXml_AllMemberIdsCovered() + { + var pe = await AssembleIdStringProbe(); + var candidates = CollectIdStringCandidates(pe); + string xmlPath = Path.Combine(Tester.TesterPath, "../../../../Documentation/IdStringProbe.xml"); + Assert.Multiple(() => { + foreach (var member in System.Xml.Linq.XDocument.Load(xmlPath).Descendants("member")) + { + string name = member.Attribute("name").Value; + Assert.That(candidates, Does.Contain(name), name); + } + }); + } + + #endregion + #region Hand-built metadata helpers + static TypeReferenceHandle AddCompilerServicesTypeRef(MetadataBuilder metadata, string name) + { + var mscorlib = metadata.AddAssemblyReference(metadata.GetOrAddString("mscorlib"), + new Version(4, 0, 0, 0), default, default, 0, default); + return metadata.AddTypeReference(mscorlib, + metadata.GetOrAddString("System.Runtime.CompilerServices"), metadata.GetOrAddString(name)); + } + static PEFile BuildAssemblyWithMethodSignature(Action encodeParameter) { return BuildAssemblyWithMethods(encodeParameter); diff --git a/ICSharpCode.Decompiler/Documentation/IdStringProvider.cs b/ICSharpCode.Decompiler/Documentation/IdStringProvider.cs index 881762a07..df073a459 100644 --- a/ICSharpCode.Decompiler/Documentation/IdStringProvider.cs +++ b/ICSharpCode.Decompiler/Documentation/IdStringProvider.cs @@ -36,9 +36,38 @@ namespace ICSharpCode.Decompiler.Documentation { #region GetIdString /// - /// Gets the ID string (C# 4.0 spec, §A.3.1) for the specified entity. + /// Gets the ID string (C# 4.0 spec, §A.3.1) for the specified entity, + /// in the form the C# compiler writes into xml documentation files. /// public static string GetIdString(this MetadataFile module, EntityHandle handle) + { + return GetIdString(module, handle, cppCliDialect: false); + } + + /// + /// Gets the ID string candidates for the entity, most specific first: the MSVC + /// C++/CLI (ECMA-372-style) form when it differs from the C#/Roslyn form, then the + /// C#/Roslyn form. Documentation lookup should try the candidates in order, so that + /// xml doc files written by either compiler can be matched. The dialects differ in + /// signatures only: Roslyn ignores custom modifiers and strips the arity marker of + /// instantiated generic types, while MSVC renders modifiers ('!' or '|' followed by + /// the modifier type), keeps arity markers, and refers to a default indexed + /// property as 'default'. The C++/CLI form comes first because wherever it differs + /// it contains character sequences Roslyn never writes, so it can only match + /// MSVC-generated keys; the stripped Roslyn form of one member can collide with the + /// key of a different member in an MSVC-generated file (e.g. overloads differing + /// only in a custom modifier). + /// + public static IEnumerable GetIdStringCandidates(this MetadataFile module, EntityHandle handle) + { + string primary = GetIdString(module, handle, cppCliDialect: false); + string cppCli = GetIdString(module, handle, cppCliDialect: true); + if (cppCli != primary) + yield return cppCli; + yield return primary; + } + + static string GetIdString(MetadataFile module, EntityHandle handle, bool cppCliDialect) { if (handle.IsNil) throw new ArgumentException("The handle must not be nil.", nameof(handle)); @@ -60,12 +89,12 @@ namespace ICSharpCode.Decompiler.Documentation case HandleKind.MethodDefinition: b.Append("M:"); - AppendMethodIdString(b, metadata, (MethodDefinitionHandle)handle); + AppendMethodIdString(b, metadata, (MethodDefinitionHandle)handle, cppCliDialect); break; case HandleKind.PropertyDefinition: b.Append("P:"); - AppendPropertyIdString(b, metadata, (PropertyDefinitionHandle)handle); + AppendPropertyIdString(b, metadata, (PropertyDefinitionHandle)handle, cppCliDialect); break; case HandleKind.EventDefinition: @@ -154,7 +183,7 @@ namespace ICSharpCode.Decompiler.Documentation b.Append(metadata.GetString(fieldDef.Name)); } - static void AppendMethodIdString(StringBuilder b, MetadataReader metadata, MethodDefinitionHandle handle) + static void AppendMethodIdString(StringBuilder b, MetadataReader metadata, MethodDefinitionHandle handle, bool cppCliDialect) { var methodDef = metadata.GetMethodDefinition(handle); var declaringType = methodDef.GetDeclaringType(); @@ -175,7 +204,7 @@ namespace ICSharpCode.Decompiler.Documentation // Parameters var signature = methodDef.DecodeSignature( - new IdStringSignatureTypeProvider(), + new IdStringSignatureTypeProvider(cppCliDialect), new MetadataGenericContext(handle, metadata)); AppendParameterList(b, signature.ParameterTypes); @@ -202,7 +231,7 @@ namespace ICSharpCode.Decompiler.Documentation } } - static void AppendPropertyIdString(StringBuilder b, MetadataReader metadata, PropertyDefinitionHandle handle) + static void AppendPropertyIdString(StringBuilder b, MetadataReader metadata, PropertyDefinitionHandle handle, bool cppCliDialect) { var propertyDef = metadata.GetPropertyDefinition(handle); @@ -211,15 +240,49 @@ namespace ICSharpCode.Decompiler.Documentation b.Append('.'); var signature = propertyDef.DecodeSignature( - new IdStringSignatureTypeProvider(), + new IdStringSignatureTypeProvider(cppCliDialect), new MetadataGenericContext(declaringType, metadata)); - b.Append(metadata.GetString(propertyDef.Name).Replace('.', '#').Replace('<', '{').Replace('>', '}')); + string name = metadata.GetString(propertyDef.Name); + // The MSVC xml doc generator refers to a type's default indexed property by the + // C++/CLI keyword 'default' instead of the property's metadata name. + if (cppCliDialect && signature.ParameterTypes.Length > 0 + && name == GetDefaultMemberName(metadata, declaringType)) + { + b.Append("default"); + } + else + { + b.Append(name.Replace('.', '#').Replace('<', '{').Replace('>', '}')); + } // Indexers have parameters AppendParameterList(b, signature.ParameterTypes); } + static string GetDefaultMemberName(MetadataReader metadata, TypeDefinitionHandle declaringType) + { + foreach (var h in metadata.GetTypeDefinition(declaringType).GetCustomAttributes()) + { + var customAttribute = metadata.GetCustomAttribute(h); + if (!customAttribute.IsKnownAttribute(metadata, KnownAttribute.DefaultMember)) + continue; + try + { + var value = customAttribute.DecodeValue(Metadata.MetadataExtensions.MinimalAttributeTypeProvider); + if (value.FixedArguments.Length == 1 && value.FixedArguments[0].Value is string name) + return name; + } + catch (BadImageFormatException) + { + } + catch (Metadata.EnumUnderlyingTypeResolveException) + { + } + } + return null; + } + static TypeDefinitionHandle FindDeclaringTypeOfProperty(MetadataReader metadata, PropertyDefinitionHandle propertyHandle) { var accessors = metadata.GetPropertyDefinition(propertyHandle).GetAccessors(); @@ -278,10 +341,15 @@ namespace ICSharpCode.Decompiler.Documentation static bool IsAsciiDigit(char c) => c >= '0' && c <= '9'; /// - /// Signature type provider that produces ID string fragments. + /// Signature type provider that produces ID string fragments. With + /// set, produces the MSVC C++/CLI form + /// (custom modifiers rendered, arity markers kept on generic instantiations) + /// instead of the C#/Roslyn form. /// - readonly struct IdStringSignatureTypeProvider : ISignatureTypeProvider + readonly struct IdStringSignatureTypeProvider(bool cppCliDialect) : ISignatureTypeProvider { + readonly bool cppCliDialect = cppCliDialect; + public string GetPrimitiveType(PrimitiveTypeCode typeCode) { return typeCode switch { @@ -370,6 +438,10 @@ namespace ICSharpCode.Decompiler.Documentation } else { + // MSVC keeps the arity marker in front of the argument list + // (List`1{System.Int32}); Roslyn strips it (List{System.Int32}). + if (cppCliDialect) + sb.Append(genericType, i, markerEnd - i); sb.Append('{'); for (int k = 0; k < arity; k++) { @@ -459,10 +531,18 @@ namespace ICSharpCode.Decompiler.Documentation public string GetModifiedType(string modifier, string unmodifiedType, bool isRequired) { - // Custom modifiers are not part of the ID string: Roslyn ignores them (e.g. a - // virtual method's 'in' parameter carries modreq(InAttribute) but is - // documented as T@). - return unmodifiedType; + // Roslyn ignores custom modifiers entirely (e.g. a virtual method's 'in' + // parameter carries modreq(InAttribute) but is documented as T@). + if (!cppCliDialect) + return unmodifiedType; + // The MSVC xml doc generator renders a modifier after the modified type, + // e.g. System.Int32!System.Runtime.CompilerServices.IsConst for a C++/CLI + // 'const int' parameter. Its documented mapping is '!' for modopt and '|' + // for modreq, but observed output uses '|' only for modreq(IsVolatile); + // modreq(IsByValue) on conversion operator operands is rendered with '!'. + char prefix = isRequired && modifier == "System.Runtime.CompilerServices.IsVolatile" + ? '|' : '!'; + return unmodifiedType + prefix + modifier; } public string GetPinnedType(string elementType)