Browse Source

Export several selected assemblies as a Visual Studio solution

The single-assembly project save was already wired, but selecting
multiple assemblies and choosing Save Code only ever saved one of
them: SolutionWriter had never been ported, so there was no path that
emitted a .sln with one decompiled project per assembly.

Port SolutionWriter UI-agnostically and route both Save Code surfaces
(the context-menu entry and the Ctrl+S command) through a shared
SolutionExport helper that recognises a multi-assembly selection,
prompts for the .sln path, runs the export behind the tab's cancellable
progress UI, and reports the result into the active decompiler tab
(matching how the other long-running commands surface their output).
Unlike the prior app, the project-export path writes the .csproj to
disk itself and only returns a breadcrumb through the text output, so
each project's on-disk file is located after decompiling rather than
captured from the output stream.

Assisted-by: Claude:claude-opus-4-8:Claude Code
pull/3755/head
Siegfried Pammer 1 month ago
parent
commit
0588da3533
  1. 100
      ILSpy.Tests/ContextMenus/SaveCodeSolutionContextMenuTests.cs
  2. 126
      ILSpy.Tests/Languages/SolutionExportTests.cs
  3. 11
      ILSpy/Commands/FileCommands.cs
  4. 57
      ILSpy/Commands/SaveCodeContextMenuEntry.cs
  5. 115
      ILSpy/Commands/SolutionExport.cs
  6. 243
      ILSpy/SolutionWriter.cs

100
ILSpy.Tests/ContextMenus/SaveCodeSolutionContextMenuTests.cs

@ -0,0 +1,100 @@ @@ -0,0 +1,100 @@
// 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.AppEnv;
using ILSpy.TextView;
using ILSpy.TreeNodes;
using NUnit.Framework;
namespace ICSharpCode.ILSpy.Tests;
/// <summary>
/// Visibility/enablement rules for "Save Code": a single node always qualifies (its own
/// <c>Save</c> override decides the format), several valid assemblies qualify (the solution
/// export path), and any other multi-selection does not.
/// </summary>
[TestFixture]
public class SaveCodeSolutionContextMenuTests
{
static IContextMenuEntry Entry()
=> AppComposition.Current.GetExport<ContextMenuEntryRegistry>().GetEntry(nameof(Resources._SaveCode));
static TextViewContext Context(params SharpTreeNode[] nodes)
=> new() { SelectedTreeNodes = nodes };
[AvaloniaTest]
public async Task Single_Node_Is_Visible()
{
var (_, vm) = await TestHarness.BootAsync(3);
var assembly = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>("System.Linq");
Assert.That(assembly, Is.Not.Null);
var entry = Entry();
entry.IsVisible(Context(assembly!)).Should().BeTrue("a single node delegates to its own Save override");
entry.IsEnabled(Context(assembly!)).Should().BeTrue();
}
[AvaloniaTest]
public async Task Multiple_Valid_Assemblies_Are_Visible()
{
var (_, vm) = await TestHarness.BootAsync(3);
var assemblies = vm.AssemblyTreeModel.AssemblyList!.GetAssemblies()
.Where(a => a.IsLoadedAsValidAssembly)
.GroupBy(a => a.ShortName).Select(g => g.First())
.Take(2)
.Select(a => (SharpTreeNode)new AssemblyTreeNode(a))
.ToArray();
assemblies.Should().HaveCount(2);
Entry().IsVisible(Context(assemblies)).Should().BeTrue(
"several valid assemblies offer the solution export path");
}
[AvaloniaTest]
public async Task Mixed_Multi_Selection_Is_Hidden()
{
var (_, vm) = await TestHarness.BootAsync(3);
var assembly = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>("System.Linq");
var type = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"System.Linq", "System.Linq", "System.Linq.Enumerable");
Assert.That(assembly, Is.Not.Null);
Assert.That(type, Is.Not.Null);
Entry().IsVisible(Context(assembly!, type!)).Should().BeFalse(
"a mixed assembly + type selection has no well-defined Save Code action");
}
[AvaloniaTest]
public async Task Empty_Selection_Is_Hidden()
{
await TestHarness.BootAsync(1);
Entry().IsVisible(Context()).Should().BeFalse();
}
}

126
ILSpy.Tests/Languages/SolutionExportTests.cs

@ -0,0 +1,126 @@ @@ -0,0 +1,126 @@
// 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 ILSpy.AppEnv;
using ILSpy.Languages;
using NUnit.Framework;
namespace ICSharpCode.ILSpy.Tests.Languages;
/// <summary>
/// End-to-end smoke test of the multi-assembly solution (.sln) export path that backs the
/// "Save Code" entry when several assemblies are selected. Drives
/// <see cref="global::ILSpy.SolutionWriter.CreateSolutionAsync"/> directly (no file picker)
/// and asserts a solution file plus one project per assembly is produced.
/// </summary>
[TestFixture]
public class SolutionExportTests
{
[AvaloniaTest]
public async Task CreateSolution_Writes_Sln_And_Per_Assembly_Projects()
{
var (_, vm) = await TestHarness.BootAsync(3);
// CoreLib is too big for a smoke test; the Uri and System.Linq reference assemblies
// the headless fixture seeds are both manageable full-project decompiles.
var assemblies = vm.AssemblyTreeModel.AssemblyList!.GetAssemblies()
.Where(a => a.IsLoadedAsValidAssembly && a.ShortName != TreeNavigation.CoreLibName)
.GroupBy(a => a.ShortName).Select(g => g.First())
.Take(2).ToList();
assemblies.Should().HaveCountGreaterThanOrEqualTo(2, "a solution needs at least two distinct assemblies");
foreach (var a in assemblies)
await a.GetLoadResultAsync();
var language = AppComposition.Current.GetExport<LanguageService>()
.Languages.OfType<CSharpLanguage>().First();
var tempDir = Path.Combine(Path.GetTempPath(), "ILSpySln_" + System.Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempDir);
var slnPath = Path.Combine(tempDir, "Solution.sln");
try
{
var result = await global::ILSpy.SolutionWriter.CreateSolutionAsync(slnPath, language, assemblies);
result.Success.Should().BeTrue(
"the solution export should succeed for valid assemblies. Status:\n" + result.StatusText);
File.Exists(slnPath).Should().BeTrue("the .sln file must be written to the chosen path");
foreach (var a in assemblies)
{
var projectDir = Path.Combine(tempDir, a.ShortName);
Directory.EnumerateFiles(projectDir, "*.csproj").Should().HaveCount(1,
$"each assembly gets its own subdirectory with one project file ({a.ShortName})");
Directory.EnumerateFiles(projectDir, "*.cs", SearchOption.AllDirectories).Should().NotBeEmpty(
$"the decompiled project for {a.ShortName} must contain at least one source file");
}
result.StatusText.Should().Contain("Created the Visual Studio Solution file",
"a successful run reports the solution-written breadcrumb");
}
finally
{
try
{ Directory.Delete(tempDir, recursive: true); }
catch { /* best-effort cleanup */ }
}
}
[AvaloniaTest]
public async Task CreateSolution_Aborts_On_Duplicate_Assembly_Names()
{
var (_, vm) = await TestHarness.BootAsync(3);
var assembly = vm.AssemblyTreeModel.AssemblyList!.GetAssemblies()
.First(a => a.IsLoadedAsValidAssembly && a.ShortName != TreeNavigation.CoreLibName);
await assembly.GetLoadResultAsync();
var language = AppComposition.Current.GetExport<LanguageService>()
.Languages.OfType<CSharpLanguage>().First();
var tempDir = Path.Combine(Path.GetTempPath(), "ILSpySlnDup_" + System.Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempDir);
var slnPath = Path.Combine(tempDir, "Solution.sln");
try
{
// The same assembly twice collides on ShortName: the writer must refuse rather than
// clobber one project directory with another.
var result = await global::ILSpy.SolutionWriter.CreateSolutionAsync(
slnPath, language, new[] { assembly, assembly });
result.Success.Should().BeFalse("duplicate assembly names cannot produce a valid solution");
result.StatusText.Should().Contain("Duplicate assembly names");
File.Exists(slnPath).Should().BeFalse("no solution file is written when the export aborts");
}
finally
{
try
{ Directory.Delete(tempDir, recursive: true); }
catch { /* best-effort cleanup */ }
}
}
}

11
ILSpy/Commands/FileCommands.cs

@ -202,13 +202,22 @@ namespace ILSpy.Commands @@ -202,13 +202,22 @@ namespace ILSpy.Commands
[method: ImportingConstructor]
internal sealed class SaveCommand(
AssemblyTreeModel assemblyTreeModel,
LanguageService languageService) : SimpleCommand
LanguageService languageService,
DockWorkspace dockWorkspace) : SimpleCommand
{
public override bool CanExecute(object? parameter)
=> assemblyTreeModel.SelectedItem is ILSpyTreeNode;
public override async void Execute(object? parameter)
{
// Several selected assemblies export a Visual Studio solution (one project each),
// matching the Save Code context-menu entry.
if (SolutionExport.TryGetAssemblies(assemblyTreeModel.SelectedItems, out var assemblies))
{
await SolutionExport.PromptAndExportAsync(assemblies, languageService.CurrentLanguage, dockWorkspace);
return;
}
if (assemblyTreeModel.SelectedItem is not ILSpyTreeNode node)
return;
// Resource nodes (and any future override) handle their own save format and dialog;

57
ILSpy/Commands/SaveCodeContextMenuEntry.cs

@ -17,40 +17,71 @@ @@ -17,40 +17,71 @@
// DEALINGS IN THE SOFTWARE.
using System.Composition;
using System.Linq;
using ICSharpCode.ILSpy.Properties;
using ICSharpCode.ILSpyX.TreeView;
using ILSpy.Docking;
using ILSpy.Languages;
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>.
/// Right-click → "Save Code". Mirrors the WPF entry's two modes:
/// <list type="bullet">
/// <item>A single node delegates to its <see cref="ILSpyTreeNode.Save"/> override —
/// <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.</item>
/// <item>Several selected assemblies export a Visual Studio solution (one decompiled project
/// each) via <see cref="SolutionWriter"/>.</item>
/// </list>
/// </summary>
[ExportContextMenuEntry(Header = nameof(Resources._SaveCode), Category = nameof(Resources.Save), Icon = "Images/Save", Order = 300)]
[Shared]
public sealed class SaveCodeContextMenuEntry : IContextMenuEntry
{
public bool IsVisible(TextViewContext context) => GetTarget(context) != null;
readonly LanguageService languageService;
readonly DockWorkspace dockWorkspace;
public bool IsEnabled(TextViewContext context) => GetTarget(context) != null;
[ImportingConstructor]
public SaveCodeContextMenuEntry(LanguageService languageService, DockWorkspace dockWorkspace)
{
this.languageService = languageService;
this.dockWorkspace = dockWorkspace;
}
public bool IsVisible(TextViewContext context) => CanExecute(context.SelectedTreeNodes);
public bool IsEnabled(TextViewContext context) => CanExecute(context.SelectedTreeNodes);
public void Execute(TextViewContext context)
{
GetTarget(context)?.Save();
var nodes = context.SelectedTreeNodes;
if (nodes is not { Length: > 0 })
return;
if (SolutionExport.TryGetAssemblies(nodes, out var assemblies))
{
_ = SolutionExport.PromptAndExportAsync(assemblies, languageService.CurrentLanguage, dockWorkspace);
return;
}
if (nodes.Length == 1)
(nodes[0] as ILSpyTreeNode)?.Save();
}
static ILSpyTreeNode? GetTarget(TextViewContext context)
// Visible when exactly one ILSpyTreeNode is selected (the single-node save path), or when
// several valid assemblies are selected (the solution-export path). Mixed or other
// multi-selections have no well-defined "save code" meaning.
static bool CanExecute(SharpTreeNode[]? selectedNodes)
{
if (context.SelectedTreeNodes is not { Length: 1 } nodes)
return null;
return nodes[0] as ILSpyTreeNode;
if (selectedNodes is not { Length: > 0 })
return false;
if (selectedNodes.Length == 1)
return selectedNodes[0] is ILSpyTreeNode;
return SolutionExport.TryGetAssemblies(selectedNodes, out _);
}
}
}

115
ILSpy/Commands/SolutionExport.cs

@ -0,0 +1,115 @@ @@ -0,0 +1,115 @@
// 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.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using ICSharpCode.ILSpy.Properties;
using ICSharpCode.ILSpyX;
using ICSharpCode.ILSpyX.TreeView;
using ILSpy.Docking;
using ILSpy.Languages;
using ILSpy.TextView;
using ILSpy.TreeNodes;
namespace ILSpy.Commands
{
/// <summary>
/// The shared "export several assemblies as a Visual Studio solution" flow behind both the
/// File &rarr; Save Code command and the Save Code context-menu entry: it recognises a
/// solution-eligible selection, prompts for the <c>.sln</c> path, runs
/// <see cref="SolutionWriter"/> behind the tab's cancellable progress UI, and surfaces the
/// status report in the active decompiler tab.
/// </summary>
internal static class SolutionExport
{
/// <summary>
/// True when <paramref name="nodes"/> is several assembly nodes that all loaded as valid
/// assemblies — the only selection shape that maps onto a multi-project solution.
/// </summary>
public static bool TryGetAssemblies(IReadOnlyList<SharpTreeNode>? nodes, out List<LoadedAssembly> assemblies)
{
assemblies = new List<LoadedAssembly>();
if (nodes is not { Count: > 1 })
return false;
if (!nodes.All(n => n is AssemblyTreeNode { LoadedAssembly.IsLoadedAsValidAssembly: true }))
return false;
assemblies = nodes.OfType<AssemblyTreeNode>().Select(n => n.LoadedAssembly).ToList();
return true;
}
/// <summary>
/// Prompts for a target <c>.sln</c> file and exports <paramref name="assemblies"/> into it,
/// one decompiled project each. The status report lands in the active decompiler tab, where
/// the user is already looking. Does nothing if the user cancels the picker.
/// </summary>
public static async Task PromptAndExportAsync(IReadOnlyList<LoadedAssembly> assemblies,
Language language, DockWorkspace dockWorkspace)
{
var path = await FilePickers.SaveAsync(
Resources.VisualStudioSolutionFileSlnAllFiles, "Solution.sln", Resources._SaveCode)
.ConfigureAwait(true);
if (string.IsNullOrEmpty(path))
return;
try
{
var output = await dockWorkspace.RunWithCancellation(async token => {
var result = await SolutionWriter.CreateSolutionAsync(path, language, assemblies, token)
.ConfigureAwait(false);
var o = new AvaloniaEditTextOutput { Title = Resources._SaveCode };
o.Write(result.StatusText);
o.WriteLine();
if (result.Success && Path.GetDirectoryName(path) is { Length: > 0 } directory)
{
o.AddButton(null, Resources.OpenExplorer, (_, _) => OpenFolder(directory));
o.WriteLine();
}
return o;
}, "Exporting solution");
dockWorkspace.ShowText(output);
}
catch (OperationCanceledException)
{
// User cancelled — leave the previous tab content visible.
}
}
static void OpenFolder(string path)
{
try
{
if (OperatingSystem.IsWindows())
Process.Start(new ProcessStartInfo("explorer.exe", $"\"{path}\"") { UseShellExecute = false });
else if (OperatingSystem.IsMacOS())
Process.Start(new ProcessStartInfo("open", $"\"{path}\"") { UseShellExecute = false });
else
Process.Start(new ProcessStartInfo("xdg-open", path) { UseShellExecute = false });
}
catch
{
// Best-effort.
}
}
}
}

243
ILSpy/SolutionWriter.cs

@ -0,0 +1,243 @@ @@ -0,0 +1,243 @@
// Copyright (c) 2011 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.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using ICSharpCode.Decompiler;
using ICSharpCode.Decompiler.Solution;
using ICSharpCode.ILSpyX;
using ILSpy.Languages;
namespace ILSpy
{
/// <summary>
/// The outcome of a <see cref="SolutionWriter.CreateSolutionAsync"/> run: whether a complete
/// solution was produced and the human-readable status report (the same breadcrumb the WPF
/// version printed into the decompiler text view).
/// </summary>
public sealed record SolutionExportResult(bool Success, string StatusText);
/// <summary>
/// Creates a Visual Studio solution containing one decompiled project per assembly. The
/// solution directory must be empty or non-existent. UI-agnostic: callers supply an explicit
/// target path (the "Save Code" entry picks it via a file dialog) and surface
/// <see cref="SolutionExportResult.StatusText"/> however they like.
/// </summary>
internal sealed class SolutionWriter
{
/// <summary>
/// Decompiles every assembly in <paramref name="assemblies"/> into its own project under the
/// directory of <paramref name="solutionFilePath"/> and writes the solution file itself.
/// </summary>
/// <exception cref="ArgumentException">Thrown when <paramref name="solutionFilePath"/> is null
/// or whitespace.</exception>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="language"/> or
/// <paramref name="assemblies"/> is null.</exception>
public static Task<SolutionExportResult> CreateSolutionAsync(string solutionFilePath,
Language language, IReadOnlyList<LoadedAssembly> assemblies,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(solutionFilePath))
throw new ArgumentException("The solution file path cannot be null or empty.", nameof(solutionFilePath));
ArgumentNullException.ThrowIfNull(language);
ArgumentNullException.ThrowIfNull(assemblies);
return new SolutionWriter(solutionFilePath).CreateSolutionAsync(assemblies, language, cancellationToken);
}
readonly string solutionFilePath;
readonly string solutionDirectory;
readonly ConcurrentBag<ProjectItem> projects;
readonly ConcurrentBag<string> statusOutput;
SolutionWriter(string solutionFilePath)
{
this.solutionFilePath = solutionFilePath;
solutionDirectory = Path.GetDirectoryName(solutionFilePath)!;
statusOutput = new ConcurrentBag<string>();
projects = new ConcurrentBag<ProjectItem>();
}
async Task<SolutionExportResult> CreateSolutionAsync(IReadOnlyList<LoadedAssembly> allAssemblies,
Language language, CancellationToken ct)
{
var report = new StringBuilder();
// Two assemblies that share a short name would decompile into the same project directory;
// refuse rather than have one clobber the other.
var assembliesByShortName = allAssemblies.GroupBy(a => a.ShortName).ToDictionary(g => g.Key, g => g.ToList());
bool first = true;
bool abort = false;
foreach (var (_, assemblies) in assembliesByShortName)
{
if (assemblies.Count == 1)
continue;
if (first)
{
report.AppendLine("Duplicate assembly names selected, cannot generate a solution:");
abort = true;
first = false;
}
report.AppendLine("- " + assemblies[0].Text + " conflicts with "
+ string.Join(", ", assemblies.Skip(1).Select(a => a.Text)));
}
if (abort)
return new SolutionExportResult(false, report.ToString());
try
{
// An explicit enumerable partitioner avoids Parallel.ForEach's list special-casing,
// whose static partitioning is inefficient when assemblies decompile at different speeds.
await Task.Run(() => System.Threading.Tasks.Parallel.ForEach(Partitioner.Create(allAssemblies),
new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount, CancellationToken = ct },
item => WriteProject(item, language, solutionDirectory, ct)))
.ConfigureAwait(false);
if (projects.Count == 0)
{
report.AppendLine();
report.AppendLine("Solution could not be created, because none of the selected assemblies could be decompiled into a project.");
return new SolutionExportResult(false, report.ToString());
}
await Task.Run(() => SolutionCreator.WriteSolutionFile(solutionFilePath, projects.ToList()), ct)
.ConfigureAwait(false);
}
catch (AggregateException ae)
{
if (ae.Flatten().InnerExceptions.All(e => e is OperationCanceledException))
{
report.AppendLine();
report.AppendLine("Generation was cancelled.");
return new SolutionExportResult(false, report.ToString());
}
report.AppendLine();
report.AppendLine("Failed to generate the Visual Studio Solution. Errors:");
ae.Handle(e => {
report.AppendLine(e.Message);
return true;
});
return new SolutionExportResult(false, report.ToString());
}
catch (OperationCanceledException)
{
report.AppendLine();
report.AppendLine("Generation was cancelled.");
return new SolutionExportResult(false, report.ToString());
}
foreach (var item in statusOutput)
report.AppendLine(item);
// statusOutput only collects per-assembly failures; an empty bag means every project built.
if (statusOutput.Count != 0)
return new SolutionExportResult(false, report.ToString());
report.AppendLine("Successfully decompiled the following assemblies into Visual Studio projects:");
foreach (var n in allAssemblies)
report.AppendLine(n.Text.ToString());
report.AppendLine();
if (allAssemblies.Count == projects.Count)
report.AppendLine("Created the Visual Studio Solution file.");
return new SolutionExportResult(true, report.ToString());
}
void WriteProject(LoadedAssembly loadedAssembly, Language language, string targetDirectory, CancellationToken ct)
{
targetDirectory = Path.Combine(targetDirectory, loadedAssembly.ShortName);
if (language.ProjectFileExtension == null)
{
statusOutput.Add("-------------");
statusOutput.Add($"Language '{language.Name}' does not support exporting assemblies as projects!");
return;
}
if (File.Exists(targetDirectory))
{
statusOutput.Add("-------------");
statusOutput.Add($"Failed to create a directory '{targetDirectory}':{Environment.NewLine}A file with the same name already exists!");
return;
}
if (!Directory.Exists(targetDirectory))
{
try
{
Directory.CreateDirectory(targetDirectory);
}
catch (Exception e)
{
statusOutput.Add("-------------");
statusOutput.Add($"Failed to create a directory '{targetDirectory}':{Environment.NewLine}{e}");
return;
}
}
try
{
var options = new DecompilationOptions {
FullDecompilation = true,
EscapeInvalidIdentifiers = true,
CancellationToken = ct,
SaveAsProjectDirectory = targetDirectory,
};
// The project-export path writes the .csproj into SaveAsProjectDirectory itself; the
// ITextOutput only receives a "Project written to ..." breadcrumb, which we discard here.
var projectInfo = language.DecompileAssembly(loadedAssembly, new PlainTextOutput(new StringWriter()), options);
if (projectInfo != null)
{
// SolutionCreator.FixAllProjectReferences parses each project file off disk, so the
// ProjectItem must point at the .csproj the decompiler actually produced (its name is
// derived from the module name, not necessarily the assembly short name).
var projectFileName = Directory.EnumerateFiles(targetDirectory, "*" + language.ProjectFileExtension).FirstOrDefault()
?? Path.Combine(targetDirectory, loadedAssembly.ShortName + language.ProjectFileExtension);
projects.Add(new ProjectItem(projectFileName, projectInfo.PlatformName, projectInfo.Guid, projectInfo.TypeGuid));
}
}
catch (NotSupportedException e)
{
statusOutput.Add("-------------");
statusOutput.Add($"Failed to decompile the assembly '{loadedAssembly.FileName}':{Environment.NewLine}{e.Message}");
}
catch (PathTooLongException e)
{
statusOutput.Add("-------------");
statusOutput.Add(string.Format(ICSharpCode.ILSpy.Properties.Resources.ProjectExportPathTooLong, loadedAssembly.FileName)
+ Environment.NewLine + Environment.NewLine + e.ToString());
}
catch (Exception e) when (e is not OperationCanceledException)
{
statusOutput.Add("-------------");
statusOutput.Add($"Failed to decompile the assembly '{loadedAssembly.FileName}':{Environment.NewLine}{e}");
}
}
}
}
Loading…
Cancel
Save