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
+