diff --git a/ILSpy.Tests/Analyzers/AnalyzeContextMenuTests.cs b/ILSpy.Tests/Analyzers/AnalyzeContextMenuTests.cs new file mode 100644 index 000000000..f5bede0be --- /dev/null +++ b/ILSpy.Tests/Analyzers/AnalyzeContextMenuTests.cs @@ -0,0 +1,148 @@ +// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team +// +// 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 Avalonia.Headless.NUnit; + +using AwesomeAssertions; + +using ICSharpCode.ILSpy.Properties; +using ICSharpCode.ILSpyX.TreeView; + +using ILSpy; +using ILSpy.Analyzers; +using ILSpy.AppEnv; +using ILSpy.TreeNodes; +using ILSpy.ViewModels; +using ILSpy.Views; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests.Analyzers; + +[TestFixture] +public class AnalyzeContextMenuTests +{ + [AvaloniaTest] + public async Task Analyze_Entry_Is_Registered_With_The_Localised_Header() + { + var window = AppComposition.Current.GetExport(); + window.Show(); + var vm = (MainWindowViewModel)window.DataContext!; + await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1); + + var registry = AppComposition.Current.GetExport(); + registry.Entries.Should().Contain(e => e.Metadata.Header == nameof(Resources.Analyze), + "the analyze context-menu entry must reach the right-click menu through the MEF registry"); + } + + [AvaloniaTest] + public async Task Analyze_Entry_Is_Visible_For_Member_Tree_Nodes_And_Hidden_For_AssemblyTreeNode() + { + // AnalyzeContextMenuEntry visibility contract: visible when every selected node is + // an IMemberTreeNode (types, methods, fields, properties, events), hidden otherwise. + + var window = AppComposition.Current.GetExport(); + window.Show(); + var vm = (MainWindowViewModel)window.DataContext!; + await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1); + + var registry = AppComposition.Current.GetExport(); + var entry = registry.Entries + .Single(e => e.Metadata.Header == nameof(Resources.Analyze)) + .Value; + + var typeNode = vm.AssemblyTreeModel.FindNode( + "System.Linq", "System.Linq", "System.Linq.Enumerable"); + entry.IsVisible(new TextViewContext { SelectedTreeNodes = new[] { (SharpTreeNode)typeNode } }) + .Should().BeTrue("a type selection must surface the Analyze entry"); + + var assemblyNode = vm.AssemblyTreeModel.FindNode("System.Linq"); + entry.IsVisible(new TextViewContext { SelectedTreeNodes = new[] { (SharpTreeNode)assemblyNode } }) + .Should().BeFalse("AssemblyTreeNode isn't an IMemberTreeNode — Analyze must hide"); + + entry.IsVisible(new TextViewContext { SelectedTreeNodes = null }) + .Should().BeFalse("no selection means no entity to analyse"); + entry.IsVisible(new TextViewContext { SelectedTreeNodes = System.Array.Empty() }) + .Should().BeFalse("an empty selection must hide the entry"); + } + + [AvaloniaTest] + public async Task Executing_Analyze_Pushes_The_Selected_Entity_Into_The_Analyzer_Pane() + { + // Execute calls AnalyzerTreeViewModel.Analyze for every IMemberTreeNode in the + // selection. The pane's Root.Children must hold one AnalyzedTypeTreeNode wrapping + // the same type definition after the call. + + var window = AppComposition.Current.GetExport(); + window.Show(); + var vm = (MainWindowViewModel)window.DataContext!; + await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1); + + var registry = AppComposition.Current.GetExport(); + var entry = registry.Entries + .Single(e => e.Metadata.Header == nameof(Resources.Analyze)) + .Value; + + var typeNode = vm.AssemblyTreeModel.FindNode( + "System.Linq", "System.Linq", "System.Linq.Enumerable"); + + var analyzerVm = AppComposition.Current.GetExport(); + var beforeCount = analyzerVm.Root.Children.Count; + + entry.Execute(new TextViewContext { SelectedTreeNodes = new[] { (SharpTreeNode)typeNode } }); + + analyzerVm.Root.Children.Count.Should().Be(beforeCount + 1, + "Execute on the analyze entry must add exactly one new row to the pane root"); + (analyzerVm.Root.Children.Last() is AnalyzerEntityTreeNode).Should().BeTrue( + "the new pane row must be an entity wrapper that exposes the analysed member"); + } + + [AvaloniaTest] + public async Task Pressing_Ctrl_R_On_The_Assembly_Tree_Analyses_The_Selected_Member() + { + // Ctrl+R while a member is selected on the assembly tree pane must surface the + // member in the analyzer pane — the same end-state as right-click + Analyze. + + var window = AppComposition.Current.GetExport(); + window.Show(); + var vm = (MainWindowViewModel)window.DataContext!; + await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1); + + var pane = await window.WaitForComponent(); + var typeNode = vm.AssemblyTreeModel.FindNode( + "System.Linq", "System.Linq", "System.Linq.Enumerable"); + vm.AssemblyTreeModel.SelectNode(typeNode); + + var analyzerVm = AppComposition.Current.GetExport(); + var beforeCount = analyzerVm.Root.Children.Count; + + var grid = await pane.WaitForComponent(); + grid.RaiseEvent(new global::Avalonia.Input.KeyEventArgs { + Key = global::Avalonia.Input.Key.R, + KeyModifiers = global::Avalonia.Input.KeyModifiers.Control, + RoutedEvent = global::Avalonia.Input.InputElement.KeyDownEvent, + Source = grid, + }); + + analyzerVm.Root.Children.Count.Should().Be(beforeCount + 1, + "Ctrl+R with a type selected must analyse it"); + } +} diff --git a/ILSpy/Analyzers/AnalyzeContextMenuEntry.cs b/ILSpy/Analyzers/AnalyzeContextMenuEntry.cs new file mode 100644 index 000000000..eb872ae74 --- /dev/null +++ b/ILSpy/Analyzers/AnalyzeContextMenuEntry.cs @@ -0,0 +1,83 @@ +// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team +// +// 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 System.Linq; + +using ICSharpCode.Decompiler.TypeSystem; +using ICSharpCode.ILSpy.Properties; + +using ILSpy.TreeNodes; + +namespace ILSpy.Analyzers +{ + /// + /// Right-click → "Analyze" — pushes every selected member (type, method, field, property, + /// event) into the analyzer pane. The pane's + /// dedupes entries by + parent module so re-running + /// the menu on the same entity just refocuses the existing row. + /// + [ExportContextMenuEntry( + Header = nameof(Resources.Analyze), + Category = nameof(Resources.Analyze), + InputGestureText = "Ctrl+R", + Order = 100)] + [Shared] + public sealed class AnalyzeContextMenuEntry : IContextMenuEntry + { + readonly AnalyzerTreeViewModel analyzerTreeViewModel; + + [ImportingConstructor] + public AnalyzeContextMenuEntry(AnalyzerTreeViewModel analyzerTreeViewModel) + { + this.analyzerTreeViewModel = analyzerTreeViewModel; + } + + public bool IsVisible(TextViewContext context) + { + if (context.SelectedTreeNodes is not { Length: > 0 } nodes) + return false; + return nodes.All(n => n is IMemberTreeNode); + } + + public bool IsEnabled(TextViewContext context) + { + if (context.SelectedTreeNodes is not { Length: > 0 } nodes) + return false; + return nodes.OfType().All(n => IsAnalysable(n.Member)); + } + + public void Execute(TextViewContext context) + { + if (context.SelectedTreeNodes is not { Length: > 0 } nodes) + return; + foreach (var member in nodes.OfType() + .Select(n => n.Member) + .Where(IsAnalysable)) + { + analyzerTreeViewModel.Analyze(member!); + } + } + + /// + /// Const fields are textual literals at every use-site rather than entities the + /// analyser can match against — exclude them so the entry stays disabled. + /// + static bool IsAnalysable(IEntity? entity) => entity is not null and not IField { IsConst: true }; + } +} diff --git a/ILSpy/AssemblyTree/AssemblyListPane.axaml.cs b/ILSpy/AssemblyTree/AssemblyListPane.axaml.cs index 369dd1221..e19dcd7e6 100644 --- a/ILSpy/AssemblyTree/AssemblyListPane.axaml.cs +++ b/ILSpy/AssemblyTree/AssemblyListPane.axaml.cs @@ -196,18 +196,49 @@ namespace ILSpy.AssemblyTree void OnTreeGridKeyDown(object? sender, KeyEventArgs e) { - if (e.Key != Key.Delete || DataContext is not AssemblyTreeModel model || model.AssemblyList is not { } list) + if (DataContext is not AssemblyTreeModel model) return; - // Snapshot before mutation — Unload mutates the list and indirectly the model's - // selection. - var assemblies = model.SelectedItems.OfType() - .Select(n => n.LoadedAssembly) - .ToList(); - if (assemblies.Count == 0) + if (e.Key == Key.Delete && model.AssemblyList is { } list) + { + // Snapshot before mutation — Unload mutates the list and indirectly the + // model's selection. + var assemblies = model.SelectedItems.OfType() + .Select(n => n.LoadedAssembly) + .ToList(); + if (assemblies.Count == 0) + return; + foreach (var asm in assemblies) + list.Unload(asm); + e.Handled = true; return; - foreach (var asm in assemblies) - list.Unload(asm); - e.Handled = true; + } + if (e.Key == Key.R && e.KeyModifiers == KeyModifiers.Control) + { + var members = model.SelectedItems.OfType() + .Select(n => n.Member) + .Where(m => m is not null and not ICSharpCode.Decompiler.TypeSystem.IField { IsConst: true }) + .ToList(); + if (members.Count == 0) + return; + var analyzerVm = TryGetAnalyzerTreeViewModel(); + if (analyzerVm == null) + return; + foreach (var member in members) + analyzerVm.Analyze(member!); + e.Handled = true; + } + } + + static ILSpy.Analyzers.AnalyzerTreeViewModel? TryGetAnalyzerTreeViewModel() + { + try + { + return AppComposition.Current.GetExport(); + } + catch + { + return null; + } } void OnTreeGridSelectionChanged(object? sender, SelectionChangedEventArgs e)