From 36fa5a3f90d0d4e84e4e5f38d2319605f98f8268 Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Mon, 17 Aug 2026 10:24:23 +0200 Subject: [PATCH] Find package-nested assembly nodes without walking the tree Resolving a type to its tree node scanned every descendant of the root, which means every namespace node of every assembly - all of them built eagerly - to find the one assembly node it needed. A package child records the bundle it came from, so that chain leads straight to the single top-level node worth descending into, and only that package's folders are searched from there. The two sibling lookups only ever considered the root's direct children, so neither resolved anything inside a package at all. Sharing one helper fixes them along the way, and it expands package folders on the descent because search surfaces package contents whether or not the tree was ever opened there. Assisted-by: Claude:claude-opus-5[1m]:Claude Code --- .../LoadedAssemblyExtensions.cs | 14 ++++++ .../AssemblyTree/AssemblyTreeModelTests.cs | 34 +++++++++++++ ILSpy/AssemblyTree/TreeNodeLocator.cs | 50 ++++++++++++++++--- 3 files changed, 92 insertions(+), 6 deletions(-) diff --git a/ICSharpCode.ILSpyX/LoadedAssemblyExtensions.cs b/ICSharpCode.ILSpyX/LoadedAssemblyExtensions.cs index 7db035305..a02bd333f 100644 --- a/ICSharpCode.ILSpyX/LoadedAssemblyExtensions.cs +++ b/ICSharpCode.ILSpyX/LoadedAssemblyExtensions.cs @@ -70,6 +70,20 @@ namespace ICSharpCode.ILSpyX return GetLoadedAssembly(file).GetTypeSystemOrNull(DecompilerTypeSystem.GetOptions(settings)); } + /// + /// Like , but returns null for a file that was built outside + /// the machinery instead of throwing. + /// + public static LoadedAssembly? GetLoadedAssemblyOrNull(this MetadataFile file) + { + if (file == null) + throw new ArgumentNullException(nameof(file)); + lock (LoadedAssembly.loadedAssemblies) + { + return LoadedAssembly.loadedAssemblies.TryGetValue(file, out var loadedAssembly) ? loadedAssembly : null; + } + } + public static LoadedAssembly GetLoadedAssembly(this MetadataFile file) { if (file == null) diff --git a/ILSpy.Tests/AssemblyTree/AssemblyTreeModelTests.cs b/ILSpy.Tests/AssemblyTree/AssemblyTreeModelTests.cs index d0a44536d..ceda979af 100644 --- a/ILSpy.Tests/AssemblyTree/AssemblyTreeModelTests.cs +++ b/ILSpy.Tests/AssemblyTree/AssemblyTreeModelTests.cs @@ -16,6 +16,12 @@ // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. +using System; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Threading.Tasks; + using Avalonia.Headless.NUnit; using AwesomeAssertions; @@ -24,6 +30,7 @@ using ICSharpCode.ILSpyX; using ICSharpCode.ILSpy.AppEnv; using ICSharpCode.ILSpy.AssemblyTree; +using ICSharpCode.ILSpy.TreeNodes; using NUnit.Framework; @@ -63,4 +70,31 @@ public class AssemblyTreeModelTests model.ActiveListName.Should().Be(AssemblyListManager.DefaultListName, "Initialize selects the (Default) list so the tree has something to render at startup."); } + + [AvaloniaTest] + public async Task FindTreeNode_resolves_a_type_inside_an_unexpanded_package() + { + // Search enumerates the contents of packages whether or not the user ever opened them in + // the tree, so activating such a result has to reach a node that does not exist yet. + var (_, vm) = await TestHarness.BootAsync(); + + var tempDir = Path.Combine(Path.GetTempPath(), "ILSpy.Tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + var zipPath = Path.Combine(tempDir, "package.zip"); + using (var zip = ZipFile.Open(zipPath, ZipArchiveMode.Create)) + zip.CreateEntryFromFile(FixtureAssembly.Emit("Nested"), "lib/net10.0/Nested.dll"); + + await vm.OpenAssemblyAsync(zipPath); + + var nested = (await vm.AssemblyTreeModel.AssemblyList!.GetAllAssemblies()) + .Single(a => a.ParentBundle != null); + var type = nested.GetTypeSystemOrNull()!.MainModule.TypeDefinitions + .Single(t => t.Name == FixtureAssembly.TypeName); + + var node = vm.AssemblyTreeModel.FindTreeNode(type); + + // Cast through object so the generic Should() resolves, not the SharpTreeNode shadow. + ((object?)node).Should().BeOfType( + "the lookup must descend into the package's folders, expanding them on the way"); + } } diff --git a/ILSpy/AssemblyTree/TreeNodeLocator.cs b/ILSpy/AssemblyTree/TreeNodeLocator.cs index 693fe68ba..3b2fc49a0 100644 --- a/ILSpy/AssemblyTree/TreeNodeLocator.cs +++ b/ILSpy/AssemblyTree/TreeNodeLocator.cs @@ -100,8 +100,7 @@ namespace ICSharpCode.ILSpy.AssemblyTree return root.FindAssemblyNode(lasm); case MetadataFile metadataFile: - return root.Children.OfType() - .FirstOrDefault(a => a.LoadedAssembly.GetMetadataFileOrNull() == metadataFile); + return FindAssemblyNode(root, metadataFile); case Resource resource: return FindResourceNode(root, resource, null); @@ -117,6 +116,47 @@ namespace ICSharpCode.ILSpy.AssemblyTree } } + /// + /// Finds the node for the assembly was loaded from, including + /// assemblies nested inside a package or bundle. Package folders are expanded on the way + /// down, so this resolves even when the user has never opened the package in the tree. + /// + public static AssemblyTreeNode? FindAssemblyNode(AssemblyListTreeNode root, MetadataFile? module) + { + // A package child records the bundle it came from, so walking up that chain leads + // straight to the one top-level node worth descending into. Searching the tree for a + // matching module instead would have to visit every namespace and type node already + // built, and would still miss nested assemblies that are not loaded yet. + var nesting = new Stack(); + for (var current = module?.GetLoadedAssemblyOrNull(); current != null; current = current.ParentBundle) + nesting.Push(current); + if (nesting.Count == 0) + return null; + + var node = root.FindAssemblyNode(nesting.Pop()); + while (node != null && nesting.Count > 0) + node = FindNestedAssemblyNode(node, nesting.Pop()); + return node; + } + + // Depth-first search for one assembly within a package node's folder structure. Only + // package folders are descended into, so the walk stays inside the package. + static AssemblyTreeNode? FindNestedAssemblyNode(SharpTreeNode packageNode, LoadedAssembly assembly) + { + packageNode.EnsureLazyChildren(); + foreach (var child in packageNode.Children) + { + switch (child) + { + case AssemblyTreeNode nested when nested.LoadedAssembly == assembly: + return nested; + case PackageFolderTreeNode folder when FindNestedAssemblyNode(folder, assembly) is { } found: + return found; + } + } + return null; + } + // Resolves a resource (optionally a named sub-entry) to its tree node. Mirrors the previous // version's AssemblyListTreeNode.FindResourceNode so resource search results / links navigate. static ILSpyTreeNode? FindResourceNode(AssemblyListTreeNode root, Resource resource, string? name) @@ -152,8 +192,7 @@ namespace ICSharpCode.ILSpy.AssemblyTree var module = ns.ContributingModules.FirstOrDefault(); if (module?.MetadataFile == null) return null; - var assembly = root.Children.OfType() - .FirstOrDefault(a => a.LoadedAssembly.GetMetadataFileOrNull() == module.MetadataFile); + var assembly = FindAssemblyNode(root, module.MetadataFile); if (assembly == null) return null; assembly.EnsureLazyChildren(); @@ -166,8 +205,7 @@ namespace ICSharpCode.ILSpy.AssemblyTree var module = type.ParentModule?.MetadataFile; if (module == null) return null; - var assembly = root.Children.OfType() - .FirstOrDefault(a => a.LoadedAssembly.GetMetadataFileOrNull() == module); + var assembly = FindAssemblyNode(root, module); if (assembly == null) return null;