diff --git a/ILSpy.Tests/Metadata/MetadataProtocolHandlerDrillDownTests.cs b/ILSpy.Tests/Metadata/MetadataProtocolHandlerDrillDownTests.cs new file mode 100644 index 000000000..b1c70c1d1 --- /dev/null +++ b/ILSpy.Tests/Metadata/MetadataProtocolHandlerDrillDownTests.cs @@ -0,0 +1,151 @@ +// 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.Reflection.Metadata; +using System.Reflection.Metadata.Ecma335; +using System.Threading.Tasks; + +using Avalonia.Headless.NUnit; + +using AwesomeAssertions; + +using ILSpy.AppEnv; +using ILSpy.Metadata; +using ILSpy.Metadata.CorTables; +using ILSpy.TreeNodes; +using ILSpy.ViewModels; +using ILSpy.Views; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests.Metadata; + +/// +/// Drill-down half of the metadata:// protocol handler: when the supplied handle +/// belongs to a metadata table, the handler should resolve to that specific +/// rather than just landing on the per-assembly +/// . WPF's FindNodeByHandleKind is the helper that +/// turns a into the matching table tree node; this test fixture +/// pins both the helper and the protocol-handler wiring. +/// +[TestFixture] +public class MetadataProtocolHandlerDrillDownTests +{ + [AvaloniaTest] + public async Task FindNodeByHandleKind_Returns_The_TypeDef_Table_For_A_TypeDefinition_Handle() + { + // Direct exercise of the helper. CoreLib is guaranteed to contain a TypeDef table — + // the assert simply checks Kind == TableIndex.TypeDef. The HandleKind→TableIndex cast + // is what the helper relies on; if the underlying enums ever diverge we want a loud + // failure here rather than a silent "always returns null". + var metadataNode = await GetMetadataTreeNodeForCoreLib(); + + var result = metadataNode.FindNodeByHandleKind(HandleKind.TypeDefinition); + + ((object?)result).Should().NotBeNull(); + result!.Kind.Should().Be(TableIndex.TypeDef, "the TypeDef table backs HandleKind.TypeDefinition"); + } + + [AvaloniaTest] + public async Task FindNodeByHandleKind_Returns_Null_For_A_Heap_Handle_Kind() + { + // Heap handles (UserString / Blob / Guid / String) don't have a backing + // MetadataTableTreeNode — they live as siblings under the MetadataTreeNode itself. + // The helper must return null in that case so the caller can fall back to the + // per-assembly Metadata node instead of drilling into a wrong table. + var metadataNode = await GetMetadataTreeNodeForCoreLib(); + + var result = metadataNode.FindNodeByHandleKind(HandleKind.UserString); + + ((object?)result).Should().BeNull(); + } + + [AvaloniaTest] + public async Task MetadataProtocolHandler_Drills_Into_TypeDef_Table_For_A_TypeDefinition_Handle() + { + // Full integration: hand a TypeDefinitionHandle to the protocol handler and expect + // the resolved node to be the TypeDef MetadataTableTreeNode under the per-assembly + // Metadata folder. Without the drill-down patch the handler would return the parent + // MetadataTreeNode itself, which makes the user scroll-find the right table by hand. + var (handler, module, _) = await SetUpHandler(); + var someTypeDef = module.Metadata.TypeDefinitions.First(); + + var result = handler.Resolve("metadata", module, someTypeDef, out _); + + // SharpTreeNode has a custom .Should() extension in this assembly that hides BeOfType, + // so compare exact types directly. + ((object?)result).Should().NotBeNull(); + result!.GetType().Should().Be(typeof(TypeDefTableTreeNode), + "TypeDefinition handles must drill into the TypeDef table"); + } + + [AvaloniaTest] + public async Task MetadataProtocolHandler_Falls_Back_To_MetadataTreeNode_For_Heap_Handles() + { + // String / UserString / Blob / Guid handles don't map to a per-table tree node, so + // the handler stays at the Metadata folder level. Users can still expand to the right + // heap from there; the alternative (returning null) would suppress navigation entirely. + var (handler, module, _) = await SetUpHandler(); + + // Construct a UserStringHandle via MetadataTokens (the public ctor is internal). + var heapHandle = MetadataTokens.UserStringHandle(1); + + var result = handler.Resolve("metadata", module, heapHandle, out _); + + ((object?)result).Should().NotBeNull(); + result!.GetType().Should().Be(typeof(MetadataTreeNode), + "heap handles have no table match, so the handler should keep the user at the Metadata folder"); + } + + // --- helpers --------------------------------------------------------------------------- + + static async Task GetMetadataTreeNodeForCoreLib() + { + 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 assemblyNode = vm.AssemblyTreeModel.FindNode(coreLibName); + assemblyNode.EnsureLazyChildren(); + var metadataNode = assemblyNode.Children.OfType().Single(); + // Drill-down needs the children realised; EnsureLazyChildren cascades only one level. + metadataNode.EnsureLazyChildren(); + var tablesNode = metadataNode.Children.OfType().Single(); + tablesNode.EnsureLazyChildren(); + return metadataNode; + } + + static async Task<(MetadataProtocolHandler handler, ICSharpCode.Decompiler.Metadata.MetadataFile module, MetadataTreeNode metadataNode)> SetUpHandler() + { + var metadataNode = await GetMetadataTreeNodeForCoreLib(); + var vm = (MainWindowViewModel)AppComposition.Current.GetExport().DataContext!; + var coreLibName = typeof(object).Assembly.GetName().Name!; + var assemblyNode = vm.AssemblyTreeModel.FindNode(coreLibName); + var module = assemblyNode.LoadedAssembly.GetMetadataFileOrNull()!; + // The handler is exported as IProtocolHandler, not as itself — fish out the + // MetadataProtocolHandler concrete impl from the contract list. + var handler = AppComposition.Current + .GetExports() + .OfType() + .Single(); + return (handler, module, metadataNode); + } +} diff --git a/ILSpy/Metadata/MetadataProtocolHandler.cs b/ILSpy/Metadata/MetadataProtocolHandler.cs index 9ea731507..e9b6bddde 100644 --- a/ILSpy/Metadata/MetadataProtocolHandler.cs +++ b/ILSpy/Metadata/MetadataProtocolHandler.cs @@ -52,18 +52,23 @@ namespace ILSpy.Metadata newTabPage = true; if (protocol != "metadata") return null; - var assemblyNode = assemblyTreeModel.FindTreeNode(module) as AssemblyTreeNode; + // AssemblyTreeModel.FindTreeNode only resolves EntityReference/ITypeDefinition/IMember, + // not MetadataFile — walk the assembly-tree root manually here. Same lookup pattern + // FindTypeNode uses internally. + var assemblyNode = (assemblyTreeModel.Root as AssemblyListTreeNode)?.Children + .OfType() + .FirstOrDefault(a => a.LoadedAssembly.GetMetadataFileOrNull() == module); if (assemblyNode == null) return null; assemblyNode.EnsureLazyChildren(); var metadataNode = assemblyNode.Children.OfType().FirstOrDefault(); if (metadataNode == null) return null; - // WPF additionally drills into the matching table for the handle kind. The - // Avalonia MetadataTreeNode doesn't yet expose a FindNodeByHandleKind helper; - // surfacing the MetadataTreeNode itself is enough to land the user inside the - // per-assembly metadata view. Specific-table drill-down is a follow-up. - return metadataNode; + // Drill into the matching metadata table when the handle is table-backed. Heap + // handles (String / UserString / Blob / Guid) have no per-table node, so we fall + // back to the per-assembly Metadata folder — the user can expand the right heap + // sibling from there. + return (ILSpyTreeNode?)metadataNode.FindNodeByHandleKind(handle.Kind) ?? metadataNode; } } } diff --git a/ILSpy/Metadata/MetadataTreeNode.cs b/ILSpy/Metadata/MetadataTreeNode.cs index 6002351d1..55a9bbad5 100644 --- a/ILSpy/Metadata/MetadataTreeNode.cs +++ b/ILSpy/Metadata/MetadataTreeNode.cs @@ -17,6 +17,7 @@ // DEALINGS IN THE SOFTWARE. using System; +using System.Linq; using System.Reflection.Metadata; using System.Reflection.Metadata.Ecma335; @@ -59,6 +60,26 @@ namespace ILSpy.Metadata DumpMetadataInfo(language, output, metadataFile.Metadata); } + /// + /// Resolves a to the per-table tree node under this metadata + /// folder, or null when the handle has no table (e.g. heap handles: String / + /// UserString / Blob / Guid). Used by MetadataProtocolHandler to drill into + /// the right table when navigating a metadata:// reference. Cast from + /// to relies on the two enums sharing + /// numeric values for the 0..44 metadata-table range. + /// + public MetadataTableTreeNode? FindNodeByHandleKind(HandleKind kind) + { + EnsureLazyChildren(); + var tables = Children.OfType().FirstOrDefault(); + if (tables is null) + return null; + tables.EnsureLazyChildren(); + return tables.Children + .OfType() + .FirstOrDefault(x => x.Kind == (TableIndex)kind); + } + internal static void DumpMetadataInfo(Language language, ITextOutput output, MetadataReader metadata) { language.WriteCommentLine(output, "MetadataKind: " + metadata.MetadataKind);