diff --git a/ILSpy.Tests/Editor/XmlDocumentationTests.cs b/ILSpy.Tests/Editor/XmlDocumentationTests.cs new file mode 100644 index 000000000..db1c83fb3 --- /dev/null +++ b/ILSpy.Tests/Editor/XmlDocumentationTests.cs @@ -0,0 +1,84 @@ +// 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.Linq; +using System.Threading.Tasks; + +using Avalonia.Headless.NUnit; + +using AwesomeAssertions; + +using ICSharpCode.Decompiler.Documentation; +using ICSharpCode.Decompiler.TypeSystem; + +using ILSpy.AppEnv; +using ILSpy.TextView; +using ILSpy.TreeNodes; +using ILSpy.ViewModels; +using ILSpy.Views; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests.TextView; + +/// +/// Diagnoses whether the XML-documentation lookup that backs the decompiler-view hover +/// tooltip actually surfaces non-empty docs for a well-documented system method. The +/// renderer + wiring (DocumentationRenderer, +/// DecompilerTextView.BuildHoverContent, AppendXmlDocumentation) are +/// already in place; this test verifies the underlying +/// +/// path produces a real doc string for at least one ubiquitous CoreLib method. +/// +[TestFixture] +public class XmlDocumentationTests +{ + [AvaloniaTest] + public async Task XmlDocLoader_Surfaces_Documentation_For_CoreLib_String_Concat() + { + var window = AppComposition.Current.GetExport(); + window.Show(); + var vm = (MainWindowViewModel)window.DataContext!; + await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1); + + var coreLibName = typeof(object).Assembly.GetName().Name!; + var stringNode = vm.AssemblyTreeModel.FindNode(coreLibName, "System", "System.String"); + stringNode.IsExpanded = true; + var concatNode = stringNode.Children.OfType() + .First(m => m.MethodDefinition.Name == "Concat"); + + var concat = concatNode.MethodDefinition; + Assert.That(concat, Is.Not.Null); + 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!); + ((object?)provider).Should().NotBeNull( + "ModernXmlDocLookup must locate the ref-pack XML for the test-host runtime layout"); + + var documentation = provider!.GetDocumentation(concat.GetIdString()); + documentation.Should().NotBeNullOrEmpty( + "System.String.Concat is one of the most-documented methods in CoreLib — the hover tooltip would be empty without this"); + documentation.Should().Contain(" tag the renderer parses"); + } +} diff --git a/ILSpy/TextView/DecompilerTextView.axaml.cs b/ILSpy/TextView/DecompilerTextView.axaml.cs index 4836543a9..940eb3f06 100644 --- a/ILSpy/TextView/DecompilerTextView.axaml.cs +++ b/ILSpy/TextView/DecompilerTextView.axaml.cs @@ -653,10 +653,13 @@ namespace ILSpy.TextView { if (entity.ParentModule?.MetadataFile is not { } metadata) return; - var docProvider = XmlDocLoader.LoadDocumentation(metadata); - if (docProvider == null) - return; - var documentation = docProvider.GetDocumentation(entity.GetIdString()); + 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); 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 new file mode 100644 index 000000000..c4ba3c5bd --- /dev/null +++ b/ILSpy/TextView/ModernXmlDocLookup.cs @@ -0,0 +1,191 @@ +// 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; + } + } + } +}