diff --git a/ILSpy.Tests/Languages/ProjectExportTests.cs b/ILSpy.Tests/Languages/ProjectExportTests.cs index 139dd5de0..f0165b184 100644 --- a/ILSpy.Tests/Languages/ProjectExportTests.cs +++ b/ILSpy.Tests/Languages/ProjectExportTests.cs @@ -25,12 +25,16 @@ using Avalonia.Headless.NUnit; using AwesomeAssertions; using ICSharpCode.Decompiler; +using ICSharpCode.Decompiler.CSharp; +using ICSharpCode.Decompiler.Solution; using ICSharpCode.ILSpy.AppEnv; +using ICSharpCode.ILSpy.Commands; using ICSharpCode.ILSpy.AssemblyTree; using ICSharpCode.ILSpy.Languages; using ICSharpCode.ILSpy.ViewModels; using ICSharpCode.ILSpy.Views; +using ICSharpCode.ILSpyX; using NUnit.Framework; @@ -63,6 +67,69 @@ public class ProjectExportTests cs.ProjectFileExtension.Should().Be(".csproj"); } + /// + /// An export finishes even when parts of the assembly cannot be decompiled, so the failures + /// have to reach the result report: the ITextOutput handed to the language is thrown away by + /// the export path, and the report is all the user ever sees. + /// + [AvaloniaTest] + public async Task Export_Report_Names_The_Failures_And_Where_To_Report_Them() + { + var window = AppComposition.Current.GetExport(); + window.Show(); + var vm = (MainWindowViewModel)window.DataContext!; + await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1); + + var loaded = await vm.OpenFixtureAsync(); + var tempDir = Path.Combine(Path.GetTempPath(), "ILSpyExport_" + System.Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + var options = new ProjectExportOptions(tempDir, UseSdkStyleProjectFormat: true, + UseNestedDirectoriesForNamespaces: false, RemoveDeadCode: false, RemoveDeadStores: false, + UseDebugSymbols: false, StrongNameKeyFile: null, GeneratePdb: false, + EmbedSourceFilesInPdb: false); + + var result = await ProjectExporter.ExportAsync([loaded], solutionMode: false, options, + new DecompilerSettings(), new FailingLanguage(), progress: null, default); + + var sw = new StringWriter(); + ProjectExporter.WriteDecompilationErrors(new PlainTextOutput(sw), result.Errors); + string written = sw.ToString(); + + result.Success.Should().BeTrue("a recovered failure still produces a project"); + written.Should().Contain("Error decompiling C.M", + "the failing member must be named"); + written.Should().Contain("Simulated failure", + "the exception the export recovered from must show up, stack trace and all"); + written.Should().Contain(CSharpDecompiler.DecompilationErrorReportUrl, + "the report is where the user is asked to file the bug"); + } + finally + { + try + { Directory.Delete(tempDir, recursive: true); } + catch { /* best-effort */ } + } + } + + /// Stands in for a decompiler that recovered from a failure while exporting. + sealed class FailingLanguage : Language + { + public override string Name => "Failing"; + + public override string FileExtension => ".cs"; + + public override string ProjectFileExtension => ".csproj"; + + public override ProjectId? DecompileAssembly(LoadedAssembly assembly, ITextOutput output, DecompilationOptions options) + { + options.DecompilationErrors.Add(new DecompilerException(assembly.GetMetadataFileOrNull()!, + "Error decompiling C.M", new System.InvalidOperationException("Simulated failure"))); + return null; + } + } + [AvaloniaTest] public async Task DecompileAssembly_With_SaveAsProjectDirectory_Writes_Csproj_And_Cs_Files() { diff --git a/ILSpy.Tests/SmartTextOutputExtensionsTests.cs b/ILSpy.Tests/SmartTextOutputExtensionsTests.cs new file mode 100644 index 000000000..cc135b0a7 --- /dev/null +++ b/ILSpy.Tests/SmartTextOutputExtensionsTests.cs @@ -0,0 +1,65 @@ +// Copyright (c) 2026 Siegfried Pammer +// +// 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 AwesomeAssertions; + +using ICSharpCode.Decompiler; + +using ICSharpCode.ILSpy.TextView; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests; + +[TestFixture] +public class SmartTextOutputExtensionsTests +{ + /// + /// A collapsed fold hides everything up to its end offset, so a fold reaching past the last + /// frame takes the line after it - the one the reader needs to see - down with it. + /// + [Test] + public void Exception_Fold_Stops_At_The_Last_Frame() + { + var output = new AvaloniaEditTextOutput(); + output.Write("Something failed:"); + output.WriteLine(); + output.WriteExceptionDetails(ExceptionWithTrace()); + output.Write("this line must stay visible"); + output.WriteLine(); + + string text = output.GetText(); + var fold = output.Foldings.Should().ContainSingle().Subject; + text[fold.StartOffset..fold.EndOffset].Should().NotEndWith("\n", + "the fold must end with the last frame, not with the newline behind it"); + text.Should().Contain("this line must stay visible"); + } + + static Exception ExceptionWithTrace() => new TrailingNewlineException(); + + /// + /// Stands in for the exceptions that actually reach this helper: + /// renders a trailing newline, which would push the fold one line past the last frame. + /// + sealed class TrailingNewlineException : Exception + { + public override string ToString() => "boom" + Environment.NewLine + " at Frame1" + Environment.NewLine; + } +} diff --git a/ILSpy/Commands/ProjectExport.cs b/ILSpy/Commands/ProjectExport.cs index c8ba8ea0e..d879a6908 100644 --- a/ILSpy/Commands/ProjectExport.cs +++ b/ILSpy/Commands/ProjectExport.cs @@ -211,6 +211,7 @@ namespace ICSharpCode.ILSpy.Commands var o = new AvaloniaEditTextOutput { Title = title }; o.Write(result.StatusText); o.WriteLine(); + ProjectExporter.WriteDecompilationErrors(o, result.Errors); if (result.Success && Directory.Exists(options.OutputDirectory)) { o.AddOpenFolderButton(options.OutputDirectory); diff --git a/ILSpy/Commands/ProjectExporter.cs b/ILSpy/Commands/ProjectExporter.cs index ddff5bdf7..207ba8bcc 100644 --- a/ILSpy/Commands/ProjectExporter.cs +++ b/ILSpy/Commands/ProjectExporter.cs @@ -95,7 +95,7 @@ namespace ICSharpCode.ILSpy.Commands .ConfigureAwait(false); } AppendSkipReport(report, skipReport); - return new SolutionExportResult(solution.Success, report.ToString()); + return new SolutionExportResult(solution.Success, report.ToString()) { Errors = solution.Errors }; } var projectResult = await Task @@ -125,6 +125,32 @@ namespace ICSharpCode.ILSpy.Commands return report.ToString(); } + /// + /// 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. + /// + internal static void WriteDecompilationErrors(ITextOutput output, IReadOnlyList 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) @@ -138,16 +164,18 @@ namespace ICSharpCode.ILSpy.Commands { 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 { - var decompileOptions = new DecompilationOptions(settingsClone) { - FullDecompilation = true, - EscapeInvalidIdentifiers = true, - CancellationToken = ct, - SaveAsProjectDirectory = options.OutputDirectory, - StrongNameKeyFile = options.StrongNameKeyFile, - ProgressIndicator = progress, - }; language.DecompileAssembly(assembly, new PlainTextOutput(new StringWriter()), decompileOptions); report.AppendLine("Project written to " + options.OutputDirectory); success = true; @@ -165,7 +193,7 @@ namespace ICSharpCode.ILSpy.Commands if (options.GeneratePdb && success) GeneratePdbs(new[] { assembly }, _ => options.OutputDirectory, settingsClone, options, report, ct); - return new SolutionExportResult(success, report.ToString()); + return new SolutionExportResult(success, report.ToString()) { Errors = [.. decompileOptions.DecompilationErrors] }; } static void GeneratePdbs(IReadOnlyList assemblies, diff --git a/ILSpy/DecompilationOptions.cs b/ILSpy/DecompilationOptions.cs index 2e076ec24..27bb43af2 100644 --- a/ILSpy/DecompilationOptions.cs +++ b/ILSpy/DecompilationOptions.cs @@ -17,6 +17,7 @@ // DEALINGS IN THE SOFTWARE. using System; +using System.Collections.Generic; using System.Threading; using ICSharpCode.Decompiler; @@ -82,6 +83,14 @@ namespace ICSharpCode.ILSpy /// public IProgress? ProgressIndicator { get; set; } + /// + /// Filled by the project-export path with the members, files and resources the decompiler + /// could not handle. The export writes the error text into the affected sources and runs to + /// completion instead of failing, so the caller's result report is where the user learns + /// that anything went wrong at all - the ITextOutput of an export is discarded. + /// + public IList DecompilationErrors { get; } = new List(); + // Deliberately no parameterless constructor: every decompilation must make an explicit // choice of settings. Callers inside the app want the user's current settings (see // SettingsService.CreateEffectiveDecompilerSettings), and a silent new DecompilerSettings() diff --git a/ILSpy/Languages/CSharpLanguage.cs b/ILSpy/Languages/CSharpLanguage.cs index 67edae098..e5e70f0e6 100644 --- a/ILSpy/Languages/CSharpLanguage.cs +++ b/ILSpy/Languages/CSharpLanguage.cs @@ -555,8 +555,22 @@ namespace ICSharpCode.ILSpy.Languages targetDirectory, WholeProjectDecompiler.CleanUpFileName(module.Name, ProjectFileExtension)); ProjectId? id; - using (var writer = new System.IO.StreamWriter(projectFileName)) - id = decompiler.DecompileProject(module, targetDirectory, writer, options.CancellationToken); + try + { + using (var writer = new System.IO.StreamWriter(projectFileName)) + id = decompiler.DecompileProject(module, targetDirectory, writer, options.CancellationToken); + } + finally + { + // The export does not abort on what it cannot decompile; hand the failures to the + // caller, whose result report - not this ITextOutput - is what the user sees when it + // finishes. Sources carrying error text are already on disk even if the export went + // on to fail, so this belongs in the finally. + foreach (var error in decompiler.Errors) + { + options.DecompilationErrors.Add(error); + } + } output.WriteLine("// Project written to " + targetDirectory); return id; } diff --git a/ILSpy/SmartTextOutputExtensions.cs b/ILSpy/SmartTextOutputExtensions.cs index ca2b7e712..4355b1b80 100644 --- a/ILSpy/SmartTextOutputExtensions.cs +++ b/ILSpy/SmartTextOutputExtensions.cs @@ -52,7 +52,9 @@ namespace ICSharpCode.ILSpy // The fold span is counted in WriteLine() calls, not in the '\n' characters embedded in a // single Write(). Emit the trace one line per WriteLine() so the fold genuinely spans // multiple lines; otherwise it collapses to a single line and is dropped as noise. - var lines = ex.ToString().Split('\n'); + // Trailing newlines would put the fold's end past the last frame, so the collapsed + // section would swallow the line after it. + var lines = ex.ToString().TrimEnd().Split('\n'); for (int i = 0; i < lines.Length; i++) { if (i > 0) diff --git a/ILSpy/SolutionWriter.cs b/ILSpy/SolutionWriter.cs index 3ebe1f2e4..bb8293240 100644 --- a/ILSpy/SolutionWriter.cs +++ b/ILSpy/SolutionWriter.cs @@ -38,7 +38,15 @@ namespace ICSharpCode.ILSpy /// 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); + public sealed record SolutionExportResult(bool Success, string StatusText) + { + /// + /// What the decompiler could not handle while exporting. These are recovered failures - the + /// affected code was replaced by the error text and the export ran to completion - so they + /// do not make false; the caller renders them for the user to report. + /// + public IReadOnlyList Errors { get; init; } = []; + } /// /// Creates a Visual Studio solution containing one decompiled project per assembly. The @@ -82,6 +90,7 @@ namespace ICSharpCode.ILSpy readonly IProgress? progress; readonly ConcurrentBag projects; readonly ConcurrentBag statusOutput; + readonly ConcurrentBag decompilationErrors = new(); // How far each project has got, keyed by assembly short name -- unique, because duplicate names // abort the export before any project runs. The workers fill these in as they decompile and the @@ -206,7 +215,7 @@ namespace ICSharpCode.ILSpy // statusOutput only collects per-assembly failures; an empty bag means every project built. if (statusOutput.Count != 0) - return new SolutionExportResult(false, report.ToString()); + return new SolutionExportResult(false, report.ToString()) { Errors = [.. decompilationErrors] }; report.AppendLine("Successfully decompiled the following assemblies into Visual Studio projects:"); foreach (var n in allAssemblies) @@ -215,7 +224,7 @@ namespace ICSharpCode.ILSpy if (allAssemblies.Count == projects.Count) report.AppendLine("Created the Visual Studio Solution file."); - return new SolutionExportResult(true, report.ToString()); + return new SolutionExportResult(true, report.ToString()) { Errors = [.. decompilationErrors] }; } // Reports the whole solution's progress: the file counts of every project added up. The projects @@ -323,6 +332,12 @@ namespace ICSharpCode.ILSpy // 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); + // Recovered failures travel on the result, not in statusOutput: a non-empty + // statusOutput means the solution is incomplete, and these projects did get written. + foreach (var error in options.DecompilationErrors) + { + decompilationErrors.Add(error); + } if (projectInfo != null) { // SolutionCreator.FixAllProjectReferences parses each project file off disk, so the diff --git a/ILSpy/TextView/DecompilerTabPageModel.cs b/ILSpy/TextView/DecompilerTabPageModel.cs index 74be6b190..51127f1d0 100644 --- a/ILSpy/TextView/DecompilerTabPageModel.cs +++ b/ILSpy/TextView/DecompilerTabPageModel.cs @@ -595,8 +595,10 @@ namespace ICSharpCode.ILSpy.TextView catch (Exception ex) { output.WriteLine(); - output.WriteLine("/* Decompilation failed:"); - output.WriteLine(ex.ToString()); + output.WriteLine("/* Decompilation failed: " + ex.Message); + // The trace goes in a collapsed fold: what the reader needs is the message, + // and the frames only when they go looking for them. + output.WriteExceptionDetails(ex); output.WriteLine("*/"); } return (output, cts.Token);