Browse Source

File → Save Code writes a .csproj via WholeProjectDecompiler

Brings the assembly-to-project export across. Right-click an assembly in
the tree (or use the existing File → Save Code / Ctrl+S that calls into
`node.Save()`) and choose between two filters in the save picker:
pull/3755/head
Siegfried Pammer 2 months ago
parent
commit
a999dfdb6d
  1. 113
      ILSpy.Tests/Languages/ProjectExportTests.cs
  2. 56
      ILSpy/Commands/SaveCodeContextMenuEntry.cs
  3. 35
      ILSpy/Languages/CSharpLanguage.cs
  4. 9
      ILSpy/Languages/Language.cs
  5. 72
      ILSpy/TreeNodes/AssemblyTreeNode.cs

113
ILSpy.Tests/Languages/ProjectExportTests.cs

@ -0,0 +1,113 @@ @@ -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;
/// <summary>
/// End-to-end smoke test of project export. Drives
/// <see cref="CSharpLanguage.DecompileAssembly"/> with
/// <see cref="DecompilationOptions.SaveAsProjectDirectory"/> set, asserts the resulting
/// directory has a <c>.csproj</c> file and at least one <c>.cs</c> file.
/// </summary>
[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<MainWindow>();
window.Show();
var vm = (MainWindowViewModel)window.DataContext!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1);
var node = vm.AssemblyTreeModel.FindNode<global::ILSpy.TreeNodes.AssemblyTreeNode>("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<LanguageService>()
.Languages.OfType<CSharpLanguage>().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 */ }
}
}
}

56
ILSpy/Commands/SaveCodeContextMenuEntry.cs

@ -0,0 +1,56 @@ @@ -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
{
/// <summary>
/// Right-click → "Save Code". Delegates to the selected node's
/// <see cref="ILSpyTreeNode.Save"/> override so the context-menu entry and File →
/// Save Code take exactly the same path: <c>AssemblyTreeNode</c> drives project /
/// single-file selection, <c>ResourceTreeNode</c> writes raw bytes, every other
/// node falls through to the generic single-file decompile in the existing
/// <c>FileCommands.SaveCommand</c>.
/// </summary>
[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;
}
}
}

35
ILSpy/Languages/CSharpLanguage.cs

@ -29,6 +29,7 @@ using AvaloniaEdit.Highlighting; @@ -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 @@ -56,6 +57,8 @@ namespace ILSpy.Languages
public override string FileExtension => ".cs";
public override string ProjectFileExtension => ".csproj";
static IReadOnlyList<LanguageVersionDto>? cachedVersions;
public override IReadOnlyList<LanguageVersionDto> LanguageVersions => cachedVersions ??= new List<LanguageVersionDto> {
@ -240,7 +243,7 @@ namespace ILSpy.Languages @@ -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 @@ -321,6 +324,36 @@ namespace ILSpy.Languages
return null;
}
/// <summary>
/// Runs <see cref="WholeProjectDecompiler"/> against the loaded assembly and emits a
/// buildable .csproj + per-type .cs files under
/// <see cref="DecompilationOptions.SaveAsProjectDirectory"/>. The <paramref name="output"/>
/// 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 <see cref="MetadataFile.Name"/>
/// via <see cref="WholeProjectDecompiler.CleanUpFileName"/>.
/// </summary>
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<EntityHandle> CollectFieldsAndCtors(ITypeDefinition type, bool isStatic)
{
var members = new List<EntityHandle>();

9
ILSpy/Languages/Language.cs

@ -46,6 +46,15 @@ namespace ILSpy.Languages @@ -46,6 +46,15 @@ namespace ILSpy.Languages
public abstract string FileExtension { get; }
/// <summary>
/// Extension for the language's project file (e.g. <c>.csproj</c>). <c>null</c> when
/// the language doesn't support multi-file project export — File → Save Code only
/// offers single-file save in that case. <see cref="ICSharpCode.Decompiler.CSharp.ProjectDecompiler.WholeProjectDecompiler"/>
/// runs against the live <see cref="MetadataFile"/> when this is non-null and
/// <see cref="DecompilationOptions.SaveAsProjectDirectory"/> is set.
/// </summary>
public virtual string? ProjectFileExtension => null;
/// <summary>
/// Versions selectable for this language (e.g. C# 1.0 → C# 14). Default is empty;
/// <see cref="HasLanguageVersions"/> reports whether any are available so toolbar UI

72
ILSpy/TreeNodes/AssemblyTreeNode.cs

@ -113,6 +113,78 @@ namespace ILSpy.TreeNodes @@ -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<LanguageService>(); }
catch { return null; }
}
public override object Text => assembly.Text;
// ToString is the stable identity used by SessionSettings.ActiveTreeViewPath — must not

Loading…
Cancel
Save