From 9a605199322314a333d094472cccac3802595667 Mon Sep 17 00:00:00 2001 From: pieroviano Date: Tue, 6 Oct 2026 07:32:30 +0300 Subject: [PATCH] Add Unresolved References Report to the Navigate menu Assemblies with missing dependencies otherwise have to be inspected one References folder at a time. The report resolves every assembly reference of the selected assemblies (or the whole list) off the UI thread and lists the failures, grouped by referencing assembly, with the resolver's probe log explaining each one. --- .../UnresolvedReferencesReport.cs | 108 +++++++++++++++ .../Dependencies/DependencyFixtures.cs | 131 ++++++++++++++++++ .../UnresolvedReferencesReportTests.cs | 67 +++++++++ .../UnresolvedReferencesReportUiTests.cs | 67 +++++++++ ILSpy.Tests/MainWindow/MainMenuTests.cs | 10 +- .../UnresolvedReferencesReportActions.cs | 105 ++++++++++++++ .../UnresolvedReferencesReportCommand.cs | 45 ++++++ ILSpy/Properties/Resources.Designer.cs | 18 +++ ILSpy/Properties/Resources.resx | 6 + ILSpy/Views/MainMenu.axaml.cs | 11 +- 10 files changed, 561 insertions(+), 7 deletions(-) create mode 100644 ICSharpCode.ILSpyX/Dependencies/UnresolvedReferencesReport.cs create mode 100644 ILSpy.Tests/Dependencies/DependencyFixtures.cs create mode 100644 ILSpy.Tests/Dependencies/UnresolvedReferencesReportTests.cs create mode 100644 ILSpy.Tests/Dependencies/UnresolvedReferencesReportUiTests.cs create mode 100644 ILSpy/Dependencies/UnresolvedReferencesReportActions.cs create mode 100644 ILSpy/Dependencies/UnresolvedReferencesReportCommand.cs diff --git a/ICSharpCode.ILSpyX/Dependencies/UnresolvedReferencesReport.cs b/ICSharpCode.ILSpyX/Dependencies/UnresolvedReferencesReport.cs new file mode 100644 index 00000000000..35680ce024f --- /dev/null +++ b/ICSharpCode.ILSpyX/Dependencies/UnresolvedReferencesReport.cs @@ -0,0 +1,108 @@ +// Copyright (c) 2026 Piero Viano +// +// 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.Linq; +using System.Threading; + +using ICSharpCode.Decompiler.Metadata; + +namespace ICSharpCode.ILSpyX.Dependencies +{ + /// An assembly reference that could not be resolved, with its resolution log. + public sealed class UnresolvedReference + { + public UnresolvedReference(AssemblyReference reference, IReadOnlyList<(MessageKind Kind, string Message)> messages) + { + Reference = reference ?? throw new ArgumentNullException(nameof(reference)); + Messages = messages ?? throw new ArgumentNullException(nameof(messages)); + } + + public AssemblyReference Reference { get; } + + /// The probing messages the assembly resolver logged for this reference. + public IReadOnlyList<(MessageKind Kind, string Message)> Messages { get; } + } + + /// The unresolved references of one referencing assembly. + public sealed class UnresolvedReferenceGroup + { + public UnresolvedReferenceGroup(LoadedAssembly assembly, IReadOnlyList references) + { + Assembly = assembly ?? throw new ArgumentNullException(nameof(assembly)); + References = references ?? throw new ArgumentNullException(nameof(references)); + } + + public LoadedAssembly Assembly { get; } + + public IReadOnlyList References { get; } + } + + /// + /// Collects every assembly reference that does not resolve, grouped by the referencing + /// assembly. Resolution goes through each assembly's own resolver (the same one the References + /// folder uses), so the probing log it records is what the report shows. + /// + public static class UnresolvedReferencesReport + { + /// + /// Resolves every assembly reference of and returns the groups + /// that have at least one unresolved reference, in input order. Assemblies that failed to + /// load are skipped. Must not run on the UI thread: resolving may probe the file system. + /// + public static IReadOnlyList Collect(IEnumerable assemblies, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(assemblies); + var groups = new List(); + foreach (var assembly in assemblies.ToList()) + { + cancellationToken.ThrowIfCancellationRequested(); + var module = assembly.GetMetadataFileOrNull(); + if (module == null) + continue; + var resolver = assembly.GetAssemblyResolver(); + var unresolved = new List(); + foreach (var reference in module.AssemblyReferences) + { + cancellationToken.ThrowIfCancellationRequested(); + MetadataFile? resolved; + try + { + resolved = resolver.Resolve(reference); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + resolved = null; + } + if (resolved != null) + continue; + IReadOnlyList<(MessageKind, string)> messages = + assembly.LoadedAssemblyReferencesInfo.TryGetInfo(reference.FullName, out var info) + ? info.Messages.ToList() + : Array.Empty<(MessageKind, string)>(); + unresolved.Add(new UnresolvedReference(reference, messages)); + } + if (unresolved.Count > 0) + groups.Add(new UnresolvedReferenceGroup(assembly, unresolved)); + } + return groups; + } + } +} diff --git a/ILSpy.Tests/Dependencies/DependencyFixtures.cs b/ILSpy.Tests/Dependencies/DependencyFixtures.cs new file mode 100644 index 00000000000..23fef2c1d14 --- /dev/null +++ b/ILSpy.Tests/Dependencies/DependencyFixtures.cs @@ -0,0 +1,131 @@ +// Copyright (c) 2026 Piero Viano +// +// 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.IO; +using System.Reflection; +using System.Reflection.Emit; +using System.Runtime.Loader; + +namespace ICSharpCode.ILSpy.Tests.Dependencies; + +/// +/// Emits small library/consumer assembly pairs for the dependency features. The library defines +/// Api.Compute(int); the consumer calls it from ., +/// so it carries an assembly reference to the library. +/// +static class DependencyFixtures +{ + public const string BodyUserType = "UsesInBody"; + public const string BodyUserMethod = "Run"; + + /// A fresh, empty temp directory. + public static string NewDirectory() + { + var dir = Path.Combine(Path.GetTempPath(), $"ILSpyDependencyFixture_{Guid.NewGuid():N}"); + Directory.CreateDirectory(dir); + return dir; + } + + /// A unique simple assembly name with the given prefix. + public static string UniqueName(string prefix) => prefix + Guid.NewGuid().ToString("N").Substring(0, 8); + + /// Emits the library <directory>/<name>.dll and returns its path. + public static string EmitLibrary(string directory, string name) + { + var assemblyName = new AssemblyName(name) { Version = new Version(1, 0, 0, 0) }; + var ab = new PersistedAssemblyBuilder(assemblyName, typeof(object).Assembly); + var module = ab.DefineDynamicModule(name); + + var api = module.DefineType($"{name}.Api", TypeAttributes.Public | TypeAttributes.Class); + var compute = api.DefineMethod("Compute", MethodAttributes.Public | MethodAttributes.Static, typeof(int), [typeof(int)]); + var il = compute.GetILGenerator(); + il.Emit(OpCodes.Ldarg_0); + il.Emit(OpCodes.Ret); + api.CreateType(); + + var path = Path.Combine(directory, name + ".dll"); + ab.Save(path); + return path; + } + + /// + /// Emits the consumer <directory>/<name>.dll compiled against the library at + /// and returns its path. The library is read into a collectible + /// load context from memory, so its file is neither locked nor kept loaded. + /// + public static string EmitConsumer(string directory, string name, string libraryPath) + { + var context = new AssemblyLoadContext(name, isCollectible: true); + try + { + using var stream = new MemoryStream(File.ReadAllBytes(libraryPath)); + var library = context.LoadFromStream(stream); + string libraryName = library.GetName().Name!; + var apiType = library.GetType($"{libraryName}.Api", throwOnError: true)!; + + var ab = new PersistedAssemblyBuilder(new AssemblyName(name) { Version = new Version(1, 0, 0, 0) }, typeof(object).Assembly); + var module = ab.DefineDynamicModule(name); + + var bodyUser = module.DefineType($"{name}.{BodyUserType}", TypeAttributes.Public | TypeAttributes.Class); + var run = bodyUser.DefineMethod(BodyUserMethod, MethodAttributes.Public | MethodAttributes.Static, typeof(int), Type.EmptyTypes); + var il = run.GetILGenerator(); + il.Emit(OpCodes.Ldc_I4_1); + il.Emit(OpCodes.Call, apiType.GetMethod("Compute")!); + il.Emit(OpCodes.Ret); + bodyUser.CreateType(); + + var path = Path.Combine(directory, name + ".dll"); + ab.Save(path); + return path; + } + finally + { + context.Unload(); + } + } + + /// + /// Emits a library and a consumer next to each other in a fresh directory, so the consumer's + /// reference resolves from its own folder. + /// + public static (string LibraryPath, string ConsumerPath, string LibraryName, string ConsumerName) EmitPair(string prefix = "Dep") + { + var dir = NewDirectory(); + var libraryName = UniqueName(prefix + "Lib"); + var consumerName = UniqueName(prefix + "App"); + var library = EmitLibrary(dir, libraryName); + var consumer = EmitConsumer(dir, consumerName, library); + return (library, consumer, libraryName, consumerName); + } + + /// + /// Emits a consumer whose library reference cannot be resolved: the library is built in a + /// separate directory that is deleted afterwards. + /// + public static (string ConsumerPath, string MissingLibraryName) EmitConsumerWithMissingReference(string prefix = "Dep") + { + var libraryDir = NewDirectory(); + var consumerDir = NewDirectory(); + var libraryName = UniqueName(prefix + "Missing"); + var library = EmitLibrary(libraryDir, libraryName); + var consumer = EmitConsumer(consumerDir, UniqueName(prefix + "Orphan"), library); + Directory.Delete(libraryDir, recursive: true); + return (consumer, libraryName); + } +} diff --git a/ILSpy.Tests/Dependencies/UnresolvedReferencesReportTests.cs b/ILSpy.Tests/Dependencies/UnresolvedReferencesReportTests.cs new file mode 100644 index 00000000000..9246068a5c2 --- /dev/null +++ b/ILSpy.Tests/Dependencies/UnresolvedReferencesReportTests.cs @@ -0,0 +1,67 @@ +// Copyright (c) 2026 Piero Viano +// +// 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.Threading.Tasks; + +using AwesomeAssertions; + +using ICSharpCode.Decompiler.Metadata; +using ICSharpCode.ILSpyX; +using ICSharpCode.ILSpyX.Dependencies; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests.Dependencies; + +[TestFixture] +public class UnresolvedReferencesReportTests +{ + [Test] + public async Task Missing_Reference_Is_Reported_Under_Its_Referencing_Assembly_With_The_Probe_Log() + { + var (consumerPath, missingName) = DependencyFixtures.EmitConsumerWithMissingReference("Report"); + var (libraryPath, resolvableConsumerPath, libraryName, _) = DependencyFixtures.EmitPair("Report"); + var list = new AssemblyList(); + var orphan = list.OpenAssembly(consumerPath); + var healthy = list.OpenAssembly(resolvableConsumerPath); + await orphan.GetLoadResultAsync(); + await healthy.GetLoadResultAsync(); + + var groups = UnresolvedReferencesReport.Collect([orphan, healthy]); + + var group = groups.Single(g => g.Assembly == orphan); + var missing = group.References.Single(r => r.Reference.Name == missingName); + missing.Messages.Should().Contain(m => m.Kind == MessageKind.Error && m.Message.Contains(missingName), + "the resolver's probe log explains why the reference failed"); + groups.SelectMany(g => g.References).Should().NotContain(r => r.Reference.Name == libraryName, + "a reference that resolves is not reported"); + } + + [Test] + public async Task Fully_Resolved_Assemblies_Produce_No_Group() + { + var (libraryPath, _, _, _) = DependencyFixtures.EmitPair("Report"); + var list = new AssemblyList(); + var library = list.OpenAssembly(libraryPath); + await library.GetLoadResultAsync(); + + // The fixture library only references the core library, which always resolves. + UnresolvedReferencesReport.Collect([library]).Should().BeEmpty(); + } +} diff --git a/ILSpy.Tests/Dependencies/UnresolvedReferencesReportUiTests.cs b/ILSpy.Tests/Dependencies/UnresolvedReferencesReportUiTests.cs new file mode 100644 index 00000000000..c509a3cdc98 --- /dev/null +++ b/ILSpy.Tests/Dependencies/UnresolvedReferencesReportUiTests.cs @@ -0,0 +1,67 @@ +// Copyright (c) 2026 Piero Viano +// +// 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.Linq; +using System.Threading.Tasks; + +using Avalonia.Headless.NUnit; + +using AwesomeAssertions; + +using ICSharpCode.ILSpy.AppEnv; +using ICSharpCode.ILSpy.Commands; +using ICSharpCode.ILSpy.Docking; +using ICSharpCode.ILSpy.Properties; +using ICSharpCode.ILSpy.TextView; +using ICSharpCode.ILSpy.TreeNodes; +using ICSharpCode.ILSpy.ViewModels; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests.Dependencies; + +[TestFixture] +public class UnresolvedReferencesReportUiTests +{ + [AvaloniaTest] + public async Task Unresolved_References_Report_Lists_The_Missing_Reference_With_Its_Log() + { + var (_, vm) = await TestHarness.BootAsync(); + var (consumerPath, missingName) = DependencyFixtures.EmitConsumerWithMissingReference("Ui"); + var orphan = await vm.OpenAssemblyAsync(consumerPath); + vm.AssemblyTreeModel.SelectNode(orphan.ShortName); + + var registry = AppComposition.Current.GetExport(); + registry.Commands.Single(c => c.Metadata.Header == nameof(Resources.UnresolvedReferencesReport)).Metadata.ParentMenuID + .Should().Be(nameof(Resources._Navigate)); + registry.GetCommand(nameof(Resources.UnresolvedReferencesReport)).Execute(null); + + var dockWorkspace = AppComposition.Current.GetExport(); + string? text = null; + await Waiters.WaitForAsync(() => { + var content = dockWorkspace.Documents?.VisibleDockables?.OfType() + .Select(t => t.Content).OfType() + .FirstOrDefault(c => c.Title == Resources.UnresolvedReferencesReport); + text = content?.Text; + return text?.Contains(missingName, StringComparison.Ordinal) == true; + }, description: "unresolved references report text"); + text.Should().Contain(orphan.ShortName); + text.Should().Contain("Could not find reference"); + } +} diff --git a/ILSpy.Tests/MainWindow/MainMenuTests.cs b/ILSpy.Tests/MainWindow/MainMenuTests.cs index b770a8e9fd8..bca65bb61e9 100644 --- a/ILSpy.Tests/MainWindow/MainMenuTests.cs +++ b/ILSpy.Tests/MainWindow/MainMenuTests.cs @@ -36,7 +36,7 @@ namespace ICSharpCode.ILSpy.Tests; -// MainMenu's top-level structure (File / View / Window with mnemonic underscores) is the +// MainMenu's top-level structure (File / View / Navigate / Window with mnemonic underscores) is the // scaffolding every later commit hangs items onto via MEF. If a future commit accidentally // drops one of these top-levels or shuffles the order, the [ExportMainMenuCommand] entries // that target them by header would silently land in the wrong menu. @@ -44,7 +44,7 @@ namespace ICSharpCode.ILSpy.Tests; public class MainMenuTests { [AvaloniaTest] - public void MainMenu_top_level_items_are_File_View_Window_in_order() + public void MainMenu_top_level_items_are_File_View_Navigate_Window_in_order() { var window = AppComposition.Current.GetExport(); window.Show(); @@ -58,7 +58,7 @@ public void MainMenu_top_level_items_are_File_View_Window_in_order() // PromoteHelpToMacAppMenu relocates the Help items into the application menu // (macOS convention: About lives under the bold app-named menu), so _Help is // not a window-menu top-level there. - headers.Should().Equal("_File", "_View", "_Window"); + headers.Should().Equal("_File", "_View", "_Navigate", "_Window"); var appMenu = NativeMenu.GetMenu(Application.Current!); appMenu.Should().NotBeNull("App.axaml declares the NativeMenu the Help items move into"); @@ -67,7 +67,7 @@ public void MainMenu_top_level_items_are_File_View_Window_in_order() } else { - headers.Should().Equal("_File", "_View", "_Window", "_Help"); + headers.Should().Equal("_File", "_View", "_Navigate", "_Window", "_Help"); } } @@ -156,7 +156,7 @@ public void Closing_An_Earlier_Window_Leaves_A_Later_Window_Help_Items_In_Place( // The app menu is declared on Application and outlives every test, so a test that promotes // placeholder items into it has to take them back out; otherwise a later test reading it - // (see MainMenu_top_level_items_are_File_View_Window_in_order) sees this test's leftovers. + // (see MainMenu_top_level_items_are_File_View_Navigate_Window_in_order) sees this test's leftovers. static void RestoreAppMenu(NativeMenu appMenu, List promoted) { foreach (var item in promoted) diff --git a/ILSpy/Dependencies/UnresolvedReferencesReportActions.cs b/ILSpy/Dependencies/UnresolvedReferencesReportActions.cs new file mode 100644 index 00000000000..7c4c41774f9 --- /dev/null +++ b/ILSpy/Dependencies/UnresolvedReferencesReportActions.cs @@ -0,0 +1,105 @@ +// Copyright (c) 2026 Piero Viano +// +// 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.Linq; +using System.Threading; +using System.Threading.Tasks; + +using ICSharpCode.Decompiler; +using ICSharpCode.Decompiler.Metadata; +using ICSharpCode.ILSpyX; +using ICSharpCode.ILSpyX.Dependencies; +using ICSharpCode.ILSpyX.TreeView; + +using ICSharpCode.ILSpy.AppEnv; +using ICSharpCode.ILSpy.Docking; +using ICSharpCode.ILSpy.Properties; +using ICSharpCode.ILSpy.TextView; +using ICSharpCode.ILSpy.TreeNodes; + +namespace ICSharpCode.ILSpy.Dependencies +{ + /// + /// The action behind the unresolved-references report: collect the assembly references that do + /// not resolve (off the UI thread) and show them, with their probe logs, in a new document tab. + /// + public static class UnresolvedReferencesReportActions + { + /// + /// The assemblies the report applies to: the selected assembly nodes (with + /// , also the assemblies owning any other selected + /// node), or every successfully loaded assembly of when that yields none. + /// + public static IReadOnlyList GetTargetAssemblies(IEnumerable? selection, AssemblyList? list, + bool includeOwnersOfSelection = true) + { + var selected = (selection ?? Enumerable.Empty()) + .Select(n => includeOwnersOfSelection + ? n.AncestorsAndSelf().OfType().FirstOrDefault() + : n as AssemblyTreeNode) + .Where(n => n is { LoadedAssembly.IsLoadedAsValidAssembly: true }) + .Select(n => n!.LoadedAssembly) + .Distinct() + .ToList(); + if (selected.Count > 0) + return selected; + return list?.GetAssemblies().Where(a => a.IsLoadedAsValidAssembly).ToList() + ?? (IReadOnlyList)Array.Empty(); + } + + /// Opens the unresolved-references report for in a new tab. + public static Task ShowUnresolvedReferencesReportAsync(IReadOnlyList assemblies) + { + ArgumentNullException.ThrowIfNull(assemblies); + var dockWorkspace = AppComposition.TryGetExport(); + if (dockWorkspace == null) + return Task.CompletedTask; + return dockWorkspace.RunInNewTabAsync(Resources.UnresolvedReferencesReport, + token => Task.Run(() => CreateUnresolvedReferencesReport(assemblies, token), token)); + } + + /// Renders the unresolved-references report, grouped by referencing assembly. + internal static AvaloniaEditTextOutput CreateUnresolvedReferencesReport(IReadOnlyList assemblies, CancellationToken token) + { + var groups = UnresolvedReferencesReport.Collect(assemblies, token); + var output = new AvaloniaEditTextOutput { Title = Resources.UnresolvedReferencesReport }; + int total = groups.Sum(g => g.References.Count); + output.WriteLine($"{total} unresolved assembly reference(s) in {groups.Count} of {assemblies.Count} assemblies."); + output.WriteLine(); + foreach (var group in groups) + { + output.WriteLine($"{group.Assembly.ShortName} ({group.Assembly.FileName})"); + output.Indent(); + foreach (var unresolved in group.References) + { + output.WriteLine(unresolved.Reference.FullName); + output.Indent(); + var log = new UnresolvedAssemblyNameReference(unresolved.Reference.FullName); + log.Messages.AddRange(unresolved.Messages.Select(m => (m.Kind, m.Message))); + AssemblyReferenceTreeNode.PrintAssemblyLoadLogMessages(output, log); + output.Unindent(); + } + output.Unindent(); + output.WriteLine(); + } + return output; + } + } +} diff --git a/ILSpy/Dependencies/UnresolvedReferencesReportCommand.cs b/ILSpy/Dependencies/UnresolvedReferencesReportCommand.cs new file mode 100644 index 00000000000..6faa454c042 --- /dev/null +++ b/ILSpy/Dependencies/UnresolvedReferencesReportCommand.cs @@ -0,0 +1,45 @@ +// Copyright (c) 2026 Piero Viano +// +// 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.Composition; + +using ICSharpCode.ILSpy.AssemblyTree; +using ICSharpCode.ILSpy.Commands; +using ICSharpCode.ILSpy.Properties; + +namespace ICSharpCode.ILSpy.Dependencies +{ + /// + /// Navigate -> "Unresolved References Report" for the selected assembly nodes, or the whole list + /// when no assembly node is selected. + /// + [ExportMainMenuCommand(ParentMenuID = nameof(Resources._Navigate), Header = nameof(Resources.UnresolvedReferencesReport), MenuCategory = "Dependencies", MenuOrder = 620)] + [Shared] + [method: ImportingConstructor] + public sealed class UnresolvedReferencesReportCommand(AssemblyTreeModel assemblyTreeModel) : SimpleCommand + { + public override bool CanExecute(object? parameter) => (assemblyTreeModel.AssemblyList?.Count ?? 0) > 0; + + public override void Execute(object? parameter) + { + var assemblies = UnresolvedReferencesReportActions.GetTargetAssemblies(assemblyTreeModel.SelectedItems, assemblyTreeModel.AssemblyList, + includeOwnersOfSelection: false); + UnresolvedReferencesReportActions.ShowUnresolvedReferencesReportAsync(assemblies).HandleExceptions(); + } + } +} diff --git a/ILSpy/Properties/Resources.Designer.cs b/ILSpy/Properties/Resources.Designer.cs index a276ef2f82b..571f329a272 100644 --- a/ILSpy/Properties/Resources.Designer.cs +++ b/ILSpy/Properties/Resources.Designer.cs @@ -150,6 +150,15 @@ public static string _LoadDependencies { } } + /// + /// Looks up a localized string similar to _Navigate. + /// + public static string _Navigate { + get { + return ResourceManager.GetString("_Navigate", resourceCulture); + } + } + /// /// Looks up a localized string similar to _New. /// @@ -3547,6 +3556,15 @@ public static string UILanguage_System { } } + /// + /// Looks up a localized string similar to Unresolved References Report. + /// + public static string UnresolvedReferencesReport { + get { + return ResourceManager.GetString("UnresolvedReferencesReport", resourceCulture); + } + } + /// /// Looks up a localized string similar to No update for ILSpy found.. /// diff --git a/ILSpy/Properties/Resources.resx b/ILSpy/Properties/Resources.resx index 94e510454b3..376272ec41b 100644 --- a/ILSpy/Properties/Resources.resx +++ b/ILSpy/Properties/Resources.resx @@ -1216,6 +1216,9 @@ Do you want to continue? System + + Unresolved References Report + No update for ILSpy found. @@ -1303,6 +1306,9 @@ Do you want to continue? _Load Dependencies + + _Navigate + _New diff --git a/ILSpy/Views/MainMenu.axaml.cs b/ILSpy/Views/MainMenu.axaml.cs index 710d347009a..09a8c2692ca 100644 --- a/ILSpy/Views/MainMenu.axaml.cs +++ b/ILSpy/Views/MainMenu.axaml.cs @@ -59,15 +59,22 @@ public static void Attach(Window window) var menu = new NativeMenu(); var topLevelByTag = new Dictionary(StringComparer.Ordinal); - // Pre-build the three known top-level slots so MEF commands with ParentMenuID set - // to "_File" / "_View" / "_Window" attach to them. Additional top-level groups + // Pre-build the known top-level slots so MEF commands with ParentMenuID set + // to "_File" / "_View" / "_Navigate" / "_Window" attach to them. Additional top-level groups // can still be created later from MEF metadata. AddTopLevel(menu, topLevelByTag, "_File", nameof(Resources._File)); var viewItem = AddTopLevel(menu, topLevelByTag, "_View", nameof(Resources._View)); + var navigateItem = AddTopLevel(menu, topLevelByTag, "_Navigate", nameof(Resources._Navigate)); var windowItem = AddTopLevel(menu, topLevelByTag, "_Window", nameof(Resources._Window)); PopulateViewMenu(viewItem.Menu!, settings.SessionSettings, setThemeCommand); AppendRegistryCommands(menu, topLevelByTag, registry.Commands); + // A top-level slot no command attached to would render as an empty, unclickable menu. + if (navigateItem.Menu!.Items.Count == 0) + { + menu.Items.Remove(navigateItem); + topLevelByTag.Remove("_Navigate"); + } AppendWindowDynamicContent(windowItem.Menu!, dockWorkspace); if (OperatingSystem.IsMacOS())