mirror of https://github.com/icsharpcode/ILSpy.git
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
258 lines
11 KiB
258 lines
11 KiB
// 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.IO; |
|
using System.Linq; |
|
using System.Text; |
|
using System.Threading; |
|
using System.Threading.Tasks; |
|
|
|
using ICSharpCode.Decompiler; |
|
using ICSharpCode.Decompiler.CSharp; |
|
using ICSharpCode.Decompiler.CSharp.ProjectDecompiler; |
|
using ICSharpCode.Decompiler.DebugInfo; |
|
using ICSharpCode.Decompiler.Metadata; |
|
using ICSharpCode.ILSpyX; |
|
|
|
using ICSharpCode.ILSpy.Languages; |
|
|
|
namespace ICSharpCode.ILSpy.Commands |
|
{ |
|
/// <summary> |
|
/// The UI-agnostic engine behind the Export Project/Solution dialog. Unifies single-assembly |
|
/// project export and multi-assembly solution export (the latter via <see cref="SolutionWriter"/>), |
|
/// applies the dialog's overrides onto a settings clone, skips (and reports) the entries with no code |
|
/// behind them, and optionally emits a portable PDB per assembly. Returns a |
|
/// <see cref="SolutionExportResult"/> the caller surfaces in the text view. Kept separate from the |
|
/// dialog so it is headless-testable. |
|
/// </summary> |
|
internal static class ProjectExporter |
|
{ |
|
public static async Task<SolutionExportResult> ExportAsync( |
|
IReadOnlyList<LoadedAssembly> assemblies, bool solutionMode, ProjectExportOptions options, |
|
DecompilerSettings settingsClone, Language language, IProgress<DecompilationProgress>? progress, |
|
CancellationToken ct) |
|
{ |
|
ArgumentNullException.ThrowIfNull(assemblies); |
|
ArgumentNullException.ThrowIfNull(options); |
|
ArgumentNullException.ThrowIfNull(settingsClone); |
|
ArgumentNullException.ThrowIfNull(language); |
|
|
|
ApplyOverrides(settingsClone, options); |
|
|
|
// Resolve each entry by awaiting its load. IsLoadedAsValidAssembly cannot stand in for this: |
|
// it is a non-blocking status poll that reads false for a load still running or not yet |
|
// started (LoadedAssembly builds its entries lazily), so filtering on it here would drop |
|
// assemblies that decompile perfectly well once awaited. The callers' selection predicate has |
|
// to poll -- it answers IsEnabled on the UI thread -- but this runs off it and can wait. |
|
var loaded = await Task.WhenAll(assemblies.Select(async a => ( |
|
Assembly: a, |
|
File: await a.GetMetadataFileOrNullAsync().ConfigureAwait(false) |
|
))).ConfigureAwait(false); |
|
|
|
// Anything without a PE image behind it has nothing to decompile: a file that failed to load, |
|
// or one that carries metadata only (a standalone PDB, say). Leave those out and name them in |
|
// the report, so a mixed selection still exports what it can and the user is told what is |
|
// missing rather than having to notice it. |
|
var exportable = loaded.Where(e => e.File is { IsMetadataOnly: false }).Select(e => e.Assembly).ToList(); |
|
var skipReport = SkipReport(loaded.Where(e => e.File is not { IsMetadataOnly: false })); |
|
if (exportable.Count == 0) |
|
{ |
|
return new SolutionExportResult(false, |
|
"Nothing to export." + Environment.NewLine + skipReport); |
|
} |
|
|
|
if (solutionMode) |
|
{ |
|
var solutionFilePath = Path.Combine(options.OutputDirectory, |
|
options.SolutionFileName ?? SolutionFileNameFor(options.OutputDirectory)); |
|
var solution = await SolutionWriter.CreateSolutionAsync( |
|
solutionFilePath, language, exportable, ct, settingsClone, options.StrongNameKeyFile, progress) |
|
.ConfigureAwait(false); |
|
|
|
var report = new StringBuilder(solution.StatusText); |
|
if (options.GeneratePdb && solution.Success) |
|
{ |
|
await Task.Run(() => GeneratePdbs(exportable, |
|
a => Path.Combine(options.OutputDirectory, a.ShortName), settingsClone, options, report, ct), ct) |
|
.ConfigureAwait(false); |
|
} |
|
AppendSkipReport(report, skipReport); |
|
return new SolutionExportResult(solution.Success, report.ToString()) { Errors = solution.Errors }; |
|
} |
|
|
|
var projectResult = await Task |
|
.Run(() => ExportProject(exportable[0], options, settingsClone, language, progress, ct), ct) |
|
.ConfigureAwait(false); |
|
if (skipReport.Length == 0) |
|
return projectResult; |
|
|
|
var projectReport = new StringBuilder(projectResult.StatusText); |
|
AppendSkipReport(projectReport, skipReport); |
|
return projectResult with { StatusText = projectReport.ToString() }; |
|
} |
|
|
|
// One line per assembly left out of the export, saying which of the two reasons applies: a file |
|
// that never loaded is a different problem from one that loaded and holds no code, and sending |
|
// the user after a corrupt file that is not there wastes their time. |
|
static string SkipReport(IEnumerable<(LoadedAssembly Assembly, MetadataFile? File)> skipped) |
|
{ |
|
var report = new StringBuilder(); |
|
foreach (var (assembly, file) in skipped) |
|
{ |
|
var reason = file == null |
|
? "the assembly failed to load" |
|
: "it holds metadata only, with no code to decompile"; |
|
report.AppendLine($"Skipped '{assembly.ShortName}': {reason}."); |
|
} |
|
return report.ToString(); |
|
} |
|
|
|
/// <summary> |
|
/// Writes what the decompiler could not handle: one headline per failure, each followed by |
|
/// the full exception in a collapsed fold. The export replaces the affected code with the |
|
/// error text and finishes rather than failing, so this is where the user learns anything |
|
/// went wrong - and the trace is right there to paste into the bug report. |
|
/// </summary> |
|
internal static void WriteDecompilationErrors(ITextOutput output, IReadOnlyList<DecompilerException> errors) |
|
{ |
|
if (errors.Count == 0) |
|
return; |
|
output.WriteLine(); |
|
foreach (string line in CSharpDecompiler.GetErrorSummaryLines(errors.Count)) |
|
{ |
|
output.WriteLine(line); |
|
} |
|
foreach (var error in errors) |
|
{ |
|
output.WriteLine(); |
|
output.WriteLine(CSharpDecompiler.GetErrorHeadline(error)); |
|
output.WriteExceptionDetails(error); |
|
} |
|
// Keeps the caller's result button off the last frame's line, the same way the |
|
// error-free report ends with a blank line. |
|
output.WriteLine(); |
|
} |
|
|
|
static void AppendSkipReport(StringBuilder report, string skipReport) |
|
{ |
|
if (skipReport.Length == 0) |
|
return; |
|
report.AppendLine(); |
|
report.Append(skipReport); |
|
} |
|
|
|
static SolutionExportResult ExportProject(LoadedAssembly assembly, ProjectExportOptions options, |
|
DecompilerSettings settingsClone, Language language, IProgress<DecompilationProgress>? progress, CancellationToken ct) |
|
{ |
|
var report = new StringBuilder(); |
|
bool success; |
|
// Declared out here because the failures the export recovered from are worth reporting |
|
// even when it goes on to fail: their error text is already in the written sources. |
|
var decompileOptions = new DecompilationOptions(settingsClone) { |
|
FullDecompilation = true, |
|
EscapeInvalidIdentifiers = true, |
|
CancellationToken = ct, |
|
SaveAsProjectDirectory = options.OutputDirectory, |
|
StrongNameKeyFile = options.StrongNameKeyFile, |
|
ProgressIndicator = progress, |
|
}; |
|
try |
|
{ |
|
language.DecompileAssembly(assembly, new PlainTextOutput(new StringWriter()), decompileOptions); |
|
report.AppendLine("Project written to " + options.OutputDirectory); |
|
success = true; |
|
} |
|
catch (OperationCanceledException) |
|
{ |
|
throw; |
|
} |
|
catch (Exception e) |
|
{ |
|
report.AppendLine($"Failed to decompile the assembly '{assembly.FileName}':{Environment.NewLine}{e.Message}"); |
|
success = false; |
|
} |
|
|
|
if (options.GeneratePdb && success) |
|
GeneratePdbs(new[] { assembly }, _ => options.OutputDirectory, settingsClone, options, report, ct); |
|
|
|
return new SolutionExportResult(success, report.ToString()) { Errors = [.. decompileOptions.DecompilationErrors] }; |
|
} |
|
|
|
static void GeneratePdbs(IReadOnlyList<LoadedAssembly> assemblies, |
|
Func<LoadedAssembly, string> projectDirectory, DecompilerSettings settingsClone, |
|
ProjectExportOptions options, StringBuilder report, CancellationToken ct) |
|
{ |
|
report.AppendLine(); |
|
foreach (var assembly in assemblies) |
|
{ |
|
ct.ThrowIfCancellationRequested(); |
|
if (assembly.GetMetadataFileOrNull() is not PEFile file |
|
|| !PortablePdbWriter.HasCodeViewDebugDirectoryEntry(file)) |
|
{ |
|
report.AppendLine($"Skipped PDB for '{assembly.ShortName}': no CodeView debug directory entry."); |
|
continue; |
|
} |
|
|
|
var pdbFileName = Path.Combine(projectDirectory(assembly), |
|
WholeProjectDecompiler.CleanUpFileName(assembly.ShortName, ".pdb")); |
|
try |
|
{ |
|
using var stream = new FileStream(pdbFileName, FileMode.Create, FileAccess.Write); |
|
var resolver = assembly.GetAssemblyResolver(); |
|
var decompiler = new CSharpDecompiler(file, resolver, settingsClone) { |
|
CancellationToken = ct, |
|
}; |
|
new PortablePdbWriter { EmbedSourceFiles = options.EmbedSourceFilesInPdb } |
|
.WritePdb(file, decompiler, settingsClone, stream); |
|
report.AppendLine("Generated PDB: " + pdbFileName); |
|
} |
|
catch (OperationCanceledException) |
|
{ |
|
throw; |
|
} |
|
catch (Exception e) |
|
{ |
|
report.AppendLine($"Failed to generate a PDB for '{assembly.ShortName}': {e.Message}"); |
|
} |
|
} |
|
} |
|
|
|
static void ApplyOverrides(DecompilerSettings settings, ProjectExportOptions options) |
|
{ |
|
settings.UseSdkStyleProjectFormat = options.UseSdkStyleProjectFormat; |
|
settings.UseNestedDirectoriesForNamespaces = options.UseNestedDirectoriesForNamespaces; |
|
settings.RemoveDeadCode = options.RemoveDeadCode; |
|
settings.RemoveDeadStores = options.RemoveDeadStores; |
|
settings.UseDebugSymbols = options.UseDebugSymbols; |
|
} |
|
|
|
/// <summary> |
|
/// The <c>.sln</c> name used when <see cref="ProjectExportOptions.SolutionFileName"/> is unset: |
|
/// after the chosen output folder, falling back to "Solution.sln". Internal so the export dialog |
|
/// can show the same name as its watermark instead of predicting it. |
|
/// </summary> |
|
internal static string SolutionFileNameFor(string outputDirectory) |
|
{ |
|
var name = Path.GetFileName(outputDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); |
|
return (string.IsNullOrEmpty(name) ? "Solution" : name) + ".sln"; |
|
} |
|
} |
|
}
|
|
|