Browse Source

Merge pull request #4116 from icsharpcode/fix/2093-navigateto-reference-assembly

Fix #2093: find a navigation target in a reference assembly too
pull/4118/head
Siegfried Pammer 1 week ago committed by GitHub
parent
commit
951ca987dd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 96
      ILSpy.Tests/AssemblyTree/NavigateToReferenceAssemblyTests.cs
  2. 78
      ILSpy.Tests/Commands/CommandLineArgumentsTests.cs
  3. 14
      ILSpy.Tests/ILSpy.Tests.csproj
  4. 59
      ILSpy/AssemblyTree/AssemblyTreeModel.cs
  5. 9
      ILSpy/Properties/Resources.Designer.cs
  6. 3
      ILSpy/Properties/Resources.resx

96
ILSpy.Tests/AssemblyTree/NavigateToReferenceAssemblyTests.cs

@ -0,0 +1,96 @@ @@ -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;
/// <summary>
/// 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).
/// </summary>
[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");
}
}

78
ILSpy.Tests/Commands/CommandLineArgumentsTests.cs

@ -18,6 +18,8 @@ @@ -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; @@ -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 @@ -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<MainWindow>();
window.Show();
var vm = (MainWindowViewModel)window.DataContext!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3);
var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"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<MainWindow>();
window.Show();
var vm = (MainWindowViewModel)window.DataContext!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3);
var typeDefNode = vm.AssemblyTreeModel.FindCoreLib()
.GetChild<MetadataTreeNode>()
.GetChild<MetadataTablesTreeNode>()
.GetChild<TypeDefTableTreeNode>();
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<DecompilerTabPageModel> ReportTabs(MainWindowViewModel vm)
=> vm.DockWorkspace.Documents?.VisibleDockables?
.OfType<ContentTabPage>()
.Select(t => t.Content)
.OfType<DecompilerTabPageModel>()
.Where(t => t.Title == "Navigation")
?? [];
}
[AvaloniaTest]
public async Task NavigateTo_Type_Arg_Selects_The_Matching_Type_Node()
{

14
ILSpy.Tests/ILSpy.Tests.csproj

@ -27,6 +27,20 @@ @@ -27,6 +27,20 @@
<PackageReference Include="NSubstitute" />
</ItemGroup>
<!-- A real reference assembly to test navigation against: the compiler writes one for this
project, carrying the ReferenceAssembly attribute and the same members as the output
assembly, which is exactly the pair a targeting pack and its runtime form. Copying it
next to the tests keeps the fixture off machine-specific NuGet paths. -->
<PropertyGroup>
<ProduceReferenceAssembly>true</ProduceReferenceAssembly>
</PropertyGroup>
<Target Name="CopyReferenceAssemblyFixture" AfterTargets="Build" Inputs="@(IntermediateRefAssembly)"
Outputs="$(OutDir)ReferenceAssemblyFixture\$(TargetFileName)">
<MakeDir Directories="$(OutDir)ReferenceAssemblyFixture" />
<Copy SourceFiles="@(IntermediateRefAssembly)" DestinationFolder="$(OutDir)ReferenceAssemblyFixture" />
</Target>
<ItemGroup>
<ProjectReference Include="..\ILSpy\ILSpy.csproj" />
<!-- Build the sample plugin so the composition test has something to load, but DON'T copy

59
ILSpy/AssemblyTree/AssemblyTreeModel.cs

@ -18,6 +18,7 @@ @@ -18,6 +18,7 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.Composition;
@ -734,11 +735,36 @@ namespace ICSharpCode.ILSpy.AssemblyTree @@ -734,11 +735,36 @@ namespace ICSharpCode.ILSpy.AssemblyTree
&& await NavigateOnLaunchAsync(navigateTo, relevant);
if (!navigationHandled && newlyLoaded.Count == 1 && FindAssemblyNode(newlyLoaded[0]) is { } singleNode)
SelectNode(singleNode);
// An ID that named nothing leaves the tree wherever it was, which on its own says
// only that the jump did not happen. Name the target and what was searched, in the
// pane the jump would have filled.
if (!navigationHandled && args.NavigateTo is { Length: > 0 } unresolved)
ReportUnresolvedNavigationTarget(unresolved, relevant);
// Search-pane wiring lands with task 6. Until then the arg parses but is a no-op
// rather than crashing.
}
static void ReportUnresolvedNavigationTarget(string navigateTo, IList<LoadedAssembly> searched)
{
var output = new TextView.AvaloniaEditTextOutput { Title = "Navigation" };
output.WriteLine(string.Format(Properties.Resources.NavigationTargetNotFound, navigateTo));
foreach (var asm in searched)
{
output.WriteLine(" " + asm.FileName);
}
if (AppEnv.AppComposition.TryGetExport<Docking.DockWorkspace>() is not { } dockWorkspace)
return;
// 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. A report that can go missing is no
// better than the silence it replaces, so fall back to a tab of its own.
if (dockWorkspace.ActiveDecompilerTab != null)
dockWorkspace.ShowText(output);
else
dockWorkspace.ShowTextInNewTab(output.Title, output);
}
/// <summary>
/// Navigates to the given target. Returns false if it named nothing, leaving the
/// selection for the caller to fill in.
@ -808,20 +834,31 @@ namespace ICSharpCode.ILSpy.AssemblyTree @@ -808,20 +834,31 @@ namespace ICSharpCode.ILSpy.AssemblyTree
/// </summary>
internal static IReadOnlyList<IEntity> FindEntitiesInRelevantAssemblies(string navigateTo, IEnumerable<LoadedAssembly> relevantAssemblies)
{
// Reference assemblies are skipped so the search keeps looking for another
// assembly that might have a usable definition.
IReadOnlyList<MetadataFile> modules = [.. from asm in relevantAssemblies let mod = asm.GetMetadataFileOrNull() where mod != null && !mod.IsReferenceAssembly() select mod];
// The id came from a command line, so it is searched with the omission-tolerant
// ladder rather than resolved exactly: a parameter list or a generic arity that has
// to be spelled out is one the caller had to know before asking.
var (module, handles) = DocumentationIdSearch.Find(navigateTo, modules);
if (module == null || handles.IsEmpty)
IReadOnlyList<MetadataFile> loaded = [.. from asm in relevantAssemblies let mod = asm.GetMetadataFileOrNull() where mod != null select mod];
// A definition with a body says more than a signature-only one, so the reference
// assemblies are searched only once the others have come up empty. Skipping them
// outright would leave the target unresolved for the assembly list a project's
// references make up, which is what the VS add-in passes (issue #2093).
var (module, handles) = FindInModules([.. loaded.Where(mod => !mod.IsReferenceAssembly())]);
if (module == null)
(module, handles) = FindInModules([.. loaded.Where(mod => mod.IsReferenceAssembly())]);
if (module == null)
return [];
(MetadataFile? Module, ImmutableArray<EntityHandle> Handles) FindInModules(IReadOnlyList<MetadataFile> modules)
{
if (modules.Count == 0)
return default;
// The id came from a command line, so it is searched with the omission-tolerant
// ladder rather than resolved exactly: a parameter list or a generic arity that
// has to be spelled out is one the caller had to know before asking.
var (found, foundHandles) = DocumentationIdSearch.Find(navigateTo, modules);
if (found != null && !foundHandles.IsEmpty)
return (found, foundHandles);
var (forwardedModule, handle) = FindMemberViaTypeForwarders(navigateTo, modules);
if (forwardedModule == null || handle.IsNil)
return [];
module = forwardedModule;
handles = [handle];
return default;
return (forwardedModule, [handle]);
}
if (module.GetLoadedAssembly().GetTypeSystemOrNull()?.MainModule is not MetadataModule metadataModule)
return [];

9
ILSpy/Properties/Resources.Designer.cs generated

@ -2425,6 +2425,15 @@ namespace ICSharpCode.ILSpy.Properties { @@ -2425,6 +2425,15 @@ namespace ICSharpCode.ILSpy.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Could not find &apos;{0}&apos; in the assemblies that were opened:.
/// </summary>
public static string NavigationTargetNotFound {
get {
return ResourceManager.GetString("NavigationTargetNotFound", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to New list.
/// </summary>

3
ILSpy/Properties/Resources.resx

@ -841,6 +841,9 @@ Are you sure you want to continue?</value> @@ -841,6 +841,9 @@ Are you sure you want to continue?</value>
<value>Navigation failed because the target is hidden or a compiler-generated class.
Please disable all filters that might hide the item (i.e. activate "View &gt; Show internal types and members") and try again.</value>
</data>
<data name="NavigationTargetNotFound" xml:space="preserve">
<value>Could not find '{0}' in the assemblies that were opened:</value>
</data>
<data name="NewList" xml:space="preserve">
<value>New list</value>
</data>

Loading…
Cancel
Save