Browse Source

ICSharpCode.Decompiler: XmlDocLoader falls back to .NET ref-pack XMLs

Promotes the modern .NET XML-doc lookup from the Avalonia port into the
shared ICSharpCode.Decompiler library so every host (WPF, Avalonia, any
third-party consumer of XmlDocLoader) gets hover/tooltip documentation
for system entities without per-host fallback wiring.
pull/3755/head
Siegfried Pammer 2 months ago
parent
commit
3aa171bac5
  1. 136
      ICSharpCode.Decompiler/Documentation/XmlDocLoader.cs
  2. 14
      ICSharpCode.Decompiler/Documentation/XmlDocumentationProvider.cs
  3. 14
      ILSpy.Tests/Editor/XmlDocumentationTests.cs
  4. 11
      ILSpy/TextView/DecompilerTextView.axaml.cs
  5. 191
      ILSpy/TextView/ModernXmlDocLookup.cs

136
ICSharpCode.Decompiler/Documentation/XmlDocLoader.cs

@ -17,9 +17,11 @@
// DEALINGS IN THE SOFTWARE. // DEALINGS IN THE SOFTWARE.
using System; using System;
using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.Globalization; using System.Globalization;
using System.IO; using System.IO;
using System.Linq;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using ICSharpCode.Decompiler.Metadata; using ICSharpCode.Decompiler.Metadata;
@ -64,18 +66,146 @@ namespace ICSharpCode.Decompiler.Documentation
if (xmlDocFile != null) if (xmlDocFile != null)
{ {
xmlDoc = new XmlDocumentationProvider(xmlDocFile); xmlDoc = new XmlDocumentationProvider(xmlDocFile);
cache.Add(module, xmlDoc);
} }
else else
{ {
cache.Add(module, null); // add missing documentation files as well // Last resort for modern .NET (.NET 5+): runtime DLLs ship under
xmlDoc = null; // dotnet/shared/Microsoft.NETCore.App/<version>/ with no .xml beside
// them. The matching XML lives in the parallel ref pack under
// dotnet/packs/Microsoft.NETCore.App.Ref/<version>/ref/<tfm>/. 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; return xmlDoc;
} }
} }
static XmlDocumentationProvider TryLoadModernRefPackDocumentation(string assemblyFileName)
{
var refPackTfmDir = ResolveModernRefPackTfmDirectory(assemblyFileName);
if (refPackTfmDir == null)
return null;
List<XmlDocumentationProvider> 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<XmlDocumentationProvider>()).Add(p);
}
return providers is { Count: > 0 } ? new AggregatingXmlDocumentationProvider(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> or when the parallel ref pack
/// isn't installed.
/// </summary>
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<char>, 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<string> SafeGetXmlFiles(string directory)
{
try
{
return Directory.GetFiles(directory, "*.xml");
}
catch
{
return Array.Empty<string>();
}
}
/// <summary>
/// Aggregates several <see cref="XmlDocumentationProvider"/> instances behind one
/// <see cref="XmlDocumentationProvider"/> surface. Used by
/// <see cref="TryLoadModernRefPackDocumentation"/> because in modern .NET an
/// entity's metadata-token-bearing assembly (<c>System.Private.CoreLib.dll</c>) is
/// different from the assembly whose XML contains its docs (<c>System.Runtime.xml</c>):
/// 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.
/// </summary>
sealed class AggregatingXmlDocumentationProvider : XmlDocumentationProvider
{
readonly IReadOnlyList<XmlDocumentationProvider> providers;
public AggregatingXmlDocumentationProvider(IReadOnlyList<XmlDocumentationProvider> 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 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"); static readonly string frameworkPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Windows), @"Microsoft.NET\Framework");

14
ICSharpCode.Decompiler/Documentation/XmlDocumentationProvider.cs

@ -123,6 +123,16 @@ namespace ICSharpCode.Decompiler.Documentation
volatile IndexEntry[] index; // SORTED array of index entries volatile IndexEntry[] index; // SORTED array of index entries
#region Constructor / Redirection support #region Constructor / Redirection support
/// <summary>
/// Constructor for subclasses that aggregate multiple <see cref="XmlDocumentationProvider"/>
/// instances (see the modern .NET ref-pack lookup in <see cref="XmlDocLoader"/>).
/// The base class's index stays empty; subclasses must override
/// <see cref="GetDocumentation(string)"/> to fan out across their contributors.
/// </summary>
protected XmlDocumentationProvider()
{
}
/// <summary> /// <summary>
/// Creates a new XmlDocumentationProvider. /// Creates a new XmlDocumentationProvider.
/// </summary> /// </summary>
@ -313,7 +323,7 @@ namespace ICSharpCode.Decompiler.Documentation
/// <summary> /// <summary>
/// Get the documentation for the member with the specified documentation key. /// Get the documentation for the member with the specified documentation key.
/// </summary> /// </summary>
public string GetDocumentation(string key) public virtual string GetDocumentation(string key)
{ {
if (key == null) if (key == null)
throw new ArgumentNullException(nameof(key)); throw new ArgumentNullException(nameof(key));
@ -323,7 +333,7 @@ namespace ICSharpCode.Decompiler.Documentation
/// <summary> /// <summary>
/// Get the documentation for the specified member. /// Get the documentation for the specified member.
/// </summary> /// </summary>
public string GetDocumentation(IEntity entity) public virtual string GetDocumentation(IEntity entity)
{ {
if (entity == null) if (entity == null)
throw new ArgumentNullException(nameof(entity)); throw new ArgumentNullException(nameof(entity));

14
ILSpy.Tests/Editor/XmlDocumentationTests.cs

@ -27,7 +27,6 @@ using ICSharpCode.Decompiler.Documentation;
using ICSharpCode.Decompiler.TypeSystem; using ICSharpCode.Decompiler.TypeSystem;
using ILSpy.AppEnv; using ILSpy.AppEnv;
using ILSpy.TextView;
using ILSpy.TreeNodes; using ILSpy.TreeNodes;
using ILSpy.ViewModels; using ILSpy.ViewModels;
using ILSpy.Views; using ILSpy.Views;
@ -67,13 +66,14 @@ public class XmlDocumentationTests
Assert.That(concat!.ParentModule, Is.Not.Null); Assert.That(concat!.ParentModule, Is.Not.Null);
Assert.That(concat.ParentModule!.MetadataFile, 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 // XmlDocLoader's modern-.NET fallback (added in the shared decompiler library) walks
// CoreLib's docs at runtime. The Avalonia ModernXmlDocLookup walks the parallel // the parallel ref pack — <dotnet>/packs/Microsoft.NETCore.App.Ref/<version>/ref/<tfm>/*.xml
// ref pack (<dotnet>/packs/Microsoft.NETCore.App.Ref/<version>/ref/<tfm>/*.xml) // — and aggregates every XML there into a single provider, since each entity's
// and aggregates every XML there into a single provider. // metadata-token-bearing assembly (System.Private.CoreLib.dll) differs from the one
var provider = ModernXmlDocLookup.TryGetProvider(concat.ParentModule.MetadataFile!); // whose XML carries its docs (System.Runtime.xml).
var provider = XmlDocLoader.LoadDocumentation(concat.ParentModule.MetadataFile!);
((object?)provider).Should().NotBeNull( ((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()); var documentation = provider!.GetDocumentation(concat.GetIdString());
documentation.Should().NotBeNullOrEmpty( documentation.Should().NotBeNullOrEmpty(

11
ILSpy/TextView/DecompilerTextView.axaml.cs

@ -653,13 +653,10 @@ namespace ILSpy.TextView
{ {
if (entity.ParentModule?.MetadataFile is not { } metadata) if (entity.ParentModule?.MetadataFile is not { } metadata)
return; return;
var idString = entity.GetIdString(); // XmlDocLoader handles every layout the decompiler library knows about: .xml
// First-cut: try the shared XmlDocLoader (handles "xml beside dll" + .NET // beside the .dll, .NET Framework reference-assemblies paths, and (recently)
// Framework reference pack paths). When that returns null — typical for // the modern .NET ref pack at dotnet/packs/Microsoft.NETCore.App.Ref/...
// modern .NET runtime DLLs whose XML lives in the parallel ref pack — fall var documentation = XmlDocLoader.LoadDocumentation(metadata)?.GetDocumentation(entity.GetIdString());
// back to ModernXmlDocLookup which walks dotnet/packs/...
var documentation = XmlDocLoader.LoadDocumentation(metadata)?.GetDocumentation(idString)
?? ModernXmlDocLookup.TryGetProvider(metadata)?.GetDocumentation(idString);
if (documentation == null) if (documentation == null)
return; return;
// First-cut: no cref resolver, so <see cref="..."/> falls back to printing the // First-cut: no cref resolver, so <see cref="..."/> falls back to printing the

191
ILSpy/TextView/ModernXmlDocLookup.cs

@ -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
{
/// <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