diff --git a/ILSpy.Tests/TextView/DisplaySettingsBridgeTests.cs b/ILSpy.Tests/TextView/DisplaySettingsBridgeTests.cs new file mode 100644 index 000000000..76bdf39b4 --- /dev/null +++ b/ILSpy.Tests/TextView/DisplaySettingsBridgeTests.cs @@ -0,0 +1,84 @@ +// 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 AwesomeAssertions; + +using ICSharpCode.Decompiler; + +using ILSpy.Options; +using ILSpy.TextView; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests.TextView; + +/// +/// The Display options that affect generated source (brace folding, debug info, indentation, and the +/// fold-expansion flags) must be bridged into the decompiler settings used for a decompile. WPF wired +/// these in DecompilationOptions; the Avalonia port previously only bridged the two expand flags, so +/// FoldBraces / ShowDebugInfo / indentation were silently dead. +/// +[TestFixture] +public class DisplaySettingsBridgeTests +{ + [Test] + public void Brace_Folding_And_Debug_Info_Are_Bridged() + { + var display = new DisplaySettings { FoldBraces = true, ShowDebugInfo = true }; + var settings = new DecompilerSettings { FoldBraces = false, ShowDebugInfo = false }; + + DecompilerTabPageModel.ApplyDisplaySettings(settings, display); + + settings.FoldBraces.Should().BeTrue(); + settings.ShowDebugInfo.Should().BeTrue(); + } + + [Test] + public void Indentation_Uses_Spaces_When_Tabs_Are_Off() + { + var display = new DisplaySettings { IndentationUseTabs = false, IndentationSize = 2 }; + var settings = new DecompilerSettings(); + + DecompilerTabPageModel.ApplyDisplaySettings(settings, display); + + settings.CSharpFormattingOptions.IndentationString.Should().Be(" ", "two spaces"); + } + + [Test] + public void Indentation_Uses_Tabs_When_Enabled() + { + var display = new DisplaySettings { IndentationUseTabs = true, IndentationSize = 4, IndentationTabSize = 4 }; + var settings = new DecompilerSettings(); + + DecompilerTabPageModel.ApplyDisplaySettings(settings, display); + + settings.CSharpFormattingOptions.IndentationString.Should().Be("\t", "one tab"); + } + + [Test] + public void Expand_Flags_Still_Bridged() + { + var display = new DisplaySettings { ExpandUsingDeclarations = true, ExpandMemberDefinitions = true }; + var settings = new DecompilerSettings { ExpandUsingDeclarations = false, ExpandMemberDefinitions = false }; + + DecompilerTabPageModel.ApplyDisplaySettings(settings, display); + + settings.ExpandUsingDeclarations.Should().BeTrue(); + settings.ExpandMemberDefinitions.Should().BeTrue(); + } +} diff --git a/ILSpy.Tests/TextView/OutputLengthLimitTests.cs b/ILSpy.Tests/TextView/OutputLengthLimitTests.cs new file mode 100644 index 000000000..62a33ded8 --- /dev/null +++ b/ILSpy.Tests/TextView/OutputLengthLimitTests.cs @@ -0,0 +1,68 @@ +// 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 AwesomeAssertions; + +using ILSpy.TextView; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests.TextView; + +/// +/// The decompiler text output stops once it exceeds its LengthLimit, so a runaway decompile (a huge +/// type/namespace) can't hang or OOM the UI thread. (Regression: the Avalonia port had no limit.) +/// +[TestFixture] +public class OutputLengthLimitTests +{ + [Test] + public void Write_Throws_Once_The_Length_Limit_Is_Exceeded() + { + var output = new AvaloniaEditTextOutput { LengthLimit = 10 }; + + var act = () => { + for (int i = 0; i < 100; i++) + output.Write("0123456789"); + }; + + act.Should().Throw(); + } + + [Test] + public void Write_Does_Not_Throw_Below_The_Limit() + { + var output = new AvaloniaEditTextOutput { LengthLimit = 1000 }; + + var act = () => { + output.Write("short output"); + output.WriteLine(); + }; + + act.Should().NotThrow(); + } + + [Test] + public void Default_Limit_Is_Unlimited() + { + var output = new AvaloniaEditTextOutput(); + output.LengthLimit.Should().Be(int.MaxValue); + } +} diff --git a/ILSpy/TextView/AvaloniaEditTextOutput.cs b/ILSpy/TextView/AvaloniaEditTextOutput.cs index 9287e2138..db129d256 100644 --- a/ILSpy/TextView/AvaloniaEditTextOutput.cs +++ b/ILSpy/TextView/AvaloniaEditTextOutput.cs @@ -45,6 +45,14 @@ namespace ILSpy.TextView public sealed class AvaloniaEditTextOutput : ISmartTextOutput { readonly StringBuilder builder = new(); + + /// + /// When the accumulated text exceeds this many characters, the next write throws + /// so a runaway decompile is stopped before it + /// hangs/OOMs the UI thread. Defaults to unlimited; the decompiler tab sets a real limit. + /// + public int LengthLimit { get; set; } = int.MaxValue; + readonly Stack<(int Offset, HighlightingColor Color)> openSpans = new(); readonly Stack<(NewFolding Folding, int StartLine)> openFoldings = new(); readonly List foldings = new(); @@ -127,12 +135,14 @@ namespace ILSpy.TextView { WriteIndentIfNeeded(); builder.Append(ch); + CheckLength(); } public void Write(string text) { WriteIndentIfNeeded(); builder.Append(text); + CheckLength(); } public void WriteLine() @@ -140,6 +150,13 @@ namespace ILSpy.TextView builder.Append('\n'); lineNumber++; needsIndent = true; + CheckLength(); + } + + void CheckLength() + { + if (builder.Length > LengthLimit) + throw new OutputLengthExceededException(); } public void WriteReference(OpCodeInfo opCode, bool omitSuffix = false) diff --git a/ILSpy/TextView/DecompilerTabPageModel.cs b/ILSpy/TextView/DecompilerTabPageModel.cs index a93dd60b9..7fcaf18c8 100644 --- a/ILSpy/TextView/DecompilerTabPageModel.cs +++ b/ILSpy/TextView/DecompilerTabPageModel.cs @@ -37,6 +37,7 @@ using CommunityToolkit.Mvvm.Input; using ICSharpCode.Decompiler; using ILSpy.Languages; +using ILSpy.Options; using ILSpy.TreeNodes; using ILSpy.ViewModels; @@ -299,6 +300,15 @@ namespace ILSpy.TextView int pendingStepLimit = int.MaxValue; bool pendingIsDebug; + /// + /// Output-length safety limits (characters): a decompile that produces more than the active + /// limit is stopped and replaced with a "too much code" message rather than hanging/OOMing the + /// UI. The user can re-run at the extended limit or save to disk. Mirrors the previous version. + /// + public const int DefaultOutputLengthLimit = 5_000_000; + public const int ExtendedOutputLengthLimit = 75_000_000; + int pendingOutputLengthLimit = DefaultOutputLengthLimit; + /// /// Force a fresh decompile of the same nodes with the IL-transform pipeline halted /// after steps. Used by the Debug Steps pane to render @@ -313,6 +323,53 @@ namespace ILSpy.TextView StartDecompile(); } + /// Re-runs the current decompile with a larger output-length limit (the "Display code + /// anyway" button on the too-much-code message). + public void RestartDecompileWithOutputLimit(int outputLengthLimit) + { + pendingOutputLengthLimit = outputLengthLimit; + StartDecompile(); + } + + // Built (off the UI thread) when a decompile blows past its output limit: a short message plus + // a "Display code anyway" button (retry at the extended limit, only offered for the normal + // limit) and a "Save code" button. The button click handlers run back on the UI thread. + AvaloniaEditTextOutput BuildOutputLengthExceededMessage(int outputLengthLimit) + { + var output = new AvaloniaEditTextOutput(); + bool wasNormalLimit = outputLengthLimit == DefaultOutputLengthLimit; + output.WriteLine(wasNormalLimit + ? "You have selected too much code for it to be displayed automatically." + : "You have selected too much code; it cannot be displayed here."); + output.WriteLine(); + if (wasNormalLimit) + { + output.AddButton(Images.Images.ViewCode, ICSharpCode.ILSpy.Properties.Resources.DisplayCode, + (_, _) => RestartDecompileWithOutputLimit(ExtendedOutputLengthLimit)); + output.WriteLine(); + } + output.AddButton(Images.Images.Save, ICSharpCode.ILSpy.Properties.Resources.SaveCode, + (_, _) => SaveCurrentNode()); + output.WriteLine(); + return output; + } + + void SaveCurrentNode() + { + if (CurrentNode is not { } node) + return; + try + { + var languageService = AppEnv.AppComposition.Current.GetExport(); + var dockWorkspace = AppEnv.AppComposition.Current.GetExport(); + ILSpy.Commands.SaveCodeHelper.SaveNodeAsync(node, languageService, dockWorkspace).HandleExceptions(); + } + catch + { + // Best-effort: no save path available (minimal host). + } + } + // Fire-and-forget wrapper around DecompileAsync that observes the resulting Task. // Without this, exceptions raised by the dispatched property setters (e.g. the // PropertyChanged subscribers in DecompilerTextView) become UnobservedTaskException @@ -394,11 +451,13 @@ namespace ILSpy.TextView var isDebug = pendingIsDebug; pendingStepLimit = int.MaxValue; pendingIsDebug = false; + var outputLengthLimit = pendingOutputLengthLimit; + pendingOutputLengthLimit = DefaultOutputLengthLimit; AvaloniaEditTextOutput output; using (ILSpy.AppEnv.AppLog.Phase($"DecompileAsync #{callNumber}: Task.Run decompile body ({nodes.Count} node(s), language={language.Name})")) { (output, _) = await Task.Run(() => { - var output = new AvaloniaEditTextOutput(); + var output = new AvaloniaEditTextOutput { LengthLimit = outputLengthLimit }; var options = decompilerSettings != null ? new DecompilationOptions(decompilerSettings) { CancellationToken = cts.Token, @@ -425,6 +484,12 @@ namespace ILSpy.TextView { // expected on cancel — just return whatever we got } + catch (OutputLengthExceededException) + { + // The decompile produced more text than the limit allows -- replace it with + // a message offering to display anyway (extended limit) or save to disk. + output = BuildOutputLengthExceededMessage(outputLengthLimit); + } catch (Exception ex) { output.WriteLine(); @@ -597,13 +662,7 @@ namespace ILSpy.TextView { var settingsService = AppEnv.AppComposition.Current.GetExport(); var settings = settingsService.DecompilerSettings.Clone(); - // Bridge the Display-options fold-expansion flags into the decompiler settings: - // TextTokenWriter reads settings.ExpandUsingDeclarations / ExpandMemberDefinitions - // to set each fold's DefaultClosed. Without this the "Expand using declarations / - // member definitions after decompilation" options would have no effect. - var display = settingsService.DisplaySettings; - settings.ExpandUsingDeclarations = display.ExpandUsingDeclarations; - settings.ExpandMemberDefinitions = display.ExpandMemberDefinitions; + ApplyDisplaySettings(settings, settingsService.DisplaySettings); try { var version = AppEnv.AppComposition.Current.GetExport().CurrentVersion; @@ -624,5 +683,31 @@ namespace ILSpy.TextView return null; } } + + /// + /// Bridges the Display options that affect decompiler output into : + /// the fold-expansion flags (TextTokenWriter reads them to set each fold's DefaultClosed), + /// brace folding, debug-symbol info, and the indentation string. Without this these Display + /// options would have no effect on the produced source. + /// + internal static void ApplyDisplaySettings(ICSharpCode.Decompiler.DecompilerSettings settings, DisplaySettings display) + { + settings.ExpandUsingDeclarations = display.ExpandUsingDeclarations; + settings.ExpandMemberDefinitions = display.ExpandMemberDefinitions; + settings.FoldBraces = display.FoldBraces; + settings.ShowDebugInfo = display.ShowDebugInfo; + settings.CSharpFormattingOptions.IndentationString = GetIndentationString(display); + } + + static string GetIndentationString(DisplaySettings display) + { + if (display.IndentationUseTabs) + { + int tabs = display.IndentationSize / display.IndentationTabSize; + int spaces = display.IndentationSize % display.IndentationTabSize; + return new string('\t', tabs) + new string(' ', spaces); + } + return new string(' ', display.IndentationSize); + } } } diff --git a/ILSpy/TextView/OutputLengthExceededException.cs b/ILSpy/TextView/OutputLengthExceededException.cs new file mode 100644 index 000000000..6fa8304ca --- /dev/null +++ b/ILSpy/TextView/OutputLengthExceededException.cs @@ -0,0 +1,31 @@ +// 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; + +namespace ILSpy.TextView +{ + /// + /// Thrown by once its accumulated text exceeds + /// , so a runaway decompile (a huge type or + /// namespace) is stopped before it can hang or exhaust memory on the UI thread. + /// + public sealed class OutputLengthExceededException : Exception + { + } +}