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);