Browse Source

Hover XML docs resolve for modern .NET system assemblies

The decompiler-view hover tooltip's XML-documentation lookup goes through
the shared ICSharpCode.Decompiler.Documentation.XmlDocLoader. That helper
only knows two layouts: .xml beside the .dll, and the .NET Framework
reference-assemblies paths (Program Files (x86)\Reference Assemblies\
Microsoft\Framework\v1.0 … v4.8.1). Modern .NET runtime DLLs
(System.Private.CoreLib.dll, System.Linq.dll, …) ship without any .xml
beside them — the XML files live in the parallel reference pack at
pull/3755/head
Siegfried Pammer 2 months ago
parent
commit
6067d75167
  1. 84
      ILSpy.Tests/Editor/XmlDocumentationTests.cs
  2. 11
      ILSpy/TextView/DecompilerTextView.axaml.cs
  3. 191
      ILSpy/TextView/ModernXmlDocLookup.cs

84
ILSpy.Tests/Editor/XmlDocumentationTests.cs

@ -0,0 +1,84 @@ @@ -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;
/// <summary>
/// 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 (<c>DocumentationRenderer</c>,
/// <c>DecompilerTextView.BuildHoverContent</c>, <c>AppendXmlDocumentation</c>) are
/// already in place; this test verifies the underlying
/// <see cref="ICSharpCode.Decompiler.Documentation.XmlDocLoader.LoadDocumentation"/>
/// path produces a real doc string for at least one ubiquitous CoreLib method.
/// </summary>
[TestFixture]
public class XmlDocumentationTests
{
[AvaloniaTest]
public async Task XmlDocLoader_Surfaces_Documentation_For_CoreLib_String_Concat()
{
var window = AppComposition.Current.GetExport<MainWindow>();
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<TypeTreeNode>(coreLibName, "System", "System.String");
stringNode.IsExpanded = true;
var concatNode = stringNode.Children.OfType<MethodTreeNode>()
.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 (<dotnet>/packs/Microsoft.NETCore.App.Ref/<version>/ref/<tfm>/*.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("<summary",
"the raw documentation string must include the <summary> tag the renderer parses");
}
}

11
ILSpy/TextView/DecompilerTextView.axaml.cs

@ -653,10 +653,13 @@ namespace ILSpy.TextView @@ -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 <see cref="..."/> falls back to printing the

191
ILSpy/TextView/ModernXmlDocLookup.cs

@ -0,0 +1,191 @@ @@ -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
{
/// <summary>
/// Fallback XML-documentation locator for modern .NET (.NET 5+). The shared
/// <see cref="XmlDocLoader"/> only knows about the .NET Framework reference-assemblies
/// paths (v1.0 – v4.8.1) and the &quot;.xml beside the .dll&quot; convention. Modern
/// runtime DLLs (<c>System.Private.CoreLib.dll</c>, <c>System.Linq.dll</c>, …) live
/// under <c>dotnet/shared/Microsoft.NETCore.App/&lt;version&gt;/</c> with no
/// matching XML; the actual XML files ship in the parallel reference pack under
/// <c>dotnet/packs/Microsoft.NETCore.App.Ref/&lt;version&gt;/ref/&lt;tfm&gt;/</c>.
/// This class walks that structure for a loaded <see cref="MetadataFile"/> and exposes
/// an aggregate provider that searches every <c>.xml</c> in the matching ref-pack tfm
/// folder. Cached per <see cref="MetadataFile"/> so the directory scan only happens
/// once per loaded assembly.
/// </summary>
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<MetadataFile, Provider> cache = new();
/// <summary>
/// Returns an aggregate XML-doc provider that walks every XML in the modern .NET
/// ref pack matching <paramref name="module"/>'s runtime, or <c>null</c> 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 <see cref="XmlDocLoader"/>).
/// </summary>
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<XmlDocumentationProvider>(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;
}
/// <summary>
/// Maps a runtime-DLL path like <c>X:\Program Files\dotnet\shared\Microsoft.NETCore.App\10.0.0\System.Private.CoreLib.dll</c>
/// to the matching ref-pack tfm directory <c>X:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\10.0.0\ref\net10.0</c>.
/// Returns <c>null</c> when the assembly isn't laid out under
/// <c>shared/Microsoft.NETCore.App/&lt;version&gt;/</c>.
/// </summary>
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<string> SafeGetXmlFiles(string directory)
{
try
{
return Directory.GetFiles(directory, "*.xml");
}
catch
{
return Array.Empty<string>();
}
}
/// <summary>
/// Aggregate XML-doc provider that probes each contributing
/// <see cref="XmlDocumentationProvider"/> 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).
/// </summary>
public sealed class Provider
{
internal static readonly Provider Empty = new(Array.Empty<XmlDocumentationProvider>());
readonly IReadOnlyList<XmlDocumentationProvider> providers;
internal Provider(IReadOnlyList<XmlDocumentationProvider> 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;
}
}
}
}
Loading…
Cancel
Save