diff --git a/ICSharpCode.Decompiler/Documentation/XmlDocLoader.cs b/ICSharpCode.Decompiler/Documentation/XmlDocLoader.cs index ff3c64bcd..42f1be33c 100644 --- a/ICSharpCode.Decompiler/Documentation/XmlDocLoader.cs +++ b/ICSharpCode.Decompiler/Documentation/XmlDocLoader.cs @@ -17,9 +17,11 @@ // DEALINGS IN THE SOFTWARE. using System; +using System.Collections.Generic; using System.Diagnostics; using System.Globalization; using System.IO; +using System.Linq; using System.Runtime.CompilerServices; using ICSharpCode.Decompiler.Metadata; @@ -64,18 +66,146 @@ namespace ICSharpCode.Decompiler.Documentation if (xmlDocFile != null) { xmlDoc = new XmlDocumentationProvider(xmlDocFile); - cache.Add(module, xmlDoc); } else { - cache.Add(module, null); // add missing documentation files as well - xmlDoc = null; + // Last resort for modern .NET (.NET 5+): runtime DLLs ship under + // dotnet/shared/Microsoft.NETCore.App// with no .xml beside + // them. The matching XML lives in the parallel ref pack under + // dotnet/packs/Microsoft.NETCore.App.Ref//ref//. Aggregate + // every *.xml in the matching tfm folder into a single provider so + // type-forwarded entities (System.String docs live in System.Runtime.xml + // but the metadata token comes from System.Private.CoreLib.dll) resolve + // transparently. + xmlDoc = TryLoadModernRefPackDocumentation(module.FileName); } + cache.Add(module, xmlDoc); // cache null misses too — avoids re-scanning disk } return xmlDoc; } } + static XmlDocumentationProvider TryLoadModernRefPackDocumentation(string assemblyFileName) + { + var refPackTfmDir = ResolveModernRefPackTfmDirectory(assemblyFileName); + if (refPackTfmDir == null) + return null; + List providers = null; + foreach (var xmlPath in SafeGetXmlFiles(refPackTfmDir)) + { + XmlDocumentationProvider p; + try + { + p = new XmlDocumentationProvider(xmlPath); + } + catch + { + // One bad XML doesn't disable the rest — skip and continue. + continue; + } + (providers ??= new List()).Add(p); + } + return providers is { Count: > 0 } ? new AggregatingXmlDocumentationProvider(providers) : null; + } + + /// + /// Maps a runtime-DLL path like + /// X:\Program Files\dotnet\shared\Microsoft.NETCore.App\10.0.0\System.Private.CoreLib.dll + /// to the matching ref-pack tfm directory + /// X:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\10.0.0\ref\net10.0. + /// Returns null when the assembly isn't laid out under + /// shared/Microsoft.NETCore.App/<version>/ or when the parallel ref pack + /// isn't installed. + /// + internal static string ResolveModernRefPackTfmDirectory(string assemblyFileName) + { + if (string.IsNullOrEmpty(assemblyFileName)) + return null; + var versionDir = Path.GetDirectoryName(assemblyFileName); + if (versionDir == null) + return null; + var sharedFramework = Path.GetDirectoryName(versionDir); + if (sharedFramework == null || !string.Equals(Path.GetFileName(sharedFramework), "Microsoft.NETCore.App", StringComparison.OrdinalIgnoreCase)) + return null; + var sharedDir = Path.GetDirectoryName(sharedFramework); + if (sharedDir == null || !string.Equals(Path.GetFileName(sharedDir), "shared", StringComparison.OrdinalIgnoreCase)) + return null; + var dotnetRoot = Path.GetDirectoryName(sharedDir); + if (dotnetRoot == null) + return null; + var version = Path.GetFileName(versionDir); + var refPackRoot = Path.Combine(dotnetRoot, "packs", "Microsoft.NETCore.App.Ref", version, "ref"); + if (!Directory.Exists(refPackRoot)) + return null; + // One tfm folder per pack version (e.g. "net10.0"). Sort by parsed + // (Major, Minor) — lexicographic compare puts "net10.0" behind "net9.0" because + // '1' < '9'. Multiple tfms only show up across pack versions, not within one. + return Directory.GetDirectories(refPackRoot) + .OrderByDescending(d => ParseTfm(Path.GetFileName(d))) + .FirstOrDefault(); + } + + static (int Major, int Minor) ParseTfm(string tfm) + { + // Avoid Span overloads — the shared library targets older TFMs that don't have + // int.TryParse(ReadOnlySpan, out int). + if (tfm == null || !tfm.StartsWith("net", StringComparison.OrdinalIgnoreCase)) + return (-1, -1); + var rest = tfm.Substring(3); + var dot = rest.IndexOf('.'); + if (dot < 0) + return (-1, -1); + if (!int.TryParse(rest.Substring(0, dot), out int major)) + return (-1, -1); + if (!int.TryParse(rest.Substring(dot + 1), out int minor)) + return (-1, -1); + return (major, minor); + } + + static IEnumerable SafeGetXmlFiles(string directory) + { + try + { + return Directory.GetFiles(directory, "*.xml"); + } + catch + { + return Array.Empty(); + } + } + + /// + /// Aggregates several instances behind one + /// surface. Used by + /// because in modern .NET an + /// entity's metadata-token-bearing assembly (System.Private.CoreLib.dll) is + /// different from the assembly whose XML contains its docs (System.Runtime.xml): + /// we have to probe every ref-pack XML for the same id string until one matches. + /// First non-empty answer wins; id strings are unique across the pack. + /// + sealed class AggregatingXmlDocumentationProvider : XmlDocumentationProvider + { + readonly IReadOnlyList providers; + + public AggregatingXmlDocumentationProvider(IReadOnlyList providers) : base() + { + this.providers = providers; + } + + public override string GetDocumentation(string key) + { + if (key == null) + throw new ArgumentNullException(nameof(key)); + foreach (var p in providers) + { + var doc = p.GetDocumentation(key); + if (!string.IsNullOrEmpty(doc)) + return doc; + } + return null; + } + } + static readonly string referenceAssembliesPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), @"Reference Assemblies\Microsoft\\Framework"); static readonly string frameworkPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Windows), @"Microsoft.NET\Framework"); diff --git a/ICSharpCode.Decompiler/Documentation/XmlDocumentationProvider.cs b/ICSharpCode.Decompiler/Documentation/XmlDocumentationProvider.cs index cae69da09..13dec93aa 100644 --- a/ICSharpCode.Decompiler/Documentation/XmlDocumentationProvider.cs +++ b/ICSharpCode.Decompiler/Documentation/XmlDocumentationProvider.cs @@ -123,6 +123,16 @@ namespace ICSharpCode.Decompiler.Documentation volatile IndexEntry[] index; // SORTED array of index entries #region Constructor / Redirection support + /// + /// Constructor for subclasses that aggregate multiple + /// instances (see the modern .NET ref-pack lookup in ). + /// The base class's index stays empty; subclasses must override + /// to fan out across their contributors. + /// + protected XmlDocumentationProvider() + { + } + /// /// Creates a new XmlDocumentationProvider. /// @@ -313,7 +323,7 @@ namespace ICSharpCode.Decompiler.Documentation /// /// Get the documentation for the member with the specified documentation key. /// - public string GetDocumentation(string key) + public virtual string GetDocumentation(string key) { if (key == null) throw new ArgumentNullException(nameof(key)); @@ -323,7 +333,7 @@ namespace ICSharpCode.Decompiler.Documentation /// /// Get the documentation for the specified member. /// - public string GetDocumentation(IEntity entity) + public virtual string GetDocumentation(IEntity entity) { if (entity == null) throw new ArgumentNullException(nameof(entity)); diff --git a/ILSpy.Tests/Editor/XmlDocumentationTests.cs b/ILSpy.Tests/Editor/XmlDocumentationTests.cs index db1c83fb3..e209adedd 100644 --- a/ILSpy.Tests/Editor/XmlDocumentationTests.cs +++ b/ILSpy.Tests/Editor/XmlDocumentationTests.cs @@ -27,7 +27,6 @@ using ICSharpCode.Decompiler.Documentation; using ICSharpCode.Decompiler.TypeSystem; using ILSpy.AppEnv; -using ILSpy.TextView; using ILSpy.TreeNodes; using ILSpy.ViewModels; using ILSpy.Views; @@ -67,13 +66,14 @@ public class XmlDocumentationTests Assert.That(concat!.ParentModule, Is.Not.Null); Assert.That(concat.ParentModule!.MetadataFile, Is.Not.Null); - // Modern .NET runtime DLLs don't ship .xml beside them — XmlDocLoader can't find - // CoreLib's docs at runtime. The Avalonia ModernXmlDocLookup walks the parallel - // ref pack (/packs/Microsoft.NETCore.App.Ref//ref//*.xml) - // and aggregates every XML there into a single provider. - var provider = ModernXmlDocLookup.TryGetProvider(concat.ParentModule.MetadataFile!); + // XmlDocLoader's modern-.NET fallback (added in the shared decompiler library) walks + // the parallel ref pack — /packs/Microsoft.NETCore.App.Ref//ref//*.xml + // — and aggregates every XML there into a single provider, since each entity's + // metadata-token-bearing assembly (System.Private.CoreLib.dll) differs from the one + // whose XML carries its docs (System.Runtime.xml). + var provider = XmlDocLoader.LoadDocumentation(concat.ParentModule.MetadataFile!); ((object?)provider).Should().NotBeNull( - "ModernXmlDocLookup must locate the ref-pack XML for the test-host runtime layout"); + "XmlDocLoader's modern-.NET ref-pack fallback must locate XMLs for the test-host runtime layout"); var documentation = provider!.GetDocumentation(concat.GetIdString()); documentation.Should().NotBeNullOrEmpty( diff --git a/ILSpy/TextView/DecompilerTextView.axaml.cs b/ILSpy/TextView/DecompilerTextView.axaml.cs index 940eb3f06..beda8714c 100644 --- a/ILSpy/TextView/DecompilerTextView.axaml.cs +++ b/ILSpy/TextView/DecompilerTextView.axaml.cs @@ -653,13 +653,10 @@ namespace ILSpy.TextView { if (entity.ParentModule?.MetadataFile is not { } metadata) return; - var idString = entity.GetIdString(); - // First-cut: try the shared XmlDocLoader (handles "xml beside dll" + .NET - // Framework reference pack paths). When that returns null — typical for - // modern .NET runtime DLLs whose XML lives in the parallel ref pack — fall - // back to ModernXmlDocLookup which walks dotnet/packs/... - var documentation = XmlDocLoader.LoadDocumentation(metadata)?.GetDocumentation(idString) - ?? ModernXmlDocLookup.TryGetProvider(metadata)?.GetDocumentation(idString); + // XmlDocLoader handles every layout the decompiler library knows about: .xml + // beside the .dll, .NET Framework reference-assemblies paths, and (recently) + // the modern .NET ref pack at dotnet/packs/Microsoft.NETCore.App.Ref/... + var documentation = XmlDocLoader.LoadDocumentation(metadata)?.GetDocumentation(entity.GetIdString()); if (documentation == null) return; // First-cut: no cref resolver, so falls back to printing the diff --git a/ILSpy/TextView/ModernXmlDocLookup.cs b/ILSpy/TextView/ModernXmlDocLookup.cs deleted file mode 100644 index c4ba3c5bd..000000000 --- a/ILSpy/TextView/ModernXmlDocLookup.cs +++ /dev/null @@ -1,191 +0,0 @@ -// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team -// -// 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.IO; -using System.Linq; -using System.Runtime.CompilerServices; - -using ICSharpCode.Decompiler.Documentation; -using ICSharpCode.Decompiler.Metadata; - -namespace ILSpy.TextView -{ - /// - /// Fallback XML-documentation locator for modern .NET (.NET 5+). The shared - /// only knows about the .NET Framework reference-assemblies - /// paths (v1.0 – v4.8.1) and the ".xml beside the .dll" convention. Modern - /// runtime DLLs (System.Private.CoreLib.dll, System.Linq.dll, …) live - /// under dotnet/shared/Microsoft.NETCore.App/<version>/ with no - /// matching XML; the actual XML files ship in the parallel reference pack under - /// dotnet/packs/Microsoft.NETCore.App.Ref/<version>/ref/<tfm>/. - /// This class walks that structure for a loaded and exposes - /// an aggregate provider that searches every .xml in the matching ref-pack tfm - /// folder. Cached per so the directory scan only happens - /// once per loaded assembly. - /// - public static class ModernXmlDocLookup - { - // Keep one cache per loaded MetadataFile so re-hovering the same assembly doesn't - // re-scan disk. The ConditionalWeakTable lets entries drop when the assembly is - // unloaded — matches XmlDocLoader's caching strategy. - static readonly ConditionalWeakTable cache = new(); - - /// - /// Returns an aggregate XML-doc provider that walks every XML in the modern .NET - /// ref pack matching 's runtime, or null when the - /// assembly isn't part of a modern .NET shared-framework layout (e.g. the user - /// opened a NuGet-only assembly with its XML already next to it — that path is - /// handled by ). - /// - public static Provider? TryGetProvider(MetadataFile module) - { - if (module == null) - return null; - lock (cache) - { - if (cache.TryGetValue(module, out var cached)) - return cached.IsEmpty ? null : cached; - var built = Build(module); - cache.Add(module, built ?? Provider.Empty); - return built; - } - } - - static Provider? Build(MetadataFile module) - { - var refPackDir = ResolveRefPackTfmDirectory(module.FileName); - if (refPackDir == null) - return null; - var xmls = SafeGetXmlFiles(refPackDir); - if (xmls.Count == 0) - return null; - var providers = new List(xmls.Count); - foreach (var x in xmls) - { - try - { - providers.Add(new XmlDocumentationProvider(x)); - } - catch - { - // One bad XML doesn't disable the rest — skip and continue. - } - } - return providers.Count > 0 ? new Provider(providers) : null; - } - - /// - /// Maps a runtime-DLL path like X:\Program Files\dotnet\shared\Microsoft.NETCore.App\10.0.0\System.Private.CoreLib.dll - /// to the matching ref-pack tfm directory X:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\10.0.0\ref\net10.0. - /// Returns null when the assembly isn't laid out under - /// shared/Microsoft.NETCore.App/<version>/. - /// - internal static string? ResolveRefPackTfmDirectory(string? assemblyFileName) - { - if (string.IsNullOrEmpty(assemblyFileName)) - return null; - var versionDir = Path.GetDirectoryName(assemblyFileName); - if (versionDir == null) - return null; - var sharedFramework = Path.GetDirectoryName(versionDir); - if (sharedFramework == null || !string.Equals(Path.GetFileName(sharedFramework), "Microsoft.NETCore.App", StringComparison.OrdinalIgnoreCase)) - return null; - var sharedDir = Path.GetDirectoryName(sharedFramework); - if (sharedDir == null || !string.Equals(Path.GetFileName(sharedDir), "shared", StringComparison.OrdinalIgnoreCase)) - return null; - var dotnetRoot = Path.GetDirectoryName(sharedDir); - if (dotnetRoot == null) - return null; - var version = Path.GetFileName(versionDir); - var refPackRoot = Path.Combine(dotnetRoot, "packs", "Microsoft.NETCore.App.Ref", version, "ref"); - if (!Directory.Exists(refPackRoot)) - return null; - // One tfm folder per pack version (e.g. "net10.0"). Pick the lexicographically - // latest — multiple tfms only show up across versions, not within one ref-pack - // version. Lexicographic order matches version order for "netN.M" formats up - // to N=9 (and net10.0 beats net9.0 by string compare too because "1" < "9" but - // length wins via the "0." prefix... actually no, this fails for net10.0). - // Sort by parsed version-number triple to be safe. - var tfmDir = Directory.GetDirectories(refPackRoot) - .OrderByDescending(d => ParseTfm(Path.GetFileName(d))) - .FirstOrDefault(); - return tfmDir; - } - - static (int Major, int Minor) ParseTfm(string tfm) - { - // "net10.0" → (10, 0). Anything we don't recognise sorts last (negative). - if (!tfm.StartsWith("net", StringComparison.OrdinalIgnoreCase)) - return (-1, -1); - var rest = tfm.Substring(3); - var dot = rest.IndexOf('.'); - if (dot < 0) - return (-1, -1); - if (!int.TryParse(rest.AsSpan(0, dot), out int major)) - return (-1, -1); - if (!int.TryParse(rest.AsSpan(dot + 1), out int minor)) - return (-1, -1); - return (major, minor); - } - - static IReadOnlyList SafeGetXmlFiles(string directory) - { - try - { - return Directory.GetFiles(directory, "*.xml"); - } - catch - { - return Array.Empty(); - } - } - - /// - /// Aggregate XML-doc provider that probes each contributing - /// in turn. First match wins — id strings are - /// unique across the ref pack (a type forwarded into one assembly is documented in - /// exactly one ref-pack XML). - /// - public sealed class Provider - { - internal static readonly Provider Empty = new(Array.Empty()); - - readonly IReadOnlyList providers; - - internal Provider(IReadOnlyList providers) - { - this.providers = providers; - } - - internal bool IsEmpty => providers.Count == 0; - - public string? GetDocumentation(string idString) - { - foreach (var p in providers) - { - var doc = p.GetDocumentation(idString); - if (!string.IsNullOrEmpty(doc)) - return doc; - } - return null; - } - } - } -}