From 461faeda58c48451204f774c60bc661f26a6409e Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Sun, 7 Jun 2026 21:03:15 +0200 Subject: [PATCH] Port the sample plugin to Avalonia TestPlugin was still WPF (net10.0-windows, UseWpf, System.Windows, TomsToolbox, the old ICSharpCode.ILSpy namespace), so it could not load into the cross-platform app. Re-target it to net10.0 and map each extension point onto the port's contracts: the custom Language, About-page addition, context-menu entry, main-menu/toolbar command, and an Avalonia options page whose view the ViewLocator resolves by naming convention. The command needs [ImportingConstructor] for System.Composition to satisfy its service dependency. Reference it build-only from the tests (no DLL copied into the test output) so a composition test can load it without the headless app auto-loading the plugin into every other test. Assisted-by: Claude:claude-opus-4-8:Claude Code --- ILSpy.Tests/ILSpy.Tests.csproj | 4 + .../Plugins/TestPluginCompositionTests.cs | 106 ++++++++++++++++++ ILSpy.sln | 14 +++ TestPlugin/AboutPageAddition.cs | 19 +++- TestPlugin/ContextMenuCommand.cs | 29 ++--- TestPlugin/CustomLanguage.cs | 30 ++--- TestPlugin/CustomOptionPage.xaml | 13 --- TestPlugin/CustomOptionsView.axaml | 15 +++ TestPlugin/CustomOptionsView.axaml.cs | 15 +++ ...Page.xaml.cs => CustomOptionsViewModel.cs} | 59 ++++------ TestPlugin/MainMenuCommand.cs | 19 ++-- TestPlugin/Properties/AssemblyInfo.cs | 18 --- TestPlugin/TestPlugin.csproj | 26 ++--- 13 files changed, 229 insertions(+), 138 deletions(-) create mode 100644 ILSpy.Tests/Plugins/TestPluginCompositionTests.cs delete mode 100644 TestPlugin/CustomOptionPage.xaml create mode 100644 TestPlugin/CustomOptionsView.axaml create mode 100644 TestPlugin/CustomOptionsView.axaml.cs rename TestPlugin/{CustomOptionPage.xaml.cs => CustomOptionsViewModel.cs} (51%) delete mode 100644 TestPlugin/Properties/AssemblyInfo.cs diff --git a/ILSpy.Tests/ILSpy.Tests.csproj b/ILSpy.Tests/ILSpy.Tests.csproj index d8dae73c1..018389097 100644 --- a/ILSpy.Tests/ILSpy.Tests.csproj +++ b/ILSpy.Tests/ILSpy.Tests.csproj @@ -29,6 +29,10 @@ + + diff --git a/ILSpy.Tests/Plugins/TestPluginCompositionTests.cs b/ILSpy.Tests/Plugins/TestPluginCompositionTests.cs new file mode 100644 index 000000000..f9abd511e --- /dev/null +++ b/ILSpy.Tests/Plugins/TestPluginCompositionTests.cs @@ -0,0 +1,106 @@ +// 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; +using System.Composition.Hosting; +using System.IO; +using System.Linq; +using System.Reflection; + +using AwesomeAssertions; + +using ILSpy.Commands; +using ILSpy.Languages; +using ILSpy.Options; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests.Plugins; + +/// +/// The ported sample plugin (Test.Plugin.dll) exposes its extension points through the same MEF +/// contracts the app composes at startup. This loads the built plugin assembly (not referenced into +/// this test's output, to avoid polluting the headless app's *.Plugin.dll scan) and asserts its +/// exports resolve against the Avalonia app's extension-point types. +/// +[TestFixture] +public class TestPluginCompositionTests +{ + static Assembly LoadPlugin() + { + // AppContext.BaseDirectory is .../ILSpy.Tests/bin//net11.0/. The plugin targets net10.0 + // (the host loads it as a library, so its TFM need not track the test project's), and builds to + // the sibling TestPlugin/bin//net10.0/ (the ProjectReference guarantees it's built first). + var baseDir = AppContext.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar); + var config = Path.GetFileName(Path.GetDirectoryName(baseDir)!); + var repo = Path.GetFullPath(Path.Combine(baseDir, "..", "..", "..", "..")); + var dll = Path.Combine(repo, "TestPlugin", "bin", config, "net10.0", "Test.Plugin.dll"); + + File.Exists(dll).Should().BeTrue($"the sample plugin must be built at {dll}"); + return Assembly.LoadFrom(dll); + } + + [Test] + public void Plugin_Contributes_A_Custom_Language() + { + using var container = new ContainerConfiguration().WithAssembly(LoadPlugin()).CreateContainer(); + + container.GetExports().Should().Contain(l => l.Name == "Custom", + "the plugin exports a Language named 'Custom'"); + } + + [Test] + public void Plugin_Contributes_An_Options_Page() + { + using var container = new ContainerConfiguration().WithAssembly(LoadPlugin()).CreateContainer(); + + // [ExportOptionPage] exports under the named "OptionPages" contract, not the default one. + container.GetExports("OptionPages").Should().Contain(p => p.Title == "TestPlugin", + "the plugin exports an [ExportOptionPage] titled 'TestPlugin'"); + } + + [Test] + public void Plugin_Contributes_An_About_Page_Addition() + { + using var container = new ContainerConfiguration().WithAssembly(LoadPlugin()).CreateContainer(); + + container.GetExports().Should().NotBeEmpty( + "the plugin exports an IAboutPageAddition"); + } + + [Test] + public void Commands_With_A_DI_Constructor_Mark_It_Importing() + { + // System.Composition can only instantiate a parameterized command constructor when it is + // marked [ImportingConstructor]. A command export whose ctor takes services but lacks the + // attribute throws when the menu/toolbar builder materialises it -- which previously took the + // whole menu bar down. Guard every exported command in the plugin. + var commands = LoadPlugin().GetTypes() + .Where(t => typeof(System.Windows.Input.ICommand).IsAssignableFrom(t) && !t.IsAbstract); + + foreach (var type in commands) + { + var ctors = type.GetConstructors(); + if (ctors.Any(c => c.GetParameters().Length == 0)) + continue; // a parameterless ctor is always fine + ctors.Should().Contain( + c => c.GetCustomAttribute() != null, + $"{type.Name} has only parameterized constructors, so one must be [ImportingConstructor]"); + } + } +} diff --git a/ILSpy.sln b/ILSpy.sln index 6f7d6b923..2cb248c06 100644 --- a/ILSpy.sln +++ b/ILSpy.sln @@ -41,6 +41,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ILSpy.Tests.Windows", "ILSp EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ILSpy.ReadyToRun", "ILSpy.ReadyToRun\ILSpy.ReadyToRun.csproj", "{AF43A431-AAF7-4758-A180-42C378AE0FB2}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestPlugin", "TestPlugin\TestPlugin.csproj", "{62B21FB3-2A55-4F51-8892-9D6B9864BC22}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -183,6 +185,18 @@ Global {AF43A431-AAF7-4758-A180-42C378AE0FB2}.Release|x64.Build.0 = Release|Any CPU {AF43A431-AAF7-4758-A180-42C378AE0FB2}.Release|x86.ActiveCfg = Release|Any CPU {AF43A431-AAF7-4758-A180-42C378AE0FB2}.Release|x86.Build.0 = Release|Any CPU + {62B21FB3-2A55-4F51-8892-9D6B9864BC22}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {62B21FB3-2A55-4F51-8892-9D6B9864BC22}.Debug|Any CPU.Build.0 = Debug|Any CPU + {62B21FB3-2A55-4F51-8892-9D6B9864BC22}.Debug|x64.ActiveCfg = Debug|Any CPU + {62B21FB3-2A55-4F51-8892-9D6B9864BC22}.Debug|x64.Build.0 = Debug|Any CPU + {62B21FB3-2A55-4F51-8892-9D6B9864BC22}.Debug|x86.ActiveCfg = Debug|Any CPU + {62B21FB3-2A55-4F51-8892-9D6B9864BC22}.Debug|x86.Build.0 = Debug|Any CPU + {62B21FB3-2A55-4F51-8892-9D6B9864BC22}.Release|Any CPU.ActiveCfg = Release|Any CPU + {62B21FB3-2A55-4F51-8892-9D6B9864BC22}.Release|Any CPU.Build.0 = Release|Any CPU + {62B21FB3-2A55-4F51-8892-9D6B9864BC22}.Release|x64.ActiveCfg = Release|Any CPU + {62B21FB3-2A55-4F51-8892-9D6B9864BC22}.Release|x64.Build.0 = Release|Any CPU + {62B21FB3-2A55-4F51-8892-9D6B9864BC22}.Release|x86.ActiveCfg = Release|Any CPU + {62B21FB3-2A55-4F51-8892-9D6B9864BC22}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/TestPlugin/AboutPageAddition.cs b/TestPlugin/AboutPageAddition.cs index 63135e4a2..a719f9c8a 100644 --- a/TestPlugin/AboutPageAddition.cs +++ b/TestPlugin/AboutPageAddition.cs @@ -2,11 +2,15 @@ // This code is distributed under MIT X11 license (for details please see \doc\license.txt) using System.Composition; -using System.Windows; -using System.Windows.Media; -using ICSharpCode.AvalonEdit.Highlighting; -using ICSharpCode.ILSpy; +using Avalonia.Controls; +using Avalonia.Media; + +using AvaloniaEdit.Highlighting; + +using ILSpy; +using ILSpy.Commands; +using ILSpy.TextView; namespace TestPlugin { @@ -22,11 +26,14 @@ namespace TestPlugin textOutput.WriteLine(); textOutput.BeginSpan(new HighlightingColor { Background = new SimpleHighlightingBrush(Colors.Black), - FontStyle = FontStyles.Italic, + FontStyle = FontStyle.Italic, Foreground = new SimpleHighlightingBrush(Colors.Aquamarine) }); textOutput.Write("DO NOT PRESS THIS BUTTON --> "); - textOutput.AddButton(null, "Test!", (sender, args) => MessageBox.Show("Naughty Naughty!", "Naughty!", MessageBoxButton.OK, MessageBoxImage.Exclamation)); + textOutput.AddButton(null, "Test!", (sender, args) => { + if (sender is Button button) + button.Content = "Naughty Naughty!"; + }); textOutput.Write(" <--"); textOutput.WriteLine(); textOutput.EndSpan(); diff --git a/TestPlugin/ContextMenuCommand.cs b/TestPlugin/ContextMenuCommand.cs index 9caeb0bed..fd591095f 100644 --- a/TestPlugin/ContextMenuCommand.cs +++ b/TestPlugin/ContextMenuCommand.cs @@ -2,42 +2,31 @@ // This code is distributed under MIT X11 license (for details please see \doc\license.txt) using System.Composition; -using System.Linq; -using ICSharpCode.ILSpy; -using ICSharpCode.ILSpy.TreeNodes; +using ILSpy; namespace TestPlugin { - [ExportContextMenuEntryAttribute(Header = "_Save Assembly")] + // Demonstrates contributing an entry to the tree's context menu. (The real "Save Assembly" + // command ships with the app; the concrete assembly-node types are internal to ILSpy, so this + // sample only shows the extension point and operates on the selected nodes generically.) + [ExportContextMenuEntry(Header = "_Test Plugin Command")] [Shared] - public class SaveAssembly : IContextMenuEntry + public class TestPluginContextCommand : IContextMenuEntry { public bool IsVisible(TextViewContext context) { - return context.SelectedTreeNodes != null && context.SelectedTreeNodes.All(n => n is AssemblyTreeNode); + return context.SelectedTreeNodes is { Length: > 0 }; } public bool IsEnabled(TextViewContext context) { - return context.SelectedTreeNodes != null && context.SelectedTreeNodes.Length == 1; + return context.SelectedTreeNodes is { Length: 1 }; } public void Execute(TextViewContext context) { - if (context.SelectedTreeNodes == null) - return; - AssemblyTreeNode node = (AssemblyTreeNode)context.SelectedTreeNodes[0]; - var asm = node.LoadedAssembly.GetMetadataFileOrNull(); - if (asm != null) - { - /*SaveFileDialog dlg = new SaveFileDialog(); - dlg.FileName = node.LoadedAssembly.FileName; - dlg.Filter = "Assembly|*.dll;*.exe"; - if (dlg.ShowDialog(MainWindow.Instance) == true) { - asm.MainModule.Write(dlg.FileName); - }*/ - } + // A real plugin would act on context.SelectedTreeNodes here. } } } diff --git a/TestPlugin/CustomLanguage.cs b/TestPlugin/CustomLanguage.cs index aa5263117..af816b992 100644 --- a/TestPlugin/CustomLanguage.cs +++ b/TestPlugin/CustomLanguage.cs @@ -3,11 +3,15 @@ using System.Composition; using System.Reflection.Metadata; -using System.Windows.Controls; + +using Avalonia.Controls; using ICSharpCode.Decompiler; using ICSharpCode.Decompiler.TypeSystem; -using ICSharpCode.ILSpy; + +using ILSpy; +using ILSpy.Languages; +using ILSpy.TextView; namespace TestPlugin { @@ -34,32 +38,22 @@ namespace TestPlugin // There are several methods available to override; in this sample, we deal with methods only public override void DecompileMethod(IMethod method, ITextOutput output, DecompilationOptions options) { - var module = ((MetadataModule)method.ParentModule).MetadataFile; + var module = ((MetadataModule)method.ParentModule!).MetadataFile; var methodDef = module.Metadata.GetMethodDefinition((MethodDefinitionHandle)method.MetadataToken); if (methodDef.HasBody()) { var methodBody = module.GetMethodBody(methodDef.RelativeVirtualAddress); output.WriteLine("Size of method: {0} bytes", methodBody.GetCodeSize()); - ISmartTextOutput smartOutput = output as ISmartTextOutput; - if (smartOutput != null) + if (output is ISmartTextOutput smartOutput) { // when writing to the text view (but not when writing to a file), we can even add UI elements such as buttons: - smartOutput.AddButton(null, "Click me!", (sender, e) => (sender as Button).Content = "I was clicked!"); + smartOutput.AddButton(null, "Click me!", (sender, e) => { + if (sender is Button button) + button.Content = "I was clicked!"; + }); smartOutput.WriteLine(); } - - // ICSharpCode.Decompiler.CSharp.CSharpDecompiler can be used to decompile to C#. - /* - ModuleDefinition module = LoadModule(assemblyFileName); - var typeSystem = new DecompilerTypeSystem(module); - CSharpDecompiler decompiler = new CSharpDecompiler(typeSystem, new DecompilerSettings()); - - decompiler.AstTransforms.Add(new EscapeInvalidIdentifiers()); - SyntaxTree syntaxTree = decompiler.DecompileWholeModuleAsSingleFile(); - var visitor = new CSharpOutputVisitor(output, FormattingOptionsFactory.CreateSharpDevelop()); - syntaxTree.AcceptVisitor(visitor); - */ } } } diff --git a/TestPlugin/CustomOptionPage.xaml b/TestPlugin/CustomOptionPage.xaml deleted file mode 100644 index 76e398cf3..000000000 --- a/TestPlugin/CustomOptionPage.xaml +++ /dev/null @@ -1,13 +0,0 @@ - - - Useless option 1 - - - \ No newline at end of file diff --git a/TestPlugin/CustomOptionsView.axaml b/TestPlugin/CustomOptionsView.axaml new file mode 100644 index 000000000..d5988c450 --- /dev/null +++ b/TestPlugin/CustomOptionsView.axaml @@ -0,0 +1,15 @@ + + + + + + + + + diff --git a/TestPlugin/CustomOptionsView.axaml.cs b/TestPlugin/CustomOptionsView.axaml.cs new file mode 100644 index 000000000..631013bf9 --- /dev/null +++ b/TestPlugin/CustomOptionsView.axaml.cs @@ -0,0 +1,15 @@ +// Copyright (c) AlphaSierraPapa for the SharpDevelop Team (for details please see \doc\copyright.txt) +// This code is distributed under MIT X11 license (for details please see \doc\license.txt) + +using Avalonia.Controls; + +namespace TestPlugin +{ + public partial class CustomOptionsView : UserControl + { + public CustomOptionsView() + { + InitializeComponent(); + } + } +} diff --git a/TestPlugin/CustomOptionPage.xaml.cs b/TestPlugin/CustomOptionsViewModel.cs similarity index 51% rename from TestPlugin/CustomOptionPage.xaml.cs rename to TestPlugin/CustomOptionsViewModel.cs index d423a5c32..6323dd06c 100644 --- a/TestPlugin/CustomOptionPage.xaml.cs +++ b/TestPlugin/CustomOptionsViewModel.cs @@ -4,41 +4,30 @@ using System.Composition; using System.Xml.Linq; -using ICSharpCode.ILSpy.Options; -using ICSharpCode.ILSpy.Util; +using CommunityToolkit.Mvvm.ComponentModel; + using ICSharpCode.ILSpyX.Settings; -using TomsToolbox.Wpf; -using TomsToolbox.Wpf.Composition.AttributedModel; +using ILSpy; +using ILSpy.Options; namespace TestPlugin { - [DataTemplate(typeof(CustomOptionsViewModel))] - [NonShared] - partial class CustomOptionPage - { - public CustomOptionPage() - { - InitializeComponent(); - } - } - + // The Options host renders an [ExportOptionPage] viewmodel by resolving its view through the + // app-wide ViewLocator (SomeViewModel -> SomeView, searched across every loaded assembly, + // including plugins). So this viewmodel pairs with CustomOptionsView.axaml by name. [ExportOptionPage(Order = 0)] - [NonShared] - class CustomOptionsViewModel : ObservableObjectBase, IOptionPage + [Shared] + public sealed partial class CustomOptionsViewModel : ObservableObject, IOptionPage { - private Options options; - public string Title => "TestPlugin"; - public Options Options { - get => options; - set => SetProperty(ref options, value); - } + [ObservableProperty] + private Options options = null!; - public void Load(SettingsSnapshot snapshot) + public void Load(SettingsService settings) { - this.Options = snapshot.GetSettings(); + Options = settings.GetSettings(); } public void LoadDefaults() @@ -47,23 +36,15 @@ namespace TestPlugin } } - class Options : ObservableObjectBase, ISettingsSection + public sealed partial class Options : ObservableObject, ISettingsSection { static readonly XNamespace ns = "http://www.ilspy.net/testplugin"; - bool uselessOption1; - - public bool UselessOption1 { - get => uselessOption1; - set => SetProperty(ref uselessOption1, value); - } + [ObservableProperty] + private bool uselessOption1; - double uselessOption2; - - public double UselessOption2 { - get => uselessOption2; - set => SetProperty(ref uselessOption2, value); - } + [ObservableProperty] + private double uselessOption2; public XName SectionName { get; } = ns + "CustomOptions"; @@ -76,11 +57,9 @@ namespace TestPlugin public XElement SaveToXml() { var section = new XElement(SectionName); - section.SetAttributeValue("useless1", UselessOption1); section.SetAttributeValue("useless2", UselessOption2); - return section; } } -} \ No newline at end of file +} diff --git a/TestPlugin/MainMenuCommand.cs b/TestPlugin/MainMenuCommand.cs index a808399ef..bd256b4a2 100644 --- a/TestPlugin/MainMenuCommand.cs +++ b/TestPlugin/MainMenuCommand.cs @@ -3,28 +3,33 @@ using System.Composition; -using ICSharpCode.ILSpy; -using ICSharpCode.ILSpy.AssemblyTree; +using ILSpy.AssemblyTree; +using ILSpy.Commands; namespace TestPlugin { - // Menu: menu into which the item is added - // MenuIcon: optional, icon to use for the menu item. Must be embedded as "Resource" (WPF-style resource) in the same assembly as the command type. + // ParentMenuID: menu into which the item is added + // MenuIcon: optional, icon to use for the menu item. Embedded as an AvaloniaResource in this assembly. // Header: text on the menu item // MenuCategory: optional, used for grouping related menu items together. A separator is added between different groups. // MenuOrder: controls the order in which the items appear (items are sorted by this value) [ExportMainMenuCommand(ParentMenuID = "_File", MenuIcon = "Clear.png", Header = "_Clear List", MenuCategory = "Open", MenuOrder = 1.5)] // ToolTip: the tool tip - // ToolbarIcon: The icon. Must be embedded as "Resource" (WPF-style resource) in the same assembly as the command type. + // ToolbarIcon: the icon, embedded as an AvaloniaResource in this assembly. // ToolbarCategory: optional, used for grouping related toolbar items together. A separator is added between different groups. // ToolbarOrder: controls the order in which the items appear (items are sorted by this value) [ExportToolbarCommand(ToolTip = "Clears the current assembly list", ToolbarIcon = "Clear.png", ToolbarCategory = "Open", ToolbarOrder = 1.5)] [Shared] + // System.Composition needs the importing constructor marked explicitly; without this the menu/ + // toolbar builder can't instantiate the command and the whole menu build fails. + [method: ImportingConstructor] public class UnloadAllAssembliesCommand(AssemblyTreeModel assemblyTreeModel) : SimpleCommand { - public override void Execute(object parameter) + public override void Execute(object? parameter) { - foreach (var loadedAssembly in assemblyTreeModel.AssemblyList.GetAssemblies()) + if (assemblyTreeModel.AssemblyList is not { } assemblyList) + return; + foreach (var loadedAssembly in assemblyList.GetAssemblies()) { loadedAssembly.AssemblyList.Unload(loadedAssembly); } diff --git a/TestPlugin/Properties/AssemblyInfo.cs b/TestPlugin/Properties/AssemblyInfo.cs deleted file mode 100644 index aed9b9fdf..000000000 --- a/TestPlugin/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,18 +0,0 @@ -#region Using directives - -using System.Reflection; -using System.Runtime.InteropServices; - -#endregion - -// General Information about an assembly is controlled through the following -// set of attributes. Change these attribute values to modify the information -// associated with an assembly. -[assembly: AssemblyDescription("")] -[assembly: AssemblyCopyright("Copyright 2011")] -[assembly: AssemblyTrademark("")] -[assembly: AssemblyCulture("")] - -// This sets the default COM visibility of types in the assembly to invisible. -// If you need to expose a type to COM, use [ComVisible(true)] on that type. -[assembly: ComVisible(false)] diff --git a/TestPlugin/TestPlugin.csproj b/TestPlugin/TestPlugin.csproj index 9a660a997..17740eb42 100644 --- a/TestPlugin/TestPlugin.csproj +++ b/TestPlugin/TestPlugin.csproj @@ -1,21 +1,9 @@ - net10.0-windows + net10.0 Test.Plugin - true - true - - - - full - true - True - - - - pdbonly - true + enable @@ -24,8 +12,14 @@ - + + + + + + + - \ No newline at end of file +