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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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