From 50f4341f1a0185d77126c39a22b69b6aea980d0c Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Mon, 7 Sep 2026 07:44:13 +0200 Subject: [PATCH] Fix #2093: find a navigation target in a reference assembly too A member ID was resolved against the loaded assemblies with the reference assemblies filtered out entirely, so the assembly set a project's references make up - which is what the VS add-in passes - left every target unresolved. The lookup now prefers assemblies that carry a body and falls back to the reference assemblies, which do declare the member; the banner already says what the reader is looking at. An ID that names nothing anywhere used to leave the tree untouched and say nothing, which is indistinguishable from a jump to the wrong place. It now names the target and the assemblies that were searched, rather than selecting an arbitrary one of them. The report prefers the pane the jump would have filled and falls back to a tab of its own, because ShowText writes to the active decompiler tab and does nothing at all when the active content is something else - a metadata table, or nothing yet at startup, which is exactly when this report is written. The test fixture is this project's own reference assembly: the compiler writes one carrying the ReferenceAssembly attribute and the same members as the output, which is the pair a targeting pack and its runtime form, and keeps the test off machine-specific NuGet paths. Assisted-by: Claude:claude-opus-5:Claude Code --- .../NavigateToReferenceAssemblyTests.cs | 96 +++++++++++++++++++ .../Commands/CommandLineArgumentsTests.cs | 78 +++++++++++++++ ILSpy.Tests/ILSpy.Tests.csproj | 14 +++ ILSpy/AssemblyTree/AssemblyTreeModel.cs | 59 +++++++++--- ILSpy/Properties/Resources.Designer.cs | 9 ++ ILSpy/Properties/Resources.resx | 3 + 6 files changed, 248 insertions(+), 11 deletions(-) create mode 100644 ILSpy.Tests/AssemblyTree/NavigateToReferenceAssemblyTests.cs diff --git a/ILSpy.Tests/AssemblyTree/NavigateToReferenceAssemblyTests.cs b/ILSpy.Tests/AssemblyTree/NavigateToReferenceAssemblyTests.cs new file mode 100644 index 000000000..0b9fb4fcb --- /dev/null +++ b/ILSpy.Tests/AssemblyTree/NavigateToReferenceAssemblyTests.cs @@ -0,0 +1,96 @@ +// Copyright (c) 2026 Siegfried Pammer +// +// 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.IO; +using System.Threading.Tasks; + +using AwesomeAssertions; + +using ICSharpCode.Decompiler.Metadata; +using ICSharpCode.ILSpy.AssemblyTree; +using ICSharpCode.ILSpyX; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests.AssemblyTree; + +/// +/// A navigation target that only a reference assembly declares. The VS add-in hands ILSpy the +/// assemblies a project references, which for a framework-targeting project are the targeting +/// pack's reference assemblies, so refusing to look in them leaves the target unresolved and +/// the jump silently does nothing (issue #2093). +/// +[TestFixture] +public class NavigateToReferenceAssemblyTests +{ + // The member is looked up on this fixture itself: it is public, so the reference assembly + // declares it too, and both assemblies are next to the test at run time. + public static int TargetMember(string text) => text.Length; + + const string TargetId = "M:ICSharpCode.ILSpy.Tests.AssemblyTree.NavigateToReferenceAssemblyTests.TargetMember(System.String)"; + + static string ImplementationPath => typeof(NavigateToReferenceAssemblyTests).Assembly.Location; + + static string ReferencePath => Path.Combine( + Path.GetDirectoryName(ImplementationPath)!, "ReferenceAssemblyFixture", + Path.GetFileName(ImplementationPath)); + + [Test] + public async Task The_Fixture_Really_Is_A_Reference_Assembly() + { + File.Exists(ReferencePath).Should().BeTrue( + "the build copies this project's reference assembly next to the tests"); + var list = new AssemblyList(); + var reference = list.OpenAssembly(ReferencePath); + var file = await reference.GetMetadataFileOrNullAsync(); + file.Should().NotBeNull(); + file!.IsReferenceAssembly().Should().BeTrue("otherwise the tests below prove nothing"); + } + + [Test] + public async Task A_Member_Only_A_Reference_Assembly_Declares_Still_Resolves() + { + var list = new AssemblyList(); + var reference = list.OpenAssembly(ReferencePath); + await reference.GetMetadataFileOrNullAsync(); + + var entity = AssemblyTreeModel.FindEntityInRelevantAssemblies(TargetId, new[] { reference }); + + entity.Should().NotBeNull("a reference assembly is the only place the member can be found"); + entity!.Name.Should().Be(nameof(TargetMember)); + } + + [Test] + public async Task An_Implementation_Assembly_Wins_Over_A_Reference_Assembly() + { + var list = new AssemblyList(); + var reference = list.OpenAssembly(ReferencePath); + var implementation = list.OpenAssembly(ImplementationPath); + await reference.GetMetadataFileOrNullAsync(); + await implementation.GetMetadataFileOrNullAsync(); + + // The reference assembly comes first, so a plain "first hit wins" search would answer + // with it; only a search that prefers real definitions picks the implementation. + var entity = AssemblyTreeModel.FindEntityInRelevantAssemblies( + TargetId, new[] { reference, implementation }); + + entity.Should().NotBeNull(); + entity!.ParentModule!.MetadataFile!.FileName.Should().Be(ImplementationPath, + "a definition with a body is more useful than a signature-only one"); + } +} diff --git a/ILSpy.Tests/Commands/CommandLineArgumentsTests.cs b/ILSpy.Tests/Commands/CommandLineArgumentsTests.cs index 336935b52..7cde3c34e 100644 --- a/ILSpy.Tests/Commands/CommandLineArgumentsTests.cs +++ b/ILSpy.Tests/Commands/CommandLineArgumentsTests.cs @@ -18,6 +18,8 @@ using System; using System.IO; +using System.Linq; +using System.Collections.Generic; using System.Threading.Tasks; using Avalonia.Headless.NUnit; @@ -26,6 +28,9 @@ using AwesomeAssertions; using ICSharpCode.ILSpy.AppEnv; using ICSharpCode.ILSpy.Languages; +using ICSharpCode.ILSpy.Metadata; +using ICSharpCode.ILSpy.Metadata.CorTables; +using ICSharpCode.ILSpy.TextView; using ICSharpCode.ILSpy.TreeNodes; using ICSharpCode.ILSpy.ViewModels; using ICSharpCode.ILSpy.Views; @@ -61,6 +66,79 @@ public class CommandLineArgumentsTests languageService.CurrentLanguage.Name.Should().Be("IL"); } + [AvaloniaTest] + public async Task NavigateTo_An_Id_That_Names_Nothing_Reports_What_Was_Searched() + { + // An ID that resolves to nothing used to leave the tree untouched and say nothing, so a + // jump that silently did not happen looked like a jump to the wrong place. The target + // and the assemblies that were searched are written to the pane the jump would have + // filled (issue #2093). + + // Arrange - boot, and decompile something so the main tab really is a decompiler tab. + // Whether one is active at startup is a race, and the report lands elsewhere when it + // is not; the sibling test below covers that case. + var window = AppComposition.Current.GetExport(); + window.Show(); + var vm = (MainWindowViewModel)window.DataContext!; + await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3); + var typeNode = vm.AssemblyTreeModel.FindNode( + "System.Linq", "System.Linq", "System.Linq.Enumerable"); + vm.AssemblyTreeModel.SelectNode(typeNode); + await vm.DockWorkspace.WaitForDecompiledTextAsync(); + vm.DockWorkspace.ActiveDecompilerTab.Should().NotBeNull("the report's preferred sink must exist"); + + var args = CommandLineArguments.Create(new[] { "--navigateto", "M:No.Such.Type.NoSuchMember" }); + + // Act - apply the args. + await vm.AssemblyTreeModel.HandleCommandLineArgumentsAsync(args); + + // Assert - the pane the jump would have filled names the target that was not found. + var tab = vm.DockWorkspace.ActiveDecompilerTab; + tab.Should().NotBeNull("the report goes to the decompiler pane"); + tab!.Text.Should().Contain("M:No.Such.Type.NoSuchMember"); + } + + [AvaloniaTest] + public async Task NavigateTo_Reports_An_Unresolved_Id_Even_Without_An_Active_Decompiler_Tab() + { + // DockWorkspace.ShowText writes to the active decompiler tab and silently does nothing + // when the active content is something else, which is a real state at startup and + // whenever a metadata table is in front. A report that can go missing is no better than + // the silence it replaces. + + // Arrange - boot and put a metadata table in front, so there is no decompiler tab. + var window = AppComposition.Current.GetExport(); + window.Show(); + var vm = (MainWindowViewModel)window.DataContext!; + await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3); + var typeDefNode = vm.AssemblyTreeModel.FindCoreLib() + .GetChild() + .GetChild() + .GetChild(); + vm.AssemblyTreeModel.SelectNode(typeDefNode); + await vm.DockWorkspace.WaitForMetadataTabAsync(); + vm.DockWorkspace.ActiveDecompilerTab.Should().BeNull("the metadata table must be in front"); + + var args = CommandLineArguments.Create(new[] { "--navigateto", "M:No.Such.Type.NoSuchMember" }); + + // Act - apply the args. + await vm.AssemblyTreeModel.HandleCommandLineArgumentsAsync(args); + + // Assert - the report opened a tab of its own rather than vanishing. ActiveDecompilerTab + // only ever names the main tab's content, which the metadata table still occupies, so + // the new tab is looked for among the open documents. + await Waiters.WaitForAsync(() => ReportTabs(vm).Any()); + ReportTabs(vm).Single().Text.Should().Contain("M:No.Such.Type.NoSuchMember"); + + static IEnumerable ReportTabs(MainWindowViewModel vm) + => vm.DockWorkspace.Documents?.VisibleDockables? + .OfType() + .Select(t => t.Content) + .OfType() + .Where(t => t.Title == "Navigation") + ?? []; + } + [AvaloniaTest] public async Task NavigateTo_Type_Arg_Selects_The_Matching_Type_Node() { diff --git a/ILSpy.Tests/ILSpy.Tests.csproj b/ILSpy.Tests/ILSpy.Tests.csproj index 018389097..fdd5cf9d8 100644 --- a/ILSpy.Tests/ILSpy.Tests.csproj +++ b/ILSpy.Tests/ILSpy.Tests.csproj @@ -27,6 +27,20 @@ + + + true + + + + + + +