diff --git a/ILSpy.Tests/Languages/ProjectExportTests.cs b/ILSpy.Tests/Languages/ProjectExportTests.cs new file mode 100644 index 000000000..ea0e61425 --- /dev/null +++ b/ILSpy.Tests/Languages/ProjectExportTests.cs @@ -0,0 +1,113 @@ +// 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.IO; +using System.Linq; +using System.Threading.Tasks; + +using Avalonia.Headless.NUnit; + +using AwesomeAssertions; + +using ICSharpCode.Decompiler; + +using ILSpy.AppEnv; +using ILSpy.AssemblyTree; +using ILSpy.Languages; +using ILSpy.ViewModels; +using ILSpy.Views; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests.Languages; + +/// +/// End-to-end smoke test of project export. Drives +/// with +/// set, asserts the resulting +/// directory has a .csproj file and at least one .cs file. +/// +[TestFixture] +public class ProjectExportTests +{ + [Test] + public void Language_Base_Defaults_ProjectFileExtension_To_Null() + { + // The base Language.ProjectFileExtension is the gate for File → Save Code's + // "save as project" filter. Default null = single-file-only for languages that + // haven't opted in. + var disassembler = new ILLanguage(); + ((object?)disassembler.ProjectFileExtension).Should().BeNull( + "only project-aware languages override; the IL disassembler is single-file only"); + } + + [Test] + public void CSharpLanguage_Reports_CSProj_As_ProjectFileExtension() + { + var cs = new CSharpLanguage(); + cs.ProjectFileExtension.Should().Be(".csproj"); + } + + [AvaloniaTest] + public async Task DecompileAssembly_With_SaveAsProjectDirectory_Writes_Csproj_And_Cs_Files() + { + // Drives the project-export path end-to-end against an already-loaded assembly in + // the headless composition. CoreLib is too big for a smoke test (multi-second + // decompile); System.Linq is a manageable subset and is loaded by the existing + // test fixture. + var window = AppComposition.Current.GetExport(); + window.Show(); + var vm = (MainWindowViewModel)window.DataContext!; + await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1); + + var node = vm.AssemblyTreeModel.FindNode("System.Linq"); + Assert.That(node, Is.Not.Null, "System.Linq must be discoverable in the headless test fixture"); + var loaded = node!.LoadedAssembly; + await loaded.GetLoadResultAsync(); + + var tempDir = Path.Combine(Path.GetTempPath(), "ILSpyExport_" + System.Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + var options = new global::ILSpy.DecompilationOptions { + FullDecompilation = true, + SaveAsProjectDirectory = tempDir, + }; + var language = AppComposition.Current.GetExport() + .Languages.OfType().First(); + var sw = new StringWriter(); + var output = new ICSharpCode.Decompiler.PlainTextOutput(sw); + // (PlainTextOutput lives in the root ICSharpCode.Decompiler namespace — no Output suffix.) + + language.DecompileAssembly(loaded, output, options); + + Directory.EnumerateFiles(tempDir, "*.csproj").Should().HaveCount(1, + "WholeProjectDecompiler must write exactly one .csproj"); + Directory.EnumerateFiles(tempDir, "*.cs", SearchOption.AllDirectories).Should().NotBeEmpty( + "at least one .cs file (AssemblyInfo or a type) must be produced"); + sw.ToString().Should().Contain("Project written to", + "the ITextOutput buffer must include a project-written breadcrumb so the editor tab shows feedback"); + } + finally + { + try + { Directory.Delete(tempDir, recursive: true); } + catch { /* best-effort */ } + } + } +} diff --git a/ILSpy/Commands/SaveCodeContextMenuEntry.cs b/ILSpy/Commands/SaveCodeContextMenuEntry.cs new file mode 100644 index 000000000..d4bbb7727 --- /dev/null +++ b/ILSpy/Commands/SaveCodeContextMenuEntry.cs @@ -0,0 +1,56 @@ +// 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 ICSharpCode.ILSpy.Properties; +using ICSharpCode.ILSpyX.TreeView; + +using ILSpy.TreeNodes; + +namespace ILSpy.Commands +{ + /// + /// Right-click → "Save Code". Delegates to the selected node's + /// override so the context-menu entry and File → + /// Save Code take exactly the same path: AssemblyTreeNode drives project / + /// single-file selection, ResourceTreeNode writes raw bytes, every other + /// node falls through to the generic single-file decompile in the existing + /// FileCommands.SaveCommand. + /// + [ExportContextMenuEntry(Header = nameof(Resources._SaveCode), Category = nameof(Resources.Save))] + [Shared] + public sealed class SaveCodeContextMenuEntry : IContextMenuEntry + { + public bool IsVisible(TextViewContext context) => GetTarget(context) != null; + + public bool IsEnabled(TextViewContext context) => GetTarget(context) != null; + + public void Execute(TextViewContext context) + { + GetTarget(context)?.Save(); + } + + static ILSpyTreeNode? GetTarget(TextViewContext context) + { + if (context.SelectedTreeNodes is not { Length: 1 } nodes) + return null; + return nodes[0] as ILSpyTreeNode; + } + } +} diff --git a/ILSpy/Languages/CSharpLanguage.cs b/ILSpy/Languages/CSharpLanguage.cs index ecd65e046..62f41ea22 100644 --- a/ILSpy/Languages/CSharpLanguage.cs +++ b/ILSpy/Languages/CSharpLanguage.cs @@ -29,6 +29,7 @@ using AvaloniaEdit.Highlighting; using ICSharpCode.Decompiler; using ICSharpCode.Decompiler.CSharp; using ICSharpCode.Decompiler.CSharp.OutputVisitor; +using ICSharpCode.Decompiler.CSharp.ProjectDecompiler; using ICSharpCode.Decompiler.CSharp.Syntax; using ICSharpCode.Decompiler.CSharp.Transforms; using ICSharpCode.Decompiler.IL; @@ -56,6 +57,8 @@ namespace ILSpy.Languages public override string FileExtension => ".cs"; + public override string ProjectFileExtension => ".csproj"; + static IReadOnlyList? cachedVersions; public override IReadOnlyList LanguageVersions => cachedVersions ??= new List { @@ -240,7 +243,7 @@ namespace ILSpy.Languages if (module == null) return null; if (options.FullDecompilation && options.SaveAsProjectDirectory != null) - throw new NotSupportedException($"Language '{Name}' does not support exporting assemblies as projects in the Avalonia host yet."); + return DecompileAsProject(assembly, module, output, options); AddReferenceAssemblyWarningMessage(module, output); AddReferenceWarningMessage(module, output); @@ -321,6 +324,36 @@ namespace ILSpy.Languages return null; } + /// + /// Runs against the loaded assembly and emits a + /// buildable .csproj + per-type .cs files under + /// . The + /// ITextOutput is the buffer the caller will display when the export finishes — we + /// write a short summary into it so the user gets feedback in the editor tab. The + /// project file's name is derived from the assembly's + /// via . + /// + ProjectId? DecompileAsProject(LoadedAssembly assembly, MetadataFile module, ITextOutput output, DecompilationOptions options) + { + var targetDirectory = options.SaveAsProjectDirectory!; + var resolver = assembly.GetAssemblyResolver(loadOnDemand: options.DecompilerSettings.AutoLoadAssemblyReferences); + var debugInfo = assembly.GetDebugInfoOrNull(); + var decompiler = new WholeProjectDecompiler( + options.DecompilerSettings, + resolver, + projectWriter: null, + assemblyReferenceClassifier: null, + debugInfoProvider: debugInfo); + var projectFileName = System.IO.Path.Combine( + targetDirectory, + WholeProjectDecompiler.CleanUpFileName(module.Name, ProjectFileExtension)); + ProjectId? id; + using (var writer = new System.IO.StreamWriter(projectFileName)) + id = decompiler.DecompileProject(module, targetDirectory, writer, options.CancellationToken); + output.WriteLine("// Project written to " + targetDirectory); + return id; + } + static List CollectFieldsAndCtors(ITypeDefinition type, bool isStatic) { var members = new List(); diff --git a/ILSpy/Languages/Language.cs b/ILSpy/Languages/Language.cs index 2d6438280..83749c241 100644 --- a/ILSpy/Languages/Language.cs +++ b/ILSpy/Languages/Language.cs @@ -46,6 +46,15 @@ namespace ILSpy.Languages public abstract string FileExtension { get; } + /// + /// Extension for the language's project file (e.g. .csproj). null when + /// the language doesn't support multi-file project export — File → Save Code only + /// offers single-file save in that case. + /// runs against the live when this is non-null and + /// is set. + /// + public virtual string? ProjectFileExtension => null; + /// /// Versions selectable for this language (e.g. C# 1.0 → C# 14). Default is empty; /// reports whether any are available so toolbar UI diff --git a/ILSpy/TreeNodes/AssemblyTreeNode.cs b/ILSpy/TreeNodes/AssemblyTreeNode.cs index 36506fe7f..7af70e72c 100644 --- a/ILSpy/TreeNodes/AssemblyTreeNode.cs +++ b/ILSpy/TreeNodes/AssemblyTreeNode.cs @@ -113,6 +113,78 @@ namespace ILSpy.TreeNodes public override void DeleteCore() => assembly.AssemblyList.Unload(assembly); + public override bool Save() + { + // Intercept the File → Save Code flow for valid managed assemblies whose active + // language supports project export (e.g. C#). Offers both the .csproj and the + // single-file filter; the picked extension drives the export mode. Languages + // without a ProjectFileExtension (the IL disassembler) fall through to the base + // single-file path. + if (!assembly.IsLoadedAsValidAssembly) + return false; + var languageService = TryGetLanguageService(); + if (languageService == null) + return false; + var language = languageService.CurrentLanguage; + if (string.IsNullOrEmpty(language.ProjectFileExtension)) + return false; + _ = SaveAsProjectOrSingleFileAsync(language); + return true; + } + + async Task SaveAsProjectOrSingleFileAsync(Language language) + { + var shortName = CleanSuggestedFileName(assembly.ShortName); + var filter = $"{language.Name} project (*{language.ProjectFileExtension})|*{language.ProjectFileExtension}" + + $"|{language.Name} (*{language.FileExtension})|*{language.FileExtension}" + + "|All files (*.*)|*.*"; + var defaultName = shortName + language.ProjectFileExtension; + var path = await Commands.FilePickers.SaveAsync(filter, defaultName, "Save Code").ConfigureAwait(false); + if (string.IsNullOrEmpty(path)) + return; + + var ext = Path.GetExtension(path); + var isProject = string.Equals(ext, language.ProjectFileExtension, StringComparison.OrdinalIgnoreCase); + + await Task.Run(() => { + var options = new DecompilationOptions { + FullDecompilation = true, + EscapeInvalidIdentifiers = true, + }; + if (isProject) + options.SaveAsProjectDirectory = Path.GetDirectoryName(path); + using var writer = new StreamWriter(path); + var output = new PlainTextOutput(writer); + try + { + language.DecompileAssembly(assembly, output, options); + } + catch (Exception ex) + { + output.WriteLine(); + output.WriteLine("/* Save failed:"); + output.WriteLine(ex.ToString()); + output.WriteLine("*/"); + } + }).ConfigureAwait(false); + } + + static string CleanSuggestedFileName(string name) + { + var invalid = Path.GetInvalidFileNameChars(); + var clean = name; + foreach (var c in invalid) + clean = clean.Replace(c, '_'); + return clean; + } + + static LanguageService? TryGetLanguageService() + { + try + { return AppEnv.AppComposition.Current.GetExport(); } + catch { return null; } + } + public override object Text => assembly.Text; // ToString is the stable identity used by SessionSettings.ActiveTreeViewPath — must not