From 0588da353389e9bb4f735d8b8387b37fb573a3bf Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Sat, 6 Jun 2026 17:07:29 +0200 Subject: [PATCH] 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 --- .../SaveCodeSolutionContextMenuTests.cs | 100 +++++++ ILSpy.Tests/Languages/SolutionExportTests.cs | 126 +++++++++ ILSpy/Commands/FileCommands.cs | 11 +- ILSpy/Commands/SaveCodeContextMenuEntry.cs | 57 +++- ILSpy/Commands/SolutionExport.cs | 115 +++++++++ ILSpy/SolutionWriter.cs | 243 ++++++++++++++++++ 6 files changed, 638 insertions(+), 14 deletions(-) create mode 100644 ILSpy.Tests/ContextMenus/SaveCodeSolutionContextMenuTests.cs create mode 100644 ILSpy.Tests/Languages/SolutionExportTests.cs create mode 100644 ILSpy/Commands/SolutionExport.cs create mode 100644 ILSpy/SolutionWriter.cs diff --git a/ILSpy.Tests/ContextMenus/SaveCodeSolutionContextMenuTests.cs b/ILSpy.Tests/ContextMenus/SaveCodeSolutionContextMenuTests.cs new file mode 100644 index 000000000..f0e8cde30 --- /dev/null +++ b/ILSpy.Tests/ContextMenus/SaveCodeSolutionContextMenuTests.cs @@ -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; + +/// +/// Visibility/enablement rules for "Save Code": a single node always qualifies (its own +/// Save override decides the format), several valid assemblies qualify (the solution +/// export path), and any other multi-selection does not. +/// +[TestFixture] +public class SaveCodeSolutionContextMenuTests +{ + static IContextMenuEntry Entry() + => AppComposition.Current.GetExport().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("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("System.Linq"); + var type = vm.AssemblyTreeModel.FindNode( + "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(); + } +} diff --git a/ILSpy.Tests/Languages/SolutionExportTests.cs b/ILSpy.Tests/Languages/SolutionExportTests.cs new file mode 100644 index 000000000..102b58c48 --- /dev/null +++ b/ILSpy.Tests/Languages/SolutionExportTests.cs @@ -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; + +/// +/// 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 +/// directly (no file picker) +/// and asserts a solution file plus one project per assembly is produced. +/// +[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() + .Languages.OfType().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() + .Languages.OfType().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 */ } + } + } +} diff --git a/ILSpy/Commands/FileCommands.cs b/ILSpy/Commands/FileCommands.cs index 857d805b3..8ffc220be 100644 --- a/ILSpy/Commands/FileCommands.cs +++ b/ILSpy/Commands/FileCommands.cs @@ -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; diff --git a/ILSpy/Commands/SaveCodeContextMenuEntry.cs b/ILSpy/Commands/SaveCodeContextMenuEntry.cs index 7021abf44..c202ee480 100644 --- a/ILSpy/Commands/SaveCodeContextMenuEntry.cs +++ b/ILSpy/Commands/SaveCodeContextMenuEntry.cs @@ -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 { /// - /// Right-click → "Save Code". Delegates to the selected node's - /// override so the context-menu entry and File → - /// Save Code take exactly the same path: AssemblyTreeNode drives project / - /// single-file selection, ResourceTreeNode writes raw bytes, every other - /// node falls through to the generic single-file decompile in the existing - /// FileCommands.SaveCommand. + /// Right-click → "Save Code". Mirrors the WPF entry's two modes: + /// + /// A single node delegates to its override — + /// AssemblyTreeNode drives project / single-file selection, ResourceTreeNode + /// writes raw bytes, every other node falls through to the generic single-file decompile. + /// Several selected assemblies export a Visual Studio solution (one decompiled project + /// each) via . + /// /// [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 _); } } } diff --git a/ILSpy/Commands/SolutionExport.cs b/ILSpy/Commands/SolutionExport.cs new file mode 100644 index 000000000..049554068 --- /dev/null +++ b/ILSpy/Commands/SolutionExport.cs @@ -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 +{ + /// + /// 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 .sln path, runs + /// behind the tab's cancellable progress UI, and surfaces the + /// status report in the active decompiler tab. + /// + internal static class SolutionExport + { + /// + /// True when is several assembly nodes that all loaded as valid + /// assemblies — the only selection shape that maps onto a multi-project solution. + /// + public static bool TryGetAssemblies(IReadOnlyList? nodes, out List assemblies) + { + assemblies = new List(); + if (nodes is not { Count: > 1 }) + return false; + if (!nodes.All(n => n is AssemblyTreeNode { LoadedAssembly.IsLoadedAsValidAssembly: true })) + return false; + assemblies = nodes.OfType().Select(n => n.LoadedAssembly).ToList(); + return true; + } + + /// + /// Prompts for a target .sln file and exports 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. + /// + public static async Task PromptAndExportAsync(IReadOnlyList 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. + } + } + } +} diff --git a/ILSpy/SolutionWriter.cs b/ILSpy/SolutionWriter.cs new file mode 100644 index 000000000..12fc30bc2 --- /dev/null +++ b/ILSpy/SolutionWriter.cs @@ -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 +{ + /// + /// The outcome of a 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). + /// + public sealed record SolutionExportResult(bool Success, string StatusText); + + /// + /// 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 + /// however they like. + /// + internal sealed class SolutionWriter + { + /// + /// Decompiles every assembly in into its own project under the + /// directory of and writes the solution file itself. + /// + /// Thrown when is null + /// or whitespace. + /// Thrown when or + /// is null. + public static Task CreateSolutionAsync(string solutionFilePath, + Language language, IReadOnlyList 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 projects; + readonly ConcurrentBag statusOutput; + + SolutionWriter(string solutionFilePath) + { + this.solutionFilePath = solutionFilePath; + solutionDirectory = Path.GetDirectoryName(solutionFilePath)!; + statusOutput = new ConcurrentBag(); + projects = new ConcurrentBag(); + } + + async Task CreateSolutionAsync(IReadOnlyList 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}"); + } + } + } +}