mirror of https://github.com/icsharpcode/ILSpy.git
Browse Source
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 Codepull/3755/head
6 changed files with 638 additions and 14 deletions
@ -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(); |
||||||
|
} |
||||||
|
} |
||||||
@ -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 */ } |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -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 → 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.
|
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -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…
Reference in new issue