diff --git a/ICSharpCode.ILSpyX/Dependencies/UnresolvedReferencesReport.cs b/ICSharpCode.ILSpyX/Dependencies/UnresolvedReferencesReport.cs new file mode 100644 index 0000000000..35680ce024 --- /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 0000000000..23fef2c1d1 --- /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 0000000000..9246068a5c --- /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 0000000000..c509a3cdc9 --- /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 b770a8e9fd..bca65bb61e 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 0000000000..7c4c41774f --- /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 0000000000..6faa454c04 --- /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 a276ef2f82..571f329a27 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 94e510454b..376272ec41 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 710d347009..09a8c2692c 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())