diff --git a/CLAUDE.md b/CLAUDE.md index 4dd9a7e32..aa0472911 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -101,7 +101,7 @@ Never put more than one C# language feature on one branch. - Always run the test suite with `--report-trx` so failures survive: `dotnet test --solution ILSpy.sln --report-trx` (the repo pins Microsoft.Testing.Platform in `global.json`; the bare `dotnet test ` form is the old VSTest syntax). Don't dismiss failures as flaky without first reproducing in isolation, then running repeatedly. - The decompiler test suite (test kinds, fixture structure, how to write tests, the compiler-matrix model) is documented in [ICSharpCode.Decompiler.Tests/CLAUDE.md](ICSharpCode.Decompiler.Tests/CLAUDE.md). - After matcher / rewriter edits, **run the relevant tests, not just the build.** `dotnet build` green ≠ behaviour correct. -- **To see what a transform did, dump the ILAst:** `ilspycmd -m --ilast` prints the IL transform pipeline's result, and `--after-transform ` stops the pipeline early so two stages can be diffed. Debug builds only (like the UI's ILAst language), so run it from a local build, not the installed tool. +- **To see what a transform did, dump the ILAst:** `ilspycmd -m --ilast` prints the IL transform pipeline's result, and `--after-transform ` stops the pipeline early so two stages can be diffed. Debug builds only (like the UI's Debug Steps pane), so run it from a local build, not the installed tool. ## Investigating dependencies diff --git a/ICSharpCode.Decompiler.Tests/DebugStepRecordingTests.cs b/ICSharpCode.Decompiler.Tests/DebugStepRecordingTests.cs new file mode 100644 index 000000000..e3139ebb2 --- /dev/null +++ b/ICSharpCode.Decompiler.Tests/DebugStepRecordingTests.cs @@ -0,0 +1,366 @@ +// 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 System.Collections.Generic; +using System.Linq; + +using ICSharpCode.Decompiler.CSharp; +using ICSharpCode.Decompiler.DebugSteps; +using ICSharpCode.Decompiler.IL; +using ICSharpCode.Decompiler.Util; +using ICSharpCode.Decompiler.IL.Transforms; +using ICSharpCode.Decompiler.Metadata; +using ICSharpCode.Decompiler.Tests.Helpers; +using ICSharpCode.Decompiler.TypeSystem; + +using NUnit.Framework; + +namespace ICSharpCode.Decompiler.Tests +{ + /// + /// The Debug Steps view replays a decompilation by index, so a single has to + /// span the whole pipeline: the IL transforms, the ILAst-to-C# conversion, and the C# AST + /// transforms. These tests pin the IL half of that recording. + /// + [TestFixture] + public class DebugStepRecordingTests + { + const string RecordedStep = "recorded IL step"; + const string DetachedStep = "step on a detached function"; + + const string SeamStep = "C# AST built from ILAst"; + + static readonly FullTypeName SampleType = + new FullTypeName("ICSharpCode.Decompiler.CSharp.ProjectDecompiler.WholeProjectDecompiler"); + + [Test] + public void ILTransformStepsLandOnTheDecompilerStepper() + { + var decompiler = CreateRecordingDecompiler(); + decompiler.ILTransforms.Add(new RecordingILTransform()); + + decompiler.DecompileTypeAsString(SampleType); + + Assert.That(TreeTraversal.PreOrder(decompiler.Stepper.Steps, n => n.Children).Select(n => n.Description), Has.Some.Contains(RecordedStep)); + } + + /// + /// Retaining the IL steps of every member of a type is affordable for a step view and not for a + /// whole-module decompile, which is why it is opt-in - and why callers that never opted in must + /// keep the throwaway per-member stepper they have always had. + /// + [Test] + public void ILTransformStepsAreNotRecordedWithoutOptIn() + { + var decompiler = StepperTesting.CreateDecompiler(); + decompiler.ILTransforms.Add(new RecordingILTransform()); + + decompiler.DecompileTypeAsString(SampleType); + + Assert.That(TreeTraversal.PreOrder(decompiler.Stepper.Steps, n => n.Children).Select(n => n.Description), Has.None.Contains(RecordedStep)); + } + + /// + /// Halting in the IL phase leaves no C# to show, so the caller needs the ILAst the pipeline + /// stopped in - and must not be told the member failed to decompile. + /// + [Test] + public void AStepLimitInTheILPhaseHaltsWithThePartiallyTransformedFunction() + { + var decompiler = CreateRecordingDecompiler(); + decompiler.ILTransforms.Insert(0, new RecordingILTransform()); + decompiler.Stepper.StepLimit = 0; + + string code = decompiler.DecompileTypeAsString(SampleType); + + var astTransformNames = CSharpDecompiler.GetAstTransforms().Select(t => t.GetType().Name).ToArray(); + using (Assert.EnterMultipleScope()) + { + Assert.That(decompiler.StepLimitHaltedFunction, Is.Not.Null, "the caller renders this as ILAst"); + Assert.That(decompiler.Stepper.LimitReachedStep, Is.Not.Null); + Assert.That(decompiler.Errors, Is.Empty, "a deliberate halt is not a decompilation failure"); + Assert.That(code, Does.Not.Contain(CSharpDecompiler.DecompilationErrorReportUrl)); + // The C# AST phase must not run after an IL-phase halt. It would hit the same limit + // again - the step counter never advances past it - and the second hit would replace + // LimitReachedStep with an AST node, so the position the halted step is highlighted at + // would be lost. + Assert.That(astTransformNames, Has.None.Matches( + name => decompiler.Stepper.LimitReachedStep!.Description.Contains(name)), + "the halt must still report the IL step it stopped on"); + } + } + + /// + /// A limit in the C# AST phase prints the partially transformed tree, and nothing claims the + /// run halted in the IL phase. + /// + [Test] + public void AStepLimitInTheAstPhaseStillProducesPartialCSharp() + { + var decompiler = StepperTesting.CreateDecompiler(); + decompiler.Stepper.StepLimit = 1; + + string code = decompiler.DecompileTypeAsString(SampleType); + + using (Assert.EnterMultipleScope()) + { + Assert.That(decompiler.StepLimitHaltedFunction, Is.Null); + Assert.That(code, Does.Contain("DecompileProject"), "the members are still written"); + } + } + + /// + /// A member whose transform throws must not swallow the members after it: their steps belong + /// beside it in the tree, not inside the group it abandoned. + /// + [Test] + public void AFailingTransformDoesNotNestLaterMembersUnderIt() + { + if (!Stepper.SteppingAvailable) + Assert.Ignore("Transform stepping is compiled out without the STEP symbol, so there are no groups to check."); + + var decompiler = CreateRecordingDecompiler(); + decompiler.ILTransforms.Add(new StepperTesting.ThrowingILTransform("CleanUpFileName")); + + decompiler.DecompileTypeAsString(SampleType); + + var crashed = decompiler.Stepper.Steps.Single(n => n.Description.Contains("CleanUpFileName")); + using (Assert.EnterMultipleScope()) + { + Assert.That(crashed.EndStep, Is.GreaterThan(crashed.BeginStep + 1), "the abandoned group was closed at the step it stopped on"); + Assert.That(decompiler.Stepper.Steps, Has.Some.Matches(n => n.Description.Contains("SanitizeFileName")), + "the members after the failing one stay top-level siblings"); + } + } + + /// + /// A step index only means anything against the run that produced it, so there must not be a + /// second numbering to confuse it with: with recording off the pipeline counts nothing at all, + /// neither the IL transforms nor the C# AST transforms that used to be recorded regardless. + /// + [Test] + public void NothingIsRecordedWhileRecordingIsOff() + { + var decompiler = StepperTesting.CreateDecompiler(); + + decompiler.DecompileTypeAsString(SampleType); + + using (Assert.EnterMultipleScope()) + { + Assert.That(decompiler.RecordSteps, Is.False, "recording is off unless a caller asks for it"); + Assert.That(decompiler.Stepper.Steps, Is.Empty, "no step may be recorded while recording is off"); + Assert.That(decompiler.Stepper.CurrentStep, Is.Zero, "the step counter must not advance either"); + } + } + + /// + /// The crashed-member attribution compares the step counter against the limit, which is only + /// meaningful while the counter is actually counting. With recording off it sits at zero, so a + /// limit of zero would otherwise match at every throwing transform and hand the pane an + /// unrelated member's ILAst. + /// + [Test] + public void ACrashWhileRecordingIsOffAttributesNoHaltedFunction() + { + var decompiler = StepperTesting.CreateDecompiler(); + decompiler.ILTransforms.Add(new StepperTesting.ThrowingILTransform("CleanUpFileName")); + decompiler.Stepper.StepLimit = 0; + + decompiler.DecompileTypeAsString(SampleType); + + Assert.That(decompiler.StepLimitHaltedFunction, Is.Null, + "a step limit cannot be reached by a run that records no steps"); + } + + /// + /// Closing the groups an unwind abandoned must stop at the depth the member started from: a + /// group opened around the decompile still belongs to whoever opened it, and swallowing it + /// files every later step as that caller's sibling instead of its child. + /// + [Test] + public void AFailingTransformLeavesAGroupOpenedAroundItAlone() + { + if (!Stepper.SteppingAvailable) + Assert.Ignore("Transform stepping is compiled out without the STEP symbol, so there are no groups to check."); + + var decompiler = CreateRecordingDecompiler(); + decompiler.ILTransforms.Add(new StepperTesting.ThrowingILTransform("CleanUpFileName")); + + var outer = decompiler.Stepper.StartGroup("outer"); + decompiler.DecompileTypeAsString(SampleType); + var afterwards = decompiler.Stepper.Step("after the decompile"); + + using (Assert.EnterMultipleScope()) + { + Assert.That(outer.Children, Does.Contain(afterwards), + "the group opened around the decompile must still be the one collecting steps"); + Assert.That(decompiler.Stepper.Steps, Does.Not.Contain(afterwards), + "a step recorded inside the outer group is not a top-level sibling of it"); + } + } + + /// + /// The plain ExpressionBuilder/StatementBuilder output is a state worth looking at, so it has + /// a step of its own at the top level rather than being reachable only as "before the first + /// AST transform". Halting there has to print C#: the whole type is converted by then, so + /// nothing is left in ILAst. + /// + [Test] + public void TheStateAtTheSeamIsUntransformedCSharp() + { + if (!Stepper.SteppingAvailable) + Assert.Ignore("Transform stepping is compiled out without the STEP symbol, so there is no seam step."); + + var decompiler = CreateRecordingDecompiler(); + string full = decompiler.DecompileTypeAsString(SampleType); + + var seam = decompiler.Stepper.Steps.SingleOrDefault(n => n.Description.Contains(SeamStep)); + Assert.That(seam, Is.Not.Null, $"'{SeamStep}' must be recorded once, at the top level"); + + var replay = CreateRecordingDecompiler(); + replay.Stepper.StepLimit = seam!.BeginStep; + string atSeam = replay.DecompileTypeAsString(SampleType); + + using (Assert.EnterMultipleScope()) + { + Assert.That(replay.StepLimitHaltedFunction, Is.Null, + "the seam is past every member's IL phase, so there is no halted function to dump"); + Assert.That(atSeam, Does.Contain("class WholeProjectDecompiler"), + "halting at the seam still prints C#"); + Assert.That(atSeam, Is.Not.EqualTo(full), + "the AST transforms have not run yet, so this cannot equal the finished output"); + } + } + + /// + /// A member group ends where the next member's group begins, so the state after its last step is + /// the state that member finished in - not the untouched IL of the member the halt unwinds from. + /// + [Test] + public void TheStateAfterAMemberGroupIsThatMembersTransformedFunction() + { + if (!Stepper.SteppingAvailable) + Assert.Ignore("Transform stepping is compiled out without the STEP symbol, so there are no groups to replay."); + + int endStep = MemberGroup("CleanUpFileName", CreateRecordingDecompiler()).EndStep; + + var replay = CreateRecordingDecompiler(); + replay.Stepper.StepLimit = endStep; + replay.DecompileTypeAsString(SampleType); + + Assert.That(replay.StepLimitHaltedFunction?.Name, Is.EqualTo("CleanUpFileName")); + } + + /// + /// Replaying the state after a group whose transform threw stops on the exception, never on a + /// step: the ILAst the crash left half-transformed is the whole point of looking at it. + /// + [Test] + public void TheStateAfterACrashedGroupIsTheFunctionTheTransformCrashedIn() + { + if (!Stepper.SteppingAvailable) + Assert.Ignore("Transform stepping is compiled out without the STEP symbol, so there are no groups to replay."); + + var decompiler = CreateRecordingDecompiler(); + decompiler.ILTransforms.Add(new StepperTesting.ThrowingILTransform("CleanUpFileName")); + decompiler.DecompileTypeAsString(SampleType); + int endStep = decompiler.Stepper.Steps.Single(n => n.Description.Contains("CleanUpFileName")).EndStep; + + var replay = CreateRecordingDecompiler(); + replay.ILTransforms.Add(new StepperTesting.ThrowingILTransform("CleanUpFileName")); + replay.Stepper.StepLimit = endStep; + replay.DecompileTypeAsString(SampleType); + + Assert.That(replay.StepLimitHaltedFunction?.Name, Is.EqualTo("CleanUpFileName")); + } + + /// + /// Some transforms build a helper function and work on it before attaching it to the member + /// (ProxyCallReplacer, the nested-function decompilers). A halt in there has to render the tree + /// that holds the halted instruction; the member's function does not contain it yet. + /// + [Test] + public void AHaltOnADetachedFunctionRendersThatFunction() + { + var detachedStep = new DetachedFunctionILTransform("CleanUpFileName"); + var decompiler = CreateRecordingDecompiler(); + decompiler.ILTransforms.Add(detachedStep); + decompiler.DecompileTypeAsString(SampleType); + int haltAt = TreeTraversal.PreOrder(decompiler.Stepper.Steps, n => n.Children).Single(n => n.Description.Contains(DetachedStep)).BeginStep; + + var replay = CreateRecordingDecompiler(); + var replayStep = new DetachedFunctionILTransform("CleanUpFileName"); + replay.ILTransforms.Add(replayStep); + replay.Stepper.StepLimit = haltAt; + replay.DecompileTypeAsString(SampleType); + + Assert.That(replay.StepLimitHaltedFunction, Is.SameAs(replayStep.DetachedFunction)); + } + + /// + /// Runs the decompiler once and returns the top-level group recording the named member's IL phase. + /// + static Stepper.Node MemberGroup(string methodName, CSharpDecompiler decompiler) + { + decompiler.DecompileTypeAsString(SampleType); + return decompiler.Stepper.Steps.Single(n => n.Description.EndsWith("." + methodName, StringComparison.Ordinal)); + } + + static CSharpDecompiler CreateRecordingDecompiler() + { + var decompiler = StepperTesting.CreateDecompiler(); + decompiler.RecordSteps = true; + return decompiler; + } + + /// + /// Records one step per top-level function through rather than + /// context.Step: the latter is [Conditional("STEP")], and that is resolved where + /// the call is compiled - this assembly, which never defines STEP - so a context.Step + /// call would vanish and leave the tests passing vacuously in every configuration. + /// + /// + /// Records one step on an instruction of a function that hangs off nothing, standing in for the + /// helper functions the real transforms build before attaching them. + /// + sealed class DetachedFunctionILTransform(string methodName) : IILTransform + { + public ILFunction? DetachedFunction { get; private set; } + + public void Run(ILFunction function, ILTransformContext context) + { + if (function.Parent != null || function.Method?.Name != methodName) + return; + DetachedFunction = new ILFunction(function.Method!, function.CodeSize, function.GenericContext, + new Nop(), ILFunctionKind.LocalFunction); + context.Stepper.Step(DetachedStep, new DebugStepNodeInfo(DetachedFunction.Body)); + } + } + + sealed class RecordingILTransform : IILTransform + { + public void Run(ILFunction function, ILTransformContext context) + { + if (function.Parent == null) + context.Stepper.Step(RecordedStep); + } + } + + } +} diff --git a/ICSharpCode.Decompiler.Tests/DecompilationErrorRecoveryTests.cs b/ICSharpCode.Decompiler.Tests/DecompilationErrorRecoveryTests.cs index a0fcf17b7..c5d28d9b9 100644 --- a/ICSharpCode.Decompiler.Tests/DecompilationErrorRecoveryTests.cs +++ b/ICSharpCode.Decompiler.Tests/DecompilationErrorRecoveryTests.cs @@ -26,6 +26,7 @@ using ICSharpCode.Decompiler.CSharp.Syntax; using ICSharpCode.Decompiler.IL; using ICSharpCode.Decompiler.IL.Transforms; using ICSharpCode.Decompiler.Metadata; +using ICSharpCode.Decompiler.Tests.Helpers; using ICSharpCode.Decompiler.TypeSystem; using NUnit.Framework; @@ -40,20 +41,19 @@ namespace ICSharpCode.Decompiler.Tests [TestFixture] public class DecompilationErrorRecoveryTests { - const string SimulatedFailure = "Simulated transform failure"; [Test] public void FailingMethodBodyKeepsTheRestOfTheType() { - var decompiler = CreateDecompiler(); - decompiler.ILTransforms.Add(new ThrowingILTransform("CleanUpFileName")); + var decompiler = StepperTesting.CreateDecompiler(); + decompiler.ILTransforms.Add(new StepperTesting.ThrowingILTransform("CleanUpFileName")); string code = decompiler.DecompileTypeAsString( new FullTypeName("ICSharpCode.Decompiler.CSharp.ProjectDecompiler.WholeProjectDecompiler")); using (Assert.EnterMultipleScope()) { - Assert.That(code, Does.Contain(SimulatedFailure), "the exception text must show up in the output"); + Assert.That(code, Does.Contain(StepperTesting.SimulatedFailure), "the exception text must show up in the output"); Assert.That(code, Does.Contain(CSharpDecompiler.DecompilationErrorReportUrl), "users need to be told where to report this"); Assert.That(code, Does.Contain("public static string CleanUpFileName"), "the failing member keeps its signature"); Assert.That(code, Does.Contain("DecompileProject"), "the other members of the type are unaffected"); @@ -63,8 +63,8 @@ namespace ICSharpCode.Decompiler.Tests [Test] public void FailingMethodBodyIsRecordedAsError() { - var decompiler = CreateDecompiler(); - decompiler.ILTransforms.Add(new ThrowingILTransform("CleanUpFileName")); + var decompiler = StepperTesting.CreateDecompiler(); + decompiler.ILTransforms.Add(new StepperTesting.ThrowingILTransform("CleanUpFileName")); decompiler.DecompileTypeAsString( new FullTypeName("ICSharpCode.Decompiler.CSharp.ProjectDecompiler.WholeProjectDecompiler")); @@ -80,8 +80,8 @@ namespace ICSharpCode.Decompiler.Tests [Test] public void ErrorsCoverOnlyTheLastDecompilation() { - var decompiler = CreateDecompiler(); - var failing = new ThrowingILTransform("CleanUpFileName"); + var decompiler = StepperTesting.CreateDecompiler(); + var failing = new StepperTesting.ThrowingILTransform("CleanUpFileName"); decompiler.ILTransforms.Add(failing); decompiler.DecompileTypeAsString( new FullTypeName("ICSharpCode.Decompiler.CSharp.ProjectDecompiler.WholeProjectDecompiler")); @@ -101,7 +101,7 @@ namespace ICSharpCode.Decompiler.Tests [Test] public void FailingOutputKeepsTheFileWellFormed() { - var decompiler = CreateDecompiler(); + var decompiler = StepperTesting.CreateDecompiler(); var syntaxTree = decompiler.DecompileType( new FullTypeName("ICSharpCode.Decompiler.CSharp.ProjectDecompiler.WholeProjectDecompiler")); @@ -125,21 +125,5 @@ namespace ICSharpCode.Decompiler.Tests } } - static CSharpDecompiler CreateDecompiler() - { - var module = new PEFile("ICSharpCode.Decompiler.dll"); - var settings = new DecompilerSettings(); - var typeSystem = new DecompilerTypeSystem(module, new UniversalAssemblyResolver(null, false, null), settings); - return new CSharpDecompiler(typeSystem, settings); - } - - sealed class ThrowingILTransform(string methodName) : IILTransform - { - public void Run(ILFunction function, ILTransformContext context) - { - if (function.Parent == null && function.Method?.Name == methodName) - throw new InvalidOperationException(SimulatedFailure); - } - } } } diff --git a/ICSharpCode.Decompiler.Tests/Helpers/StepperTesting.cs b/ICSharpCode.Decompiler.Tests/Helpers/StepperTesting.cs new file mode 100644 index 000000000..23d018d86 --- /dev/null +++ b/ICSharpCode.Decompiler.Tests/Helpers/StepperTesting.cs @@ -0,0 +1,57 @@ +// 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 ICSharpCode.Decompiler.CSharp; +using ICSharpCode.Decompiler.IL; +using ICSharpCode.Decompiler.IL.Transforms; +using ICSharpCode.Decompiler.Metadata; +using ICSharpCode.Decompiler.TypeSystem; + +namespace ICSharpCode.Decompiler.Tests.Helpers +{ + /// + /// Shared setup for the tests that drive the transform pipeline itself rather than the code it + /// produces: they decompile this assembly, which is on disk next to the tests and large enough to + /// span many members, and they need a transform that fails on demand. + /// + static class StepperTesting + { + public const string SimulatedFailure = "Simulated transform failure"; + + public static CSharpDecompiler CreateDecompiler() + { + return new CSharpDecompiler("ICSharpCode.Decompiler.dll", + new UniversalAssemblyResolver(null, false, null), new DecompilerSettings()); + } + + /// + /// Throws while transforming the named method's top-level function, leaving the pipeline to + /// unwind out of whatever step groups it had opened. + /// + public sealed class ThrowingILTransform(string methodName) : IILTransform + { + public void Run(ILFunction function, ILTransformContext context) + { + if (function.Parent == null && function.Method?.Name == methodName) + throw new InvalidOperationException(SimulatedFailure); + } + } + } +} diff --git a/ICSharpCode.Decompiler/CSharp/CSharpDecompiler.cs b/ICSharpCode.Decompiler/CSharp/CSharpDecompiler.cs index ad511a493..ed0668734 100644 --- a/ICSharpCode.Decompiler/CSharp/CSharpDecompiler.cs +++ b/ICSharpCode.Decompiler/CSharp/CSharpDecompiler.cs @@ -210,6 +210,9 @@ namespace ICSharpCode.Decompiler.CSharp transforms.RemoveRange(lastBlockTransform + 1, transforms.Count - (lastBlockTransform + 1)); // Use CombineExitsTransform so that "return other != null && ...;" is a single statement even in release builds transforms.Add(new CombineExitsTransform()); + // Deliberately not the caller's Stepper: this runs speculatively during pattern recognition, + // so its steps are noise in a step tree and its numbering would shift with settings the user + // never chose. il.RunTransforms(transforms, new ILTransformContext(il, typeSystem, debugInfo: null, settings) { CancellationToken = cancellationToken @@ -232,6 +235,35 @@ namespace ICSharpCode.Decompiler.CSharp public Stepper Stepper { get; set; } = new Stepper(); + /// + /// Gets or sets whether the pipeline records its steps into , so that one + /// step tree spans all of it: the IL transforms of every member, the ILAst-to-C# conversion, + /// and the C# AST transforms. + /// Off by default, and off means the whole pipeline records nothing: every phase keeps the + /// stepper its context creates for itself, which nothing reads. Turning it on is what makes the + /// steps retained, and each retained step pins the ILAst it captured, including the + /// instructions its transform removed. That is affordable for the single type a step view + /// displays and not for a whole-module decompile, so only callers that show the steps opt in. + /// A step index is only meaningful against a run with the same value: numbering the whole + /// pipeline and numbering nothing are not the same scale, so a full run and the step-limited + /// re-run that replays one of its indices have to agree on it. + /// Has no effect unless . + /// + public bool RecordSteps { get; set; } + + /// + /// The whose IL transforms were halted by , + /// or null when no step limit was reached before the C# AST was built. Reset at the start of + /// every Decompile* call, like . + /// A limit reached in the IL phase leaves this member and every member after it without a body, + /// so the returned syntax tree is not the interesting output: the caller renders this function + /// as an ILAst dump instead. A limit reached in the C# AST phase leaves this null and still + /// yields partially transformed C#. A transform that throws where the limit was aimed sets this + /// too, so the ILAst the crash left behind can be rendered rather than the error comment. + /// + public ILFunction? StepLimitHaltedFunction { get; private set; } + + /// /// Returns all built-in transforms of the C# AST pipeline. /// @@ -816,6 +848,10 @@ namespace ICSharpCode.Decompiler.CSharp // previous one stop counting - otherwise a reused instance reports them again against // members that decompiled cleanly. errors.Clear(); + // Same reasoning for the halted function: a stale one would make the caller render the + // previous run's ILAst, and the "already halted" guard in DecompileBody would skip every + // member from here on. + StepLimitHaltedFunction = null; List resolvedNamespaces = new List(); foreach (var ns in namespaces) { @@ -841,20 +877,36 @@ namespace ICSharpCode.Decompiler.CSharp void RunTransforms(AstNode rootNode, DecompileRun decompileRun, ITypeResolveContext decompilationContext) { + // The IL phase halted at the step limit, so this tree is missing the bodies it was supposed + // to transform and the caller renders the halted ILAst instead. Running the AST pipeline over + // it would only waste the work and hit the same limit again - and that second hit would + // overwrite Stepper.LimitReachedStep with a node from a tree nobody displays, losing the + // position the halted step is highlighted at. + if (StepLimitHaltedFunction != null) + return; var typeSystemAstBuilder = CreateAstBuilder(decompileRun.Settings); - var context = new TransformContext(typeSystem, decompileRun, decompilationContext, typeSystemAstBuilder) { - Stepper = Stepper - }; + var context = new TransformContext(typeSystem, decompileRun, decompilationContext, typeSystemAstBuilder); + // Off means off for the whole pipeline: leaving the AST half recording would give the same + // pipeline two numbering bases, and an index recorded under one of them means a different + // step under the other. Without this the context keeps a stepper of its own, which nothing + // reads. + if (RecordSteps) + context.Stepper = Stepper; // The tree handed to the pipeline must already be well-formed; check it once up front so a // malformed builder output is caught here rather than blamed on the first transform (DEBUG only). rootNode.CheckInvariant(); bool traceTransforms = DecompilerEventSource.Log.IsTransformTracingEnabled(); try { + // The whole type has been converted and nothing has transformed it yet, so this is the + // one index whose state is the plain ExpressionBuilder/StatementBuilder output. Without + // it that state is only reachable as "before the first AST transform", which names a + // transform rather than the thing being shown and sits after every member's group. + context.Step("C# AST built from ILAst", rootNode); foreach (var transform in astTransforms) { CancellationToken.ThrowIfCancellationRequested(); - context.StepStartGroup(transform.GetType().Name); + context.StepStartGroup(transform.GetType().Name, rootNode); long traceStart = traceTransforms ? Stopwatch.GetTimestamp() : 0; transform.Run(rootNode, context); if (traceTransforms) @@ -2257,8 +2309,53 @@ namespace ICSharpCode.Decompiler.CSharp return method.ReturnType.Kind == TypeKind.Void && method.Name == "InitializeComponent" && method.DeclaringTypeDefinition!.GetNonInterfaceBaseTypes().Any(t => t.FullName == "System.Windows.Forms.Control"); } + /// + /// The outermost holding the instruction the halted step was recorded + /// on, or null when the step named no instruction (a group opener) or the instruction hangs off + /// no function at all. + /// + /// + /// The function a halt belongs to, taken from the stepper rather than reconstructed. The step + /// the limit stopped on is the first choice; on a member's opening group step that step belongs + /// to the next member, so the last step actually recorded - the previous member's - is the + /// state the halt is showing. Following the instruction to its outermost function also covers + /// a step recorded in a helper the pipeline has not attached yet. + /// + ILFunction? HaltedStepFunction() + { + return FunctionOf(Stepper.LimitReachedStep) ?? FunctionOf(Stepper.LastStep); + + static ILFunction? FunctionOf(Stepper.Node? step) + => (step?.Position as ILInstruction)?.Ancestors.OfType().LastOrDefault(); + } + + /// + /// The IL transforms a member's body runs through. Decompiling definitions only stops after + /// yield and async detection: the transforms past it exist to shape a body nobody is going to + /// print, and only IsAsync/IsIterator have to be right on the ILFunction. + /// + IEnumerable SelectILTransforms(bool decompileMemberBodies) + { + foreach (var transform in ilTransforms) + { + yield return transform; + if (!decompileMemberBodies && transform is AsyncAwaitDecompiler) + yield break; + } + } + void DecompileBody(IMethod method, EntityDeclaration entityDecl, DecompileRun decompileRun, ITypeResolveContext decompilationContext, ExtensionInfo? extensionInfo) { + // An earlier member's IL phase already hit the step limit, so the pipeline is stopped for + // good: reading IL for every remaining member only to throw on its first step is wasted work. + if (StepLimitHaltedFunction != null) + return; + // Declared out here so the StepLimitReachedException handler below can report the function + // its transforms were halted in. + ILFunction? function = null; + // Only the groups this member opens may be closed when an exception unwinds out of it: a + // caller that wrapped this call in a group of its own still owns that group afterwards. + int groupDepth = Stepper.GroupDepth; try { var ilReader = new ILReader(typeSystem.MainModule) { @@ -2290,7 +2387,7 @@ namespace ICSharpCode.Decompiler.CSharp entityDecl.AddChild(body, Slots.Body); return; } - var function = ilReader.ReadIL((MethodDefinitionHandle)method.MetadataToken, methodBody, cancellationToken: CancellationToken); + function = ilReader.ReadIL((MethodDefinitionHandle)method.MetadataToken, methodBody, cancellationToken: CancellationToken); function.CheckInvariant(ILPhase.Normal); AddAnnotationsToDeclaration(method, entityDecl, function, parameterOffset); @@ -2309,17 +2406,14 @@ namespace ICSharpCode.Decompiler.CSharp CancellationToken = CancellationToken, DecompileRun = decompileRun }; - foreach (var transform in ilTransforms) - { - CancellationToken.ThrowIfCancellationRequested(); - transform.Run(function, context); - function.CheckInvariant(ILPhase.Normal); - // When decompiling definitions only, we can cancel decompilation of all steps - // after yield and async detection, because only those are needed to properly set - // IsAsync/IsIterator flags on ILFunction. - if (!localSettings.DecompileMemberBodies && transform is AsyncAwaitDecompiler) - break; - } + if (RecordSteps) + context.Stepper = Stepper; + // Deliberately unanchored: a member's opening step is where the *previous* member's + // state ends, so giving it this member's function would attribute a halt on the + // boundary to the member the pipeline is only about to start. + context.StepStartGroup(method.FullName); + function.RunTransforms(SelectILTransforms(localSettings.DecompileMemberBodies), context); + // Generate C# AST only if bodies should be displayed. if (localSettings.DecompileMemberBodies) @@ -2333,6 +2427,10 @@ namespace ICSharpCode.Decompiler.CSharp decompileRun, CancellationToken ); + // The seam between the two halves of the pipeline. Besides marking where the IL steps + // end and the C# AST steps begin, it is the only handle on the fully transformed + // ILAst: every IL step shows the state before some transform, never after the last. + context.Step("Convert ILAst to C#", function.Body); body = statementBuilder.ConvertAsBlock(function.Body); var warningAnchor = body.Statements.FirstOrDefault(); @@ -2349,14 +2447,42 @@ namespace ICSharpCode.Decompiler.CSharp entityDecl.AddChild(body, Slots.Body); } + context.StepEndGroup(keepIfEmpty: true); + CleanUpMethodDeclaration(entityDecl, body, function, localSettings.DecompileMemberBodies); } + catch (StepLimitReachedException) + { + // A step limit reached in the IL phase stops the pipeline for good: this member and every + // member after it stay body-less, and the caller renders StepLimitHaltedFunction as ILAst + // rather than the C# it would otherwise print. This clause has to stay above the general + // handler below, which would turn the halt into an error comment and report the member as + // a decompilation failure. + // A halt on this member's opening group step is the state right after the previous member + // finished - the index the pane replays for "show state after" its last step - so that is + // the function to render, not the untouched IL of the member the exception unwound from. + // A step recorded on a helper function the pipeline has not attached yet (a proxy body, a + // nested function on its way into place) belongs to that function's own tree, which the + // member's function does not contain - so follow the halted step's instruction to the + // function that actually holds it. + StepLimitHaltedFunction = HaltedStepFunction() ?? function; + Stepper.EndOpenGroups(groupDepth); + } catch (Exception innerException) when (!(innerException is OperationCanceledException)) { // One method the decompiler cannot handle must not cost the user the type or, when // exporting a project, the assembly around it: keep the signature, put the error in // front of it, and let the remaining members decompile. errors.Add(innerException as DecompilerException ?? new DecompilerException(module, method, innerException)); + // The unwind left this member's step groups open; close them so the members after it are + // recorded as its siblings instead of disappearing into the group that failed. + Stepper.EndOpenGroups(groupDepth); + // A replay aiming at the state after the crashed group stops exactly here: the transform + // throws before any step can reach the limit, so without this the halt would be attributed + // to the next member and the half-transformed ILAst the crash left behind - the one thing + // worth looking at when debugging a throwing transform - would be unreachable. + if (RecordSteps && function != null && Stepper.CurrentStep == Stepper.StepLimit) + StepLimitHaltedFunction = function; entityDecl.GetChild(Slots.Body)?.Remove(); if (settings.DecompileMemberBodies) { diff --git a/ICSharpCode.Decompiler/DebugSteps/Stepper.cs b/ICSharpCode.Decompiler/DebugSteps/Stepper.cs index 2dab0f037..9a92806ae 100644 --- a/ICSharpCode.Decompiler/DebugSteps/Stepper.cs +++ b/ICSharpCode.Decompiler/DebugSteps/Stepper.cs @@ -183,6 +183,13 @@ namespace ICSharpCode.Decompiler.DebugSteps readonly IList steps; int step = 0; + /// + /// Index the next recorded step will be given. It equals exactly at the + /// point the limit is about to halt the pipeline, so a caller unwinding for a different reason + /// can tell whether it is standing on the step a replay was aiming for. + /// + public int CurrentStep => step; + public Stepper() { steps = new List(); @@ -256,6 +263,30 @@ namespace ICSharpCode.Decompiler.DebugSteps } } + /// + /// Closes every group that is still open, e.g. after an exception unwound out of the transform + /// that started them. Without this, everything recorded afterwards is filed as a child of the + /// group that failed, and that group's still points at its own start, + /// so replaying "the state after this step" lands on the wrong step. + /// The groups are kept even when empty: a group that recorded nothing before it was abandoned is + /// precisely the one worth seeing, and 's removal path expects the group to + /// still be the last entry of its parent, which an unwind cannot guarantee. + /// Closing stops at , which every caller must state: a group that + /// was already open before the + /// unwinding code ran belongs to whoever opened it, not to the unwind. + /// + public void EndOpenGroups(int targetDepth) + { + while (groups.Count > targetDepth) + EndGroup(keepIfEmpty: true); + } + + /// + /// How many groups are currently open. Take it before entering code that may unwind, and hand + /// it back to so only the groups that code opened are closed. + /// + public int GroupDepth => groups.Count; + public void EndGroup(bool keepIfEmpty = false) { var node = groups.Pop(); diff --git a/ICSharpCode.Decompiler/IL/Instructions/ILFunction.cs b/ICSharpCode.Decompiler/IL/Instructions/ILFunction.cs index a990f05c6..c0718070c 100644 --- a/ICSharpCode.Decompiler/IL/Instructions/ILFunction.cs +++ b/ICSharpCode.Decompiler/IL/Instructions/ILFunction.cs @@ -406,13 +406,16 @@ namespace ICSharpCode.Decompiler.IL foreach (var transform in transforms) { context.CancellationToken.ThrowIfCancellationRequested(); + // 'near: this' is what lets a halt on a group opener be traced back to the function it + // belongs to; without a position the step carries no anchor and the halt cannot be + // attributed to any member. if (transform is BlockILTransform blockTransform) { - context.StepStartGroup(blockTransform.ToString()); + context.StepStartGroup(blockTransform.ToString(), this); } else { - context.StepStartGroup(transform.GetType().Name); + context.StepStartGroup(transform.GetType().Name, this); } long traceStart = traceTransforms ? System.Diagnostics.Stopwatch.GetTimestamp() : 0; transform.Run(this, context); diff --git a/ICSharpCode.ILSpyCmd/ILAstDumper.cs b/ICSharpCode.ILSpyCmd/ILAstDumper.cs index 808e5544a..752e6c906 100644 --- a/ICSharpCode.ILSpyCmd/ILAstDumper.cs +++ b/ICSharpCode.ILSpyCmd/ILAstDumper.cs @@ -37,14 +37,14 @@ namespace ICSharpCode.ILSpyCmd /// /// Writes the decompiler's intermediate representation (ILAst) of a method body, optionally /// stopping the IL transform pipeline after a chosen transform. This is the command-line - /// counterpart of the UI's "ILAst" language, and makes transform output diffable. + /// counterpart of the UI's Debug Steps pane, and makes transform output diffable. /// class ILAstDumper { static readonly IReadOnlyList transforms = CSharpDecompiler.GetILTransforms(); readonly ILAstWritingOptions writingOptions = new ILAstWritingOptions { - // same sugar as the UI's ILAst pane, so its output and this one are diffable + // same sugar as the Debug Steps pane's ILAst dump, so its output and this one are diffable UseFieldSugar = true, UseLogicOperationSugar = true, }; diff --git a/ICSharpCode.ILSpyCmd/IlspyCmdProgram.cs b/ICSharpCode.ILSpyCmd/IlspyCmdProgram.cs index 4932aa375..15e74dab4 100644 --- a/ICSharpCode.ILSpyCmd/IlspyCmdProgram.cs +++ b/ICSharpCode.ILSpyCmd/IlspyCmdProgram.cs @@ -119,7 +119,7 @@ Examples: #if DEBUG // ILAst is the decompiler's own working representation: it exists to debug transforms - // while developing ILSpy, so - like the UI's ILAst language - it ships in debug builds + // while developing ILSpy, so - like the UI's Debug Steps pane - it ships in debug builds // only and is absent from the released tool. [Option("--ilast", "Show the decompiler's intermediate representation (ILAst) of method bodies, after the full IL transform pipeline. Select what to dump with --type or --member; without either, every method of the assembly is dumped.", CommandOptionType.NoValue)] public bool ShowILAstFlag { get; } diff --git a/ILSpy.Tests/Editor/DecompilerViewTests.cs b/ILSpy.Tests/Editor/DecompilerViewTests.cs index 82c5417cb..9a8baca1e 100644 --- a/ILSpy.Tests/Editor/DecompilerViewTests.cs +++ b/ILSpy.Tests/Editor/DecompilerViewTests.cs @@ -547,12 +547,12 @@ public class DecompilerViewTests // Switch the language — the buggy path was here. var languageService = AppComposition.Current.GetExport(); - var blockIL = languageService.Languages.OfType() - .Single(l => l.Name == "ILAst"); - languageService.CurrentLanguage = blockIL; + var il = languageService.Languages.OfType() + .Single(l => l.Name == "IL"); + languageService.CurrentLanguage = il; await vm.DockWorkspace.WaitForDecompiledTextAsync(); - TestCapture.Step("ilast-method"); + TestCapture.Step("il-method"); // Force GC to flush any unobserved Task faults that escaped via the dispatcher. System.GC.Collect(); @@ -560,7 +560,7 @@ public class DecompilerViewTests System.GC.Collect(); unobserved.Should().BeNull( - $"C# → ILAst language switch must not leak an unobserved task fault; saw: {unobserved}"); + $"C# → IL language switch must not leak an unobserved task fault; saw: {unobserved}"); } finally { diff --git a/ILSpy.Tests/Views/DebugStepsTests.cs b/ILSpy.Tests/Views/DebugStepsTests.cs index 251201477..09df46b9f 100644 --- a/ILSpy.Tests/Views/DebugStepsTests.cs +++ b/ILSpy.Tests/Views/DebugStepsTests.cs @@ -73,7 +73,7 @@ public class DebugStepsTests // The default writing options match the WPF baseline: field sugar and // logic-operation sugar on; IL ranges and child-index-in-block off. The four // CheckBoxes in DebugSteps.axaml bind two-way against these defaults. - var options = DebugStepsPaneModel.WritingOptions; + var options = AppComposition.Current.GetExport().WritingOptions; options.UseFieldSugar.Should().BeTrue(); options.UseLogicOperationSugar.Should().BeTrue(); options.ShowILRanges.Should().BeFalse(); @@ -82,19 +82,21 @@ public class DebugStepsTests } [AvaloniaTest] - public async Task Debug_Steps_VM_Populates_After_ILAst_Decompile_Regardless_Of_View_Lifecycle() + public async Task Debug_Steps_VM_Populates_After_Decompile_Regardless_Of_View_Lifecycle() { // End-to-end repro of the user-reported "Debug Steps pane is empty" bug: // 1. Boot the window, load assemblies. // 2. Select a method. - // 3. Switch the active language to BlockIL (ILAst). - // 4. Wait for the BlockIL decompile to finish — its OnStepperUpdated event fires. - // 5. Assert the DebugStepsPaneModel's Steps property is populated. + // 3. Wait for the decompile to finish — its OnStepperUpdated event fires. + // 4. Assert the DebugStepsPaneModel's Steps property is populated. // // Asserting against the VM (not the View) decouples this test from the dock layout's // view-realisation timing — which is the whole point of the fix that moved state // from the View into the VM. If `Steps` is populated, any view that binds to it (now // or later) will render the correct content. + // + // Recording is what produces steps at all, and no view is realized to switch it on here. + AppComposition.Current.GetExport().SetRecordingEnabled(true); var window = AppComposition.Current.GetExport(); window.Show(); @@ -110,26 +112,26 @@ public class DebugStepsTests await vm.DockWorkspace.WaitForDecompiledTextAsync(); var languageService = AppComposition.Current.GetExport(); - var blockIL = languageService.Languages.OfType() - .Single(l => l.Name == "ILAst"); - languageService.CurrentLanguage = blockIL; - await vm.DockWorkspace.WaitForDecompiledTextAsync(); - - blockIL.Stepper.Steps.Should().NotBeEmpty( - "BlockILLanguage.DecompileMethod must populate context.Stepper.Steps when STEP is defined"); + var csharp = languageService.Languages.OfType().Single(); + csharp.Stepper.Steps.Should().NotBeEmpty( + "the C# decompile must populate decompiler.Stepper.Steps when STEP is defined"); var debugStepsVm = AppComposition.Current.GetExport(); await Waiters.WaitForAsync( () => debugStepsVm.Steps?.Count > 0, - description: "DebugStepsPaneModel.Steps to be populated after the ILAst decompile"); + description: "DebugStepsPaneModel.Steps to be populated after the decompile"); debugStepsVm.Steps.Should().NotBeNullOrEmpty( - "after switching to ILAst and decompiling, the VM's Steps must list the stepper's recorded transforms"); + "after decompiling, the VM's Steps must list the stepper's recorded transforms"); } [AvaloniaTest] public async Task CSharp_DebugSteps_Are_Grouped_By_Ast_Transform() { + // Steps exist only while the pane asks for them, and nothing realizes the pane's view here, + // so this test asks the same way the view does. + AppComposition.Current.GetExport().SetRecordingEnabled(true); + var window = AppComposition.Current.GetExport(); window.Show(); var vm = (MainWindowViewModel)window.DataContext!; @@ -152,16 +154,19 @@ public class DebugStepsTests () => debugStepsVm.Steps?.Count > 0, description: "DebugStepsPaneModel.Steps to be populated after the C# decompile"); + var astTransformNames = CSharpDecompiler.GetAstTransforms() .Select(transform => transform.GetType().Name) .ToArray(); + // The AST transforms close the tree, after the member groups of the IL half. debugStepsVm.Steps! .Select(step => StripStepNumber(step.Description)) - .Should().Equal(astTransformNames, - "C# debug steps must use AST transforms as top-level groups"); + .Should().EndWith(astTransformNames, + "C# debug steps must use AST transforms as the top-level groups of the C# half"); var transformGroupWithChanges = debugStepsVm.Steps! + .Where(step => astTransformNames.Contains(StripStepNumber(step.Description))) .FirstOrDefault(step => step.Children.Count > 0); transformGroupWithChanges.Should().NotBeNull( "individual C# AST mutation steps must be nested under their transform group"); @@ -186,16 +191,177 @@ public class DebugStepsTests AssertPreciseHighlight(tab, "C# replay after a selected AST mutation step must locate the changed node"); debugStepsVm.Steps.Should().BeSameAs(collectedSteps, "a step-limited C# replay must preserve the current full-run step tree and selection context"); + } + + [AvaloniaTest] + public async Task IL_Steps_Are_Recorded_Only_While_The_Pane_Asks_For_Them() + { + // Every retained step pins the ILAst it captured, which for one type runs to tens of thousands + // of nodes. A closed pane displays none of them, so the decompile must not collect any - not + // the IL transforms, and not the C# AST transforms either: a run that records half a pipeline + // would number its steps on a scale no displayed tree was recorded on. + var debugStepsVm = AppComposition.Current.GetExport(); + debugStepsVm.SetRecordingEnabled(false); + + var window = AppComposition.Current.GetExport(); + window.Show(); + var vm = (MainWindowViewModel)window.DataContext!; + await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3); + + var languageService = AppComposition.Current.GetExport(); + var csharp = languageService.Languages.OfType().Single(); + languageService.CurrentLanguage = csharp; + + var typeNode = vm.AssemblyTreeModel.FindNode( + "System.Linq", "System.Linq", "System.Linq.Enumerable"); + typeNode.IsExpanded = true; + var method = typeNode.Children.OfType() + .First(m => m.MethodDefinition.Name == "Range"); + vm.AssemblyTreeModel.SelectNode(method); + await vm.DockWorkspace.WaitForDecompiledTextAsync(); + + var ilTransformNames = CSharpDecompiler.GetILTransforms() + .Select(transform => transform.GetType().Name) + .ToHashSet(); + ICSharpCode.Decompiler.Util.TreeTraversal.PreOrder(csharp.Stepper.Steps, n => n.Children).Select(n => n.Description).Should().NotContain( + description => ilTransformNames.Contains(StripStepNumber(description)), + "a closed pane leaves the IL transforms unrecorded"); + csharp.Stepper.Steps.Should().BeEmpty("a closed pane leaves the whole pipeline unrecorded"); + } + + [AvaloniaTest] + public async Task Closing_The_Pane_Releases_The_Steps_Even_When_Another_Language_Is_Current() + { + // The steps are pinned on the MEF-shared CSharpLanguage, not on whichever language happens to + // be selected. Open the pane on C#, switch to IL, close the pane: the release has to reach the + // C# language anyway, or its whole IL tree stays alive until the next full C# run. + var window = AppComposition.Current.GetExport(); + window.Show(); + var vm = (MainWindowViewModel)window.DataContext!; + await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3); + + var languageService = AppComposition.Current.GetExport(); + var csharp = languageService.Languages.OfType().Single(); + languageService.CurrentLanguage = csharp; + + var debugStepsVm = AppComposition.Current.GetExport(); + debugStepsVm.SetRecordingEnabled(true); + + var typeNode = vm.AssemblyTreeModel.FindNode( + "System.Linq", "System.Linq", "System.Linq.Enumerable"); + typeNode.IsExpanded = true; + var method = typeNode.Children.OfType() + .First(m => m.MethodDefinition.Name == "Range"); + vm.AssemblyTreeModel.SelectNode(method); + await vm.DockWorkspace.WaitForDecompiledTextAsync(); + await Waiters.WaitForAsync( + () => debugStepsVm.Steps?.Count > 0, + description: "DebugStepsPaneModel.Steps to be populated after the C# decompile"); + + languageService.CurrentLanguage = languageService.GetLanguage("IL"); + Dispatcher.UIThread.RunJobs(); + + debugStepsVm.SetRecordingEnabled(false); + + csharp.Stepper.Steps.Should().BeEmpty( + "closing the pane must release the recorded steps whatever language is selected"); + } + + [AvaloniaTest] + public async Task Opening_The_Pane_Does_Not_Redecompile_A_Language_That_Records_Nothing() + { + // Only the C# language records steps, so re-running any other language's decompile buys + // nothing and throws away the view the user is looking at. + var window = AppComposition.Current.GetExport(); + window.Show(); + var vm = (MainWindowViewModel)window.DataContext!; + await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3); + + var debugStepsVm = AppComposition.Current.GetExport(); + debugStepsVm.SetRecordingEnabled(false); + + var languageService = AppComposition.Current.GetExport(); + languageService.CurrentLanguage = languageService.GetLanguage("IL"); + Dispatcher.UIThread.RunJobs(); + + var typeNode = vm.AssemblyTreeModel.FindNode( + "System.Linq", "System.Linq", "System.Linq.Enumerable"); + typeNode.IsExpanded = true; + var method = typeNode.Children.OfType() + .First(m => m.MethodDefinition.Name == "Range"); + vm.AssemblyTreeModel.SelectNode(method); + await vm.DockWorkspace.WaitForDecompiledTextAsync(); + + var tab = vm.DockWorkspace.ActiveDecompilerTab!; + tab.IsDecompiling.Should().BeFalse("the IL decompile the test awaited has finished"); + + debugStepsVm.SetRecordingEnabled(true); + + tab.IsDecompiling.Should().BeFalse( + "opening the pane on a language that records no steps must leave the tab alone"); + } + + [AvaloniaTest] + public async Task A_Run_Finishing_After_The_Pane_Closed_Does_Not_Repin_Its_Steps() + { + // Closing the pane drops the tree, but the language still raises StepperUpdated at the end of + // every full run. Taking that update would pin the tree straight back into a pane nobody is + // looking at - which is the retention closing was meant to end. + var window = AppComposition.Current.GetExport(); + window.Show(); + var vm = (MainWindowViewModel)window.DataContext!; + await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3); - static string StripStepNumber(string description) + var languageService = AppComposition.Current.GetExport(); + var csharp = languageService.Languages.OfType().Single(); + languageService.CurrentLanguage = csharp; + + var debugStepsVm = AppComposition.Current.GetExport(); + debugStepsVm.SetRecordingEnabled(true); + + var typeNode = vm.AssemblyTreeModel.FindNode( + "System.Linq", "System.Linq", "System.Linq.Enumerable"); + typeNode.IsExpanded = true; + var first = typeNode.Children.OfType() + .First(m => m.MethodDefinition.Name == "Range"); + vm.AssemblyTreeModel.SelectNode(first); + await vm.DockWorkspace.WaitForDecompiledTextAsync(); + await Waiters.WaitForAsync( + () => debugStepsVm.Steps?.Count > 0, + description: "DebugStepsPaneModel.Steps to be populated after the C# decompile"); + + debugStepsVm.SetRecordingEnabled(false); + debugStepsVm.Steps.Should().BeNull("closing the pane drops the tree it was showing"); + + // A later full run raises StepperUpdated exactly the way the in-flight one would have. + var second = typeNode.Children.OfType() + .First(m => m.MethodDefinition.Name == "AsEnumerable"); + vm.AssemblyTreeModel.SelectNode(second); + await vm.DockWorkspace.WaitForDecompiledTextAsync(); + Dispatcher.UIThread.RunJobs(); + + debugStepsVm.Steps.Should().BeNull( + "a run finishing while the pane is closed must not pin its steps back into it"); + } + + [AvaloniaTest] + public async Task CSharp_DebugSteps_Cover_IL_Transforms_And_Replay_Renders_ILAst() + { + // Recording the IL half is what an open pane switches on; nothing realizes the pane's view + // here, so this test asks for it the same way the view does. + var debugStepsVm = AppComposition.Current.GetExport(); + debugStepsVm.SetRecordingEnabled(true); + try + { + await CoverILTransformsAndReplay(); + } + finally { - var separatorIndex = description.IndexOf(": ", StringComparison.Ordinal); - return separatorIndex >= 0 ? description[(separatorIndex + 2)..] : description; + debugStepsVm.SetRecordingEnabled(false); } } - [AvaloniaTest] - public async Task ILAst_DebugStep_Replay_Highlights_Changed_Instruction() + static async Task CoverILTransformsAndReplay() { var window = AppComposition.Current.GetExport(); window.Show(); @@ -203,8 +369,8 @@ public class DebugStepsTests await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3); var languageService = AppComposition.Current.GetExport(); - var blockIL = languageService.Languages.OfType().Single(l => l.Name == "ILAst"); - languageService.CurrentLanguage = blockIL; + var csharp = languageService.Languages.OfType().Single(); + languageService.CurrentLanguage = csharp; var typeNode = vm.AssemblyTreeModel.FindNode( "System.Linq", "System.Linq", "System.Linq.Enumerable"); @@ -217,25 +383,49 @@ public class DebugStepsTests var debugStepsVm = AppComposition.Current.GetExport(); await Waiters.WaitForAsync( () => debugStepsVm.Steps?.Count > 0, - description: "DebugStepsPaneModel.Steps to be populated after the ILAst decompile"); + description: "DebugStepsPaneModel.Steps to be populated after the C# decompile"); + + // The C# step tree spans the whole pipeline, so its top level is every decompiled member's IL + // group, in order, followed by the C# AST transforms - and the IL transforms sit inside the + // group of the member they transformed. + var ilTransformNames = CSharpDecompiler.GetILTransforms() + .Select(transform => transform.GetType().Name) + .ToHashSet(); + var memberGroup = debugStepsVm.Steps! + .FirstOrDefault(step => step.Children.Any(child => ilTransformNames.Contains(StripStepNumber(child.Description)))); + memberGroup.Should().NotBeNull("the C# step tree must group each member's IL transforms"); + + var astTransformNames = CSharpDecompiler.GetAstTransforms() + .Select(transform => transform.GetType().Name) + .ToArray(); + var topLevel = debugStepsVm.Steps!.Select(step => StripStepNumber(step.Description)).ToArray(); + topLevel.Should().EndWith(astTransformNames, "the C# AST transforms close the step tree"); + var beforeTransforms = topLevel.Take(topLevel.Length - astTransformNames.Length).ToArray(); + beforeTransforms.Should().EndWith(new[] { "C# AST built from ILAst" }, + "the seam closes the IL half, right before the first AST transform"); + beforeTransforms.SkipLast(1) + .Should().OnlyContain(description => description.Contains("System.Linq.Enumerable"), + "nothing but the decompiled members' groups precedes the seam"); // Replaying an individual mutation step is what surfaces a single IL change; the leaf // step's changed instruction (or a surviving ancestor) must map to a rendered text range. - var replayStep = FirstLeafStep(debugStepsVm.Steps!); - replayStep.Should().NotBeNull("the ILAst stepper must record individual mutation steps"); + var replayStep = FirstLeafStep(memberGroup!.Children); + replayStep.Should().NotBeNull("the IL transforms must record individual mutation steps"); var collectedSteps = debugStepsVm.Steps; var tab = vm.DockWorkspace.ActiveDecompilerTab!; await tab.RestartDecompileWithStepLimit(replayStep!.BeginStep, isDebug: false, replayStep.BeginStep); - tab.Text.Should().NotBeNullOrWhiteSpace("ILAst replay before a selected step must still emit IL"); - AssertPreciseHighlight(tab, "ILAst replay before a selected step must locate the changed instruction"); + tab.Text.Should().NotBeNullOrWhiteSpace("replaying an IL-phase step must still emit output"); + tab.SyntaxExtension.Should().Be(".il", + "an IL-phase step halts before there is any C#, so the editor shows the ILAst it stopped in"); + AssertPreciseHighlight(tab, "an IL-phase replay must locate the changed instruction"); debugStepsVm.Steps.Should().BeSameAs(collectedSteps, - "a step-limited ILAst replay must not replace the full step tree shown by the pane"); + "a step-limited replay must not replace the full step tree shown by the pane"); await tab.RestartDecompileWithStepLimit(replayStep.EndStep, isDebug: false, replayStep.BeginStep); - tab.Text.Should().NotBeNullOrWhiteSpace("ILAst replay after a selected step must still emit IL"); - AssertPreciseHighlight(tab, "ILAst replay after a selected step must locate the changed instruction"); + tab.Text.Should().NotBeNullOrWhiteSpace("replaying the state after an IL-phase step must still emit output"); + AssertPreciseHighlight(tab, "an IL-phase replay must locate the changed instruction"); // The first leaf step that acts on a concrete instruction; a step whose Position is null // (e.g. an empty transform group) has nothing to highlight and is not what a user replays. @@ -258,17 +448,23 @@ public class DebugStepsTests } [AvaloniaTest] - public Task ILAst_And_TypedIL_Languages_Are_Registered_In_Debug_Builds() + public Task Stepping_And_Language_Version_Selection_Coexist_On_The_CSharp_Language() { - // Two ILAstLanguage subclasses: BlockILLanguage ("ILAst") drives the stepper, - // TypedILLanguage ("Typed IL") writes type-annotated raw IL without transforms. - // Both register via [Export(typeof(Language))]; the language picker uses them - // in addition to C# and the disassembler-IL language. + // Stepping belongs to the C# language, which offers language versions: selecting a version and + // walking the pipeline have to work at the same time, on the same language. var languageService = AppComposition.Current.GetExport(); - languageService.Languages.OfType().Should().HaveCount(2, - "both BlockIL and TypedIL must be registered when DEBUG is defined"); - languageService.Languages.Should().Contain(l => l.Name == "ILAst"); - languageService.Languages.Should().Contain(l => l.Name == "Typed IL"); + languageService.Languages.Should().NotContain(l => l.Name == "ILAst", + "no language runs the IL pipeline: it is walked in the Debug Steps pane instead"); + languageService.Languages.Should().Contain(l => l.Name == "Typed IL", + "Typed IL is a raw-IL rendering, not a pipeline stage, and stays its own debug language"); + + var csharp = languageService.Languages.OfType().Single(); + languageService.CurrentLanguage = csharp; + csharp.HasLanguageVersions.Should().BeTrue(); + + var debugStepsVm = AppComposition.Current.GetExport(); + debugStepsVm.IsAvailable.Should().BeTrue( + "the language that offers version selection is also the one that records steps"); return Task.CompletedTask; } @@ -376,9 +572,9 @@ public class DebugStepsTests [AvaloniaTest] public Task DebugSteps_View_Loads_With_Filter_Applied() { - // Guards the filter wiring in the XAML -- the per-row style Setter bindings -- against a - // structural break that x:CompileBindings="False" would not catch at build time. - // Realising the view with a populated tree and a live filter must not throw. + // Guards the filter wiring in the XAML -- the per-row style Setter bindings, which apply to + // containers the compiler never sees typed. Realising the view with a populated tree and a + // live filter must not throw. var vm = new DebugStepsPaneModel(); var group = new Stepper.Node("CombineQueryExpressions"); group.Children.Add(new Stepper.Node("3: Introduce query continuation")); @@ -394,6 +590,43 @@ public class DebugStepsTests return Task.CompletedTask; } + [AvaloniaTest] + public Task Writing_Option_CheckBoxes_Are_Bound_To_The_ILAst_Writing_Options() + { + // The checkboxes drive how a halted IL step's ILAst is rendered, through the options the pane + // owns: what the compiler cannot check is that the two-way path reaches the pane's own instance + // rather than a copy, and a dead binding here is a dead feature. + var vm = new DebugStepsPaneModel { IsAvailable = true }; + var window = new Window { Width = 500, Height = 300, Content = new DebugSteps { DataContext = vm } }; + window.Show(); + Dispatcher.UIThread.RunJobs(); + try + { + var fieldSugar = window.GetVisualDescendants().OfType() + .Single(box => (box.Content as string) == "Field sugar"); + fieldSugar.IsChecked.Should().Be(vm.WritingOptions.UseFieldSugar, + "the checkbox must show the current writing option"); + + fieldSugar.IsChecked = false; + Dispatcher.UIThread.RunJobs(); + vm.WritingOptions.UseFieldSugar.Should().BeFalse( + "toggling the checkbox must reach the options the ILAst dump is written with"); + } + finally + { + window.Close(); + } + return Task.CompletedTask; + } + + // Step descriptions are prefixed with their index ("42: Foo"); the name behind it is what + // identifies the transform that recorded them. + static string StripStepNumber(string description) + { + var separatorIndex = description.IndexOf(": ", StringComparison.Ordinal); + return separatorIndex >= 0 ? description[(separatorIndex + 2)..] : description; + } + // A replay highlight must land on the changed node, not merely be non-null: in bounds, not a // flood of the whole document, and (unless it is a zero-length removal caret) on rendered code // rather than whitespace. This is what keeps the ancestor fallback from silently widening every @@ -474,7 +707,7 @@ public class DebugStepsTests [AvaloniaTest] public Task Pane_Reports_Not_Available_For_Languages_Without_Debug_Steps() { - // The step tree only makes sense for IDebugStepProvider languages (C#, ILAst, Typed IL). + // The step tree only makes sense for the C# language, whose pipeline records it. // For the plain IL disassembler the pane must not keep showing the previous language's // stale step tree (whose commands would trigger pointless re-decompiles); it reports // unavailability so the view swaps in a "not available" message instead. diff --git a/ILSpy/DecompilationOptions.cs b/ILSpy/DecompilationOptions.cs index 27bb43af2..3db61bb38 100644 --- a/ILSpy/DecompilationOptions.cs +++ b/ILSpy/DecompilationOptions.cs @@ -54,10 +54,12 @@ namespace ICSharpCode.ILSpy public string? StrongNameKeyFile { get; set; } /// - /// Stop the IL-transform pipeline after this many steps. - /// means "run all transforms". The Debug Steps pane sets this to the index of a - /// chosen step so it can show the partial state at that point. Honoured by - /// ; ignored by every other language. + /// Stop the decompiler pipeline after this many steps. means "run + /// everything". The Debug Steps pane sets this to the index of a chosen step so it can show the + /// partial state at that point. Honoured by the C# language, whose steps span the IL transforms, + /// the ILAst-to-C# conversion and the C# AST transforms; ignored by every other language. + /// A limit landing in the IL phase yields an ILAst dump rather than C#, because the halted + /// member never reaches the C# builders. /// public int StepLimit { get; set; } = int.MaxValue; @@ -69,12 +71,21 @@ namespace ICSharpCode.ILSpy public int? HighlightStep { get; set; } /// - /// When true, transforms emit verbose debug information about their behaviour. Only - /// meaningful in combination with — the Debug Steps pane sets - /// it on the "Debug this step" context-menu action. + /// When true, the pipeline breaks into an attached debugger on reaching + /// and then carries on, instead of halting there. Only meaningful in combination with + /// — the Debug Steps pane sets it on the "Debug this step" + /// context-menu action. /// public bool IsDebug { get; set; } + /// + /// When true, the pipeline records its steps so a step view can display them. It travels with + /// the request rather than being read from the view: a step index only means anything against + /// a run recorded the same way, so the full run and the step-limited replay of one of its + /// indices have to agree on it, and the decompile that reads it happens on a background task. + /// + public bool RecordSteps { get; set; } + /// /// Optional sink for whole-project decompilation progress. Wired onto /// diff --git a/ILSpy/Docking/DockWorkspace.cs b/ILSpy/Docking/DockWorkspace.cs index ca88b3367..3e32ee93f 100644 --- a/ILSpy/Docking/DockWorkspace.cs +++ b/ILSpy/Docking/DockWorkspace.cs @@ -84,6 +84,15 @@ namespace ICSharpCode.ILSpy.Docking /// behaviour (e.g. ShowOptionsCommand). public IDocumentDock? Documents => factory.Documents; + /// + /// Whether decompiles started in this workspace record their transform steps. Set on the UI + /// thread by whoever displays them and copied into each run's , + /// so a background decompile never samples live view state - and every tab, including ones + /// opened later, records the same way. A step index only means anything against a run + /// recorded like the one the index was taken from. + /// + public bool RecordSteps { get; set; } + public IRelayCommand NavigateBackCommand { get; } public IRelayCommand NavigateForwardCommand { get; } public IRelayCommand NavigateToHistoryCommand { get; } diff --git a/ILSpy/Languages/CSharpLanguage.DebugSteps.cs b/ILSpy/Languages/CSharpLanguage.DebugSteps.cs index 6d350cee4..bb8700dcf 100644 --- a/ILSpy/Languages/CSharpLanguage.DebugSteps.cs +++ b/ILSpy/Languages/CSharpLanguage.DebugSteps.cs @@ -23,6 +23,7 @@ using System; using ICSharpCode.Decompiler; using ICSharpCode.Decompiler.CSharp; using ICSharpCode.Decompiler.DebugSteps; +using ICSharpCode.Decompiler.IL; using ICSharpCode.ILSpy.AppEnv; using ICSharpCode.ILSpy.Docking; @@ -32,12 +33,13 @@ using ICSharpCode.ILSpy.ViewModels; namespace ICSharpCode.ILSpy.Languages { /// - /// Debug Steps support for the C# language, shown in the Debug Steps pane like the ILAst - /// language already does for IL transforms. A full decompile records AST transform groups with - /// individual mutation steps inside; a selected step's index is replayed by re-decompiling with . + /// Debug Steps support for the C# language: the pipeline the pane walks. A full decompile records + /// the IL transforms of each member, the ILAst-to-C# seam, and the C# AST transforms into one + /// ; a selected step's index is replayed by re-decompiling with + /// . A step that halts the IL phase leaves no C# to + /// print, so the replay renders the halted ILAst instead - see . /// - partial class CSharpLanguage : IDebugStepProvider + partial class CSharpLanguage { Stepper stepper = new Stepper(); @@ -45,13 +47,48 @@ namespace ICSharpCode.ILSpy.Languages public event EventHandler? StepperUpdated; - // The C# AST step view has no options of its own (yet); the pane shows nothing above the - // tree for C#, unlike ILAst's writing-options checkboxes. - public object? StepOptions => null; + /// + /// Writes the ILAst the IL transforms were halted in, in place of the C# the caller would + /// otherwise print. The step limit stops the pipeline before that member ever reaches the C# + /// builders, so there is nothing else worth showing. + /// When the halt lands mid-type, the whole document becomes that one member's ILAst and the C# + /// of the members already decompiled is dropped: one document in one language keeps the + /// highlighting unambiguous, and which member the limit lands in follows decompilation order, + /// not the user's selection. + /// + private static partial bool TryWriteILAst(ITextOutput output, DecompilationOptions options, CSharpDecompiler decompiler) + { + if (decompiler.StepLimitHaltedFunction is not { } function) + return false; + if (output is AvaloniaEditTextOutput avaloniaOutput) + { + // The dump is IL, not C#; without this the editor would highlight it as C#. + avaloniaOutput.SyntaxExtensionOverride = ".il"; + } + output.WriteLine(); + function.WriteTo(output, DebugStepsPane?.WritingOptions ?? new ILAstWritingOptions()); + return true; + } + + /// + /// The Debug Steps pane, or null before composition is up (design-time previews) and in the + /// window between startup and the pane being created. It is a [Shared] export, so a decompile + /// running on a background task resolves the same instance the view is bound to. + /// + static DebugStepsPaneModel? DebugStepsPane => AppComposition.TryGetExport(); + + /// + /// Drops the recorded steps of the last run. The stepper pins every ILAst its steps captured, so + /// this is what actually releases that memory once nothing is displaying it. + /// + internal void ReleaseSteps() + { + stepper = new Stepper(); + } partial void OnCSharpDecompiled(CSharpDecompiler decompiler, ITextOutput output, DecompilationOptions options) { - // The button always shows so the pane is one click away; mirrors the ILAst language. + // The button always shows so the pane is one click away. // DockWorkspace is resolved lazily (an ImportingConstructor import would form a MEF // cycle via LanguageService -> Languages). (output as ISmartTextOutput)?.AddButton(Images.ViewCode, "Show Steps", delegate { diff --git a/ILSpy/Languages/CSharpLanguage.cs b/ILSpy/Languages/CSharpLanguage.cs index 2be32de77..10a46c120 100644 --- a/ILSpy/Languages/CSharpLanguage.cs +++ b/ILSpy/Languages/CSharpLanguage.cs @@ -327,6 +327,10 @@ namespace ICSharpCode.ILSpy.Languages }; decompiler.Stepper.StepLimit = options.StepLimit; decompiler.Stepper.IsDebug = options.IsDebug; + // The Debug Steps pane walks the whole pipeline, so the IL transforms record into the same + // stepper as the C# AST transforms. Which runs record is decided by whoever started the + // decompile, not by the pane's state at the moment this runs on a background task. + decompiler.RecordSteps = options.RecordSteps; if (options.EscapeInvalidIdentifiers) decompiler.AstTransforms.Add(new EscapeInvalidIdentifiers()); return decompiler; @@ -354,11 +358,11 @@ namespace ICSharpCode.ILSpy.Languages { var members = CollectFieldsAndCtors(methodDefinition.DeclaringTypeDefinition!, methodDefinition.IsStatic); decompiler.AstTransforms.Add(new SelectCtorTransform(methodDefinition)); - WriteCode(output, options, decompiler.Decompile(members), decompiler.Stepper); + WriteCode(output, options, decompiler.Decompile(members), decompiler); } else { - WriteCode(output, options, decompiler.Decompile(method.MetadataToken), decompiler.Stepper); + WriteCode(output, options, decompiler.Decompile(method.MetadataToken), decompiler); } OnCSharpDecompiled(decompiler, output, options); } @@ -371,7 +375,7 @@ namespace ICSharpCode.ILSpy.Languages { CSharpDecompiler decompiler = BeginDecompile(property, output, options); WriteCommentLine(output, TypeToString(property.DeclaringType)); - WriteCode(output, options, decompiler.Decompile(property.MetadataToken), decompiler.Stepper); + WriteCode(output, options, decompiler.Decompile(property.MetadataToken), decompiler); OnCSharpDecompiled(decompiler, output, options); } @@ -381,14 +385,14 @@ namespace ICSharpCode.ILSpy.Languages WriteCommentLine(output, TypeToString(field.DeclaringType)); if (field.IsConst) { - WriteCode(output, options, decompiler.Decompile(field.MetadataToken), decompiler.Stepper); + WriteCode(output, options, decompiler.Decompile(field.MetadataToken), decompiler); } else { var members = CollectFieldsAndCtors(field.DeclaringTypeDefinition!, field.IsStatic); var resolvedField = decompiler.TypeSystem.MainModule.GetDefinition((FieldDefinitionHandle)field.MetadataToken); decompiler.AstTransforms.Add(new SelectFieldTransform(resolvedField)); - WriteCode(output, options, decompiler.Decompile(members), decompiler.Stepper); + WriteCode(output, options, decompiler.Decompile(members), decompiler); } OnCSharpDecompiled(decompiler, output, options); } @@ -417,7 +421,7 @@ namespace ICSharpCode.ILSpy.Languages CSharpDecompiler decompiler = BeginDecompile(extension, output, options); WriteCommentLine(output, TypeToString(commentType, ConversionFlags.UseFullyQualifiedTypeNames | ConversionFlags.UseFullyQualifiedEntityNames | ConversionFlags.SupportExtensionDeclarations)); - WriteCode(output, options, decompiler.DecompileExtension(extension.MetadataToken), decompiler.Stepper); + WriteCode(output, options, decompiler.DecompileExtension(extension.MetadataToken), decompiler); OnCSharpDecompiled(decompiler, output, options); } @@ -425,7 +429,7 @@ namespace ICSharpCode.ILSpy.Languages { CSharpDecompiler decompiler = BeginDecompile(ev, output, options); WriteCommentLine(output, TypeToString(ev.DeclaringType)); - WriteCode(output, options, decompiler.Decompile(ev.MetadataToken), decompiler.Stepper); + WriteCode(output, options, decompiler.Decompile(ev.MetadataToken), decompiler); OnCSharpDecompiled(decompiler, output, options); } @@ -433,7 +437,7 @@ namespace ICSharpCode.ILSpy.Languages { CSharpDecompiler decompiler = BeginDecompile(type, output, options); WriteCommentLine(output, TypeToString(type, ConversionFlags.UseFullyQualifiedTypeNames | ConversionFlags.UseFullyQualifiedEntityNames)); - WriteCode(output, options, decompiler.Decompile(type.MetadataToken), decompiler.Stepper); + WriteCode(output, options, decompiler.Decompile(type.MetadataToken), decompiler); OnCSharpDecompiled(decompiler, output, options); } @@ -520,7 +524,7 @@ namespace ICSharpCode.ILSpy.Languages SyntaxTree st = options.FullDecompilation ? decompiler.DecompileWholeModuleAsSingleFile() : decompiler.DecompileModuleAndAssemblyAttributes(); - WriteCode(output, options, st, decompiler.Stepper); + WriteCode(output, options, st, decompiler); return null; } @@ -732,38 +736,54 @@ namespace ICSharpCode.ILSpy.Languages } } - static void WriteCode(ITextOutput output, DecompilationOptions options, SyntaxTree syntaxTree, Stepper stepper) + static void WriteCode(ITextOutput output, DecompilationOptions options, SyntaxTree syntaxTree, CSharpDecompiler decompiler) { - var settings = options.DecompilerSettings; - syntaxTree.AcceptVisitor(new InsertParenthesesVisitor { InsertParenthesesForReadability = true }); - output.IndentationString = settings.CSharpFormattingOptions.IndentationString; - TokenWriter tokenWriter = new TextTokenWriter(output, settings); - // Node-range tracking (NodeLookup) is only consumed by the debug-step highlighter, which - // resolves nothing without a step limit. Skip it on a normal decompile so the common path - // doesn't pay the per-node/per-annotation bookkeeping; AvaloniaEditTextOutput is an - // ISmartTextOutput, so the branch below still gives it full syntax highlighting. - if (output is TextView.AvaloniaEditTextOutput avaloniaOutput && options.StepLimit != int.MaxValue) - tokenWriter = new CSharpHighlightingTokenWriter(tokenWriter, avaloniaOutput); - else if (output is TextView.ISmartTextOutput smartOutput) - tokenWriter = new CSharpHighlightingTokenWriter(tokenWriter, smartOutput); - - // For the on-screen C# view, harvest the IL-offset/line map for body bookmarks during this - // single formatting pass (see Bookmarks.BookmarkDebugInfoCollector). The collector is the - // outermost writer so its StartNode sees each node's start line before any token is written. - // Other outputs (IL view, ilspycmd's plain text) are not AvaloniaEditTextOutput and are unaffected. - Bookmarks.BookmarkDebugInfoCollector? bookmarkCollector = null; - if (output is TextView.AvaloniaEditTextOutput bookmarkOutput) - tokenWriter = bookmarkCollector = new Bookmarks.BookmarkDebugInfoCollector(tokenWriter, bookmarkOutput); - - syntaxTree.AcceptVisitor(new CSharpOutputVisitor(tokenWriter, settings.CSharpFormattingOptions)); - bookmarkCollector?.Publish(); + if (!TryWriteILAst(output, options, decompiler)) + { + var settings = options.DecompilerSettings; + syntaxTree.AcceptVisitor(new InsertParenthesesVisitor { InsertParenthesesForReadability = true }); + output.IndentationString = settings.CSharpFormattingOptions.IndentationString; + TokenWriter tokenWriter = new TextTokenWriter(output, settings); + // Node-range tracking (NodeLookup) is only consumed by the debug-step highlighter, which + // resolves nothing without a step limit. Skip it on a normal decompile so the common path + // doesn't pay the per-node/per-annotation bookkeeping; AvaloniaEditTextOutput is an + // ISmartTextOutput, so the branch below still gives it full syntax highlighting. + if (output is TextView.AvaloniaEditTextOutput avaloniaOutput && options.StepLimit != int.MaxValue) + tokenWriter = new CSharpHighlightingTokenWriter(tokenWriter, avaloniaOutput); + else if (output is TextView.ISmartTextOutput smartOutput) + tokenWriter = new CSharpHighlightingTokenWriter(tokenWriter, smartOutput); + + // For the on-screen C# view, harvest the IL-offset/line map for body bookmarks during this + // single formatting pass (see Bookmarks.BookmarkDebugInfoCollector). The collector is the + // outermost writer so its StartNode sees each node's start line before any token is written. + // Other outputs (IL view, ilspycmd's plain text) are not AvaloniaEditTextOutput and are unaffected. + Bookmarks.BookmarkDebugInfoCollector? bookmarkCollector = null; + if (output is TextView.AvaloniaEditTextOutput bookmarkOutput) + tokenWriter = bookmarkCollector = new Bookmarks.BookmarkDebugInfoCollector(tokenWriter, bookmarkOutput); + + syntaxTree.AcceptVisitor(new CSharpOutputVisitor(tokenWriter, settings.CSharpFormattingOptions)); + bookmarkCollector?.Publish(); + } + // Shared by both renderings: ILInstruction.WriteTo marks its node ranges the same way the + // C# token writer marks the AST's, so the highlighter resolves a step against whichever + // text was just written. if (output is TextView.AvaloniaEditTextOutput nodeOutput - && TextView.DebugStepHighlighter.TryResolve(stepper, options.StepLimit, options.HighlightStep, nodeOutput.NodeLookup, out var range)) + && TextView.DebugStepHighlighter.TryResolve(decompiler.Stepper, options.StepLimit, options.HighlightStep, nodeOutput.NodeLookup, out var range)) { nodeOutput.DebugStepHighlight = range; } } + // Writes the ILAst the pipeline was halted in, when a Debug Steps replay stopped it before + // there was any C# to show, and reports whether it wrote anything. Implemented under DEBUG in + // CSharpLanguage.DebugSteps.cs; in Release nothing sets a step limit, so it never writes. + private static partial bool TryWriteILAst(ITextOutput output, DecompilationOptions options, CSharpDecompiler decompiler); + +#if !DEBUG + private static partial bool TryWriteILAst(ITextOutput output, DecompilationOptions options, CSharpDecompiler decompiler) + => false; +#endif + void AddWarningMessage(MetadataFile module, ITextOutput output, string line1, string? line2 = null, string? buttonText = null, global::Avalonia.Media.IImage? buttonImage = null, System.EventHandler? buttonClickHandler = null) diff --git a/ILSpy/Languages/IDebugStepProvider.cs b/ILSpy/Languages/IDebugStepProvider.cs deleted file mode 100644 index 465b85bc4..000000000 --- a/ILSpy/Languages/IDebugStepProvider.cs +++ /dev/null @@ -1,52 +0,0 @@ -// 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. - -#if DEBUG - -using System; - -using ICSharpCode.Decompiler.DebugSteps; - -namespace ICSharpCode.ILSpy.Languages -{ - /// - /// A language that surfaces a of decompiler-pipeline steps for the - /// Debug Steps pane to visualise. Implemented by (one node per IL - /// transform step) and (one node per C# AST transform). The pane - /// binds to whichever current language is an , so it no longer has to - /// know the concrete language type. - /// - internal interface IDebugStepProvider - { - /// The step tree produced by the most recent full decompile. - Stepper Stepper { get; } - - /// Raised after a full (non step-limited) decompile refreshes . - event EventHandler? StepperUpdated; - - /// - /// Language-specific options object the Debug Steps pane renders above the step tree - /// (e.g. ILAst's writing-options checkboxes), or when the language - /// has no step options. The pane picks a template by the object's runtime type, so each - /// language contributes its own controls. - /// - object? StepOptions { get; } - } -} - -#endif diff --git a/ILSpy/Languages/ILAstLanguage.cs b/ILSpy/Languages/ILAstLanguage.cs deleted file mode 100644 index fe4ff0dde..000000000 --- a/ILSpy/Languages/ILAstLanguage.cs +++ /dev/null @@ -1,201 +0,0 @@ -// 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. - -#if DEBUG - -using System; -using System.Collections.Generic; -using System.Composition; - -using ICSharpCode.Decompiler; -using ICSharpCode.Decompiler.CSharp; -using ICSharpCode.Decompiler.DebugSteps; -using ICSharpCode.Decompiler.Disassembler; -using ICSharpCode.Decompiler.IL; -using ICSharpCode.Decompiler.IL.Transforms; -using ICSharpCode.Decompiler.Metadata; -using ICSharpCode.Decompiler.TypeSystem; -using ICSharpCode.ILSpyX; - -using ICSharpCode.ILSpy.AppEnv; -using ICSharpCode.ILSpy.Docking; -using ICSharpCode.ILSpy.TextView; -using ICSharpCode.ILSpy.ViewModels; - -using SRM = System.Reflection.Metadata; - -namespace ICSharpCode.ILSpy.Languages -{ - /// - /// Debug-only language that surfaces the decompiler pipeline's intermediate state. - /// Two concrete variants ship: renders raw IL with type - /// annotations; runs the decompiler's IL transforms with a - /// attached so the Debug Steps pane can replay each transform. - /// Compiled only when DEBUG is defined — the language list is identical to Release - /// otherwise. - /// - public abstract class ILAstLanguage : Language, IDebugStepProvider - { - readonly string name; - - protected ILAstLanguage(string name) - { - this.name = name; - } - - // ILAst output uses the same `{}/()/[]` bracket conventions as C#, plus C#-style - // `//` comments and `"..."` strings. Reusing CSharpBracketSearcher gives the - // language correct bracket highlighting without a per-grammar implementation. - public override ICSharpCode.ILSpy.TextView.IBracketSearcher BracketSearcher { get; } = new CSharpBracketSearcher(); - - /// - /// Fires after a run installs a fresh . - /// The Debug Steps pane subscribes here so it can rebind its TreeView to the new - /// step list whenever the user reruns decompilation. - /// - public event EventHandler? StepperUpdated; - - protected virtual void OnStepperUpdated(EventArgs? e = null) - => StepperUpdated?.Invoke(this, e ?? EventArgs.Empty); - - public Stepper Stepper { get; set; } = new(); - - // ILAst contributes the shared writing-options checkboxes to the Debug Steps pane. - public object? StepOptions => DebugStepsPaneModel.WritingOptions; - - public override string Name => name; - - public override string FileExtension => ".il"; - - public override void DecompileMethod(IMethod method, ITextOutput output, DecompilationOptions options) - { - base.DecompileMethod(method, output, options); - new ReflectionDisassembler(output, options.CancellationToken) - .DisassembleMethodHeader(method.ParentModule!.MetadataFile, (SRM.MethodDefinitionHandle)method.MetadataToken); - output.WriteLine(); - output.WriteLine(); - } - } - - /// - /// Raw IL with type annotations on each instruction. No transforms, no stepper — the - /// Debug Steps pane stays empty under this language because there's nothing to step - /// through. Useful as a sanity-check view for the IL reader itself. - /// - [Export(typeof(Language))] - [Shared] - public sealed class TypedILLanguage : ILAstLanguage - { - public TypedILLanguage() : base("Typed IL") { } - - public override void DecompileMethod(IMethod method, ITextOutput output, DecompilationOptions options) - { - base.DecompileMethod(method, output, options); - var module = method.ParentModule!.MetadataFile!; - var methodDef = module.Metadata.GetMethodDefinition((SRM.MethodDefinitionHandle)method.MetadataToken); - if (!methodDef.HasBody()) - return; - var typeSystem = new DecompilerTypeSystem(module, module.GetAssemblyResolver()); - var reader = new ILReader(typeSystem.MainModule); - var methodBody = module.GetMethodBody(methodDef.RelativeVirtualAddress); - reader.WriteTypedIL((SRM.MethodDefinitionHandle)method.MetadataToken, methodBody, output, cancellationToken: options.CancellationToken); - } - } - - /// - /// Runs the full C# decompiler's IL transforms and writes the resulting . - /// Each transform is recorded by a hooked into the - /// ; the resulting step tree is what the Debug Steps - /// pane visualises. The "Show Steps" button on the output reveals the pane. - /// - [Export(typeof(Language))] - [Shared] - public sealed class BlockILLanguage : ILAstLanguage - { - readonly IReadOnlyList transforms; - - public BlockILLanguage() : base("ILAst") - { - this.transforms = CSharpDecompiler.GetILTransforms(); - } - - public override void DecompileMethod(IMethod method, ITextOutput output, DecompilationOptions options) - { - base.DecompileMethod(method, output, options); - var module = method.ParentModule!.MetadataFile!; - var metadata = module.Metadata; - var methodDef = metadata.GetMethodDefinition((SRM.MethodDefinitionHandle)method.MetadataToken); - if (!methodDef.HasBody()) - return; - IAssemblyResolver assemblyResolver = module.GetAssemblyResolver(); - var typeSystem = new DecompilerTypeSystem(module, assemblyResolver); - var reader = new ILReader(typeSystem.MainModule) { - UseDebugSymbols = options.DecompilerSettings.UseDebugSymbols, - UseRefLocalsForAccurateOrderOfEvaluation = options.DecompilerSettings.UseRefLocalsForAccurateOrderOfEvaluation, - }; - var methodBody = module.GetMethodBody(methodDef.RelativeVirtualAddress); - ILFunction il = reader.ReadIL((SRM.MethodDefinitionHandle)method.MetadataToken, methodBody, - kind: ILFunctionKind.TopLevelFunction, cancellationToken: options.CancellationToken); - var decompiler = new CSharpDecompiler(typeSystem, options.DecompilerSettings) { CancellationToken = options.CancellationToken }; - ILTransformContext context = decompiler.CreateILTransformContext(il); - context.Stepper.StepLimit = options.StepLimit; - context.Stepper.IsDebug = options.IsDebug; - try - { - il.RunTransforms(transforms, context); - } - catch (StepLimitReachedException) - { - // Expected when the Debug Steps pane asked to halt after a specific step. - } - catch (Exception ex) - { - output.WriteLine(ex.ToString()); - output.WriteLine(); - output.WriteLine("ILAst after the crash:"); - } - finally - { - // Capture the populated stepper even when a transform crashed, so the user - // can see the partial step tree leading up to the failure point. Only when - // the run was full-fidelity (no StepLimit) — partial runs leave the stepper - // alone so the previously-shown tree stays visible. - if (options.StepLimit == int.MaxValue) - { - Stepper = context.Stepper; - OnStepperUpdated(); - } - } - // DockWorkspace is resolved lazily here, not via [ImportingConstructor]: it imports - // LanguageService, which imports the registered Languages, so a constructor import would - // form a composition cycle. The lazy lookup costs one MEF resolve per "Show Steps" click. - (output as ISmartTextOutput)?.AddButton(Images.ViewCode, "Show Steps", delegate { - AppComposition.TryGetExport()?.ShowToolPane(DebugStepsPaneModel.PaneContentId); - }); - output.WriteLine(); - il.WriteTo(output, DebugStepsPaneModel.WritingOptions); - if (output is TextView.AvaloniaEditTextOutput nodeOutput - && TextView.DebugStepHighlighter.TryResolve(context.Stepper, options.StepLimit, options.HighlightStep, nodeOutput.NodeLookup, out var range)) - { - nodeOutput.DebugStepHighlight = range; - } - } - } -} - -#endif diff --git a/ILSpy/Languages/TypedILLanguage.cs b/ILSpy/Languages/TypedILLanguage.cs new file mode 100644 index 000000000..b29f840d1 --- /dev/null +++ b/ILSpy/Languages/TypedILLanguage.cs @@ -0,0 +1,72 @@ +// 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. + +#if DEBUG + +using System.Composition; + +using ICSharpCode.Decompiler; +using ICSharpCode.Decompiler.Disassembler; +using ICSharpCode.Decompiler.IL; +using ICSharpCode.Decompiler.TypeSystem; +using ICSharpCode.ILSpyX; + +using SRM = System.Reflection.Metadata; + +namespace ICSharpCode.ILSpy.Languages +{ + /// + /// Debug-only language rendering raw IL with the type the reader inferred for each instruction. + /// It runs no transforms, so it is a sanity-check view for the IL reader itself rather than a + /// stage of the decompiler pipeline - the pipeline is walked in the Debug Steps pane instead, + /// under the C# language. + /// Compiled only when DEBUG is defined; the language list is identical to Release otherwise. + /// + [Export(typeof(Language))] + [Shared] + public sealed class TypedILLanguage : Language + { + public override string Name => "Typed IL"; + + public override string FileExtension => ".il"; + + // Typed IL output uses the same `{}/()/[]` bracket conventions as C#, plus C#-style + // `//` comments and `"..."` strings. Reusing CSharpBracketSearcher gives the + // language correct bracket highlighting without a per-grammar implementation. + public override ICSharpCode.ILSpy.TextView.IBracketSearcher BracketSearcher { get; } = new CSharpBracketSearcher(); + + public override void DecompileMethod(IMethod method, ITextOutput output, DecompilationOptions options) + { + base.DecompileMethod(method, output, options); + var module = method.ParentModule!.MetadataFile!; + new ReflectionDisassembler(output, options.CancellationToken) + .DisassembleMethodHeader(module, (SRM.MethodDefinitionHandle)method.MetadataToken); + output.WriteLine(); + output.WriteLine(); + var methodDef = module.Metadata.GetMethodDefinition((SRM.MethodDefinitionHandle)method.MetadataToken); + if (!methodDef.HasBody()) + return; + var typeSystem = new DecompilerTypeSystem(module, module.GetAssemblyResolver()); + var reader = new ILReader(typeSystem.MainModule); + var methodBody = module.GetMethodBody(methodDef.RelativeVirtualAddress); + reader.WriteTypedIL((SRM.MethodDefinitionHandle)method.MetadataToken, methodBody, output, cancellationToken: options.CancellationToken); + } + } +} + +#endif diff --git a/ILSpy/TextView/DecompilerTabPageModel.cs b/ILSpy/TextView/DecompilerTabPageModel.cs index 565b9b0b4..269f5e5c2 100644 --- a/ILSpy/TextView/DecompilerTabPageModel.cs +++ b/ILSpy/TextView/DecompilerTabPageModel.cs @@ -395,6 +395,7 @@ namespace ICSharpCode.ILSpy.TextView int? pendingHighlightStep; 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 @@ -559,6 +560,10 @@ namespace ICSharpCode.ILSpy.TextView var stepLimit = pendingStepLimit; var highlightStep = pendingHighlightStep; var isDebug = pendingIsDebug; + // Unlike the per-run overrides above, this is not reset: it describes the tab, and + // every run has to record the same way or a step index picked from one tree would + // select a different step on replay. + var recordSteps = AppEnv.AppComposition.TryGetExport()?.RecordSteps ?? false; pendingStepLimit = int.MaxValue; pendingHighlightStep = null; pendingIsDebug = false; @@ -580,6 +585,7 @@ namespace ICSharpCode.ILSpy.TextView StepLimit = stepLimit, HighlightStep = highlightStep, IsDebug = isDebug, + RecordSteps = recordSteps, }; try { diff --git a/ILSpy/ViewModels/DebugStepsPaneModel.cs b/ILSpy/ViewModels/DebugStepsPaneModel.cs index 41a0ed7db..fce035d14 100644 --- a/ILSpy/ViewModels/DebugStepsPaneModel.cs +++ b/ILSpy/ViewModels/DebugStepsPaneModel.cs @@ -22,6 +22,7 @@ using System; using System.Collections.Generic; using System.ComponentModel; using System.Composition; +using System.Linq; using Avalonia.Threading; @@ -40,15 +41,14 @@ using ICSharpCode.ILSpy.Util; namespace ICSharpCode.ILSpy.ViewModels { /// - /// Bottom-aligned tool pane that surfaces the step tree from the active - /// language — ILAst (one step per IL transform) or - /// C# (one step per AST transform). The ViewModel owns the cross-language / cross-decompile - /// state (active language, current Stepper.Steps list, per-language options) so it doesn't - /// matter when the matching View materialises — the View just binds to - /// and lights up whenever the current language is a step provider and a decompile finishes. + /// Bottom-aligned tool pane that surfaces the step tree the C# language records while decompiling: + /// the IL transforms of each member, the ILAst-to-C# seam, then the C# AST transforms. The + /// ViewModel owns the cross-decompile state (active language, current Stepper.Steps list) so it + /// doesn't matter when the matching View materialises — the View just binds to + /// and lights up whenever C# is the current language and a decompile finishes. /// - /// Compiled only in Debug builds — Release users don't see the pane or the languages - /// that populate it. + /// Compiled only in Debug builds — Release users don't see the pane, and the decompiler they + /// run against records no steps to put in it. /// [Export] [ExportToolPane(ContentId = PaneContentId, Alignment = ToolPaneAlignment.Bottom, Order = 1, IsVisibleByDefault = false)] @@ -57,29 +57,38 @@ namespace ICSharpCode.ILSpy.ViewModels { public const string PaneContentId = "DebugSteps"; + /// + /// Whether the C# language should record the IL transforms of every member into its + /// . Every retained step pins the ILAst it captured, so recording a whole + /// type costs tens of thousands of nodes (System.Linq.Enumerable: 85k, ~35 MB) and a third + /// again as much decompilation time - worth it while the pane is on screen to show them, + /// wasted while it is closed. Reached from the C# language through composition: the pane is a + /// [Shared] export, so the background decompile resolves this very instance. + /// + public bool IsRecording { get; private set; } + readonly LanguageService? languageService; - IDebugStepProvider? activeLanguage; + CSharpLanguage? activeLanguage; int lastSelectedStep = int.MaxValue; /// - /// App-wide ILAst writing options shared between the BlockIL language (which reads - /// them while emitting the transformed IL) and the DebugSteps view (whose four - /// checkboxes toggle their values). Static singleton state because the language is - /// MEF-shared and decompiles on background tasks that have no view-model reference. + /// App-wide ILAst writing options shared between the C# language (which reads them while + /// emitting the ILAst a halted IL step stopped in) and the DebugSteps view (whose four + /// checkboxes toggle their values). Reached from the C# language through composition, the same + /// way is. /// - public static ILAstWritingOptions WritingOptions { get; } = new() { + public ILAstWritingOptions WritingOptions { get; } = new() { UseFieldSugar = true, UseLogicOperationSugar = true, }; /// - /// Options controls for the active step-provider language (e.g. ILAst's writing options), - /// or null when the language has none. The view selects a template by runtime type, so the - /// options shown swap with the language. + /// The writing options the checkboxes above the step tree bind to. They govern the ILAst dump + /// an IL-phase step renders, which is one step selection away at any time, so unlike the step + /// list they are always applicable. /// - [ObservableProperty] - object? options; + public ILAstWritingOptions Options => WritingOptions; /// /// The recorded transform steps currently backing . Tracked so that @@ -109,9 +118,9 @@ namespace ICSharpCode.ILSpy.ViewModels StepNodeViewModel? selectedStep; /// - /// True while the current language is an . When false, - /// the view replaces the step tree with a "not available" note instead of leaving the - /// previous language's stale tree (whose commands would trigger pointless re-decompiles). + /// True while the current language records steps, i.e. while it is C#. When false, the view + /// replaces the step tree with a "not available" note instead of leaving the previous + /// language's stale tree (whose commands would trigger pointless re-decompiles). /// [ObservableProperty] bool isAvailable; @@ -174,7 +183,7 @@ namespace ICSharpCode.ILSpy.ViewModels // ToolPaneRegistry which materialises this VM which would import DockWorkspace). // Lazy lookup at command-execution time breaks the cycle. - // Language flips go through LanguageService.CurrentLanguage; the BlockILLanguage + // Language flips go through LanguageService.CurrentLanguage; the C# language // pumps StepperUpdated when its decompile finishes. The selection-changed event // is the signal that the user picked a new tree node — clear the step list so // the previous run's nodes aren't shown against a fresh selection. All three @@ -210,13 +219,13 @@ namespace ICSharpCode.ILSpy.ViewModels void TryAttachToCurrentLanguage() { - if (languageService?.CurrentLanguage is IDebugStepProvider il) - AttachToLanguage(il); + if (languageService?.CurrentLanguage is CSharpLanguage csharp) + AttachToLanguage(csharp); else DetachFromLanguage(); } - void AttachToLanguage(IDebugStepProvider language) + void AttachToLanguage(CSharpLanguage language) { if (ReferenceEquals(activeLanguage, language)) { @@ -230,7 +239,6 @@ namespace ICSharpCode.ILSpy.ViewModels activeLanguage = language; language.StepperUpdated += OnStepperUpdated; SetStepsSource(language.Stepper.Steps); - Options = language.StepOptions; IsAvailable = true; } @@ -242,13 +250,17 @@ namespace ICSharpCode.ILSpy.ViewModels { activeLanguage.StepperUpdated -= OnStepperUpdated; activeLanguage = null; - Options = null; } } void OnStepperUpdated(object? sender, System.EventArgs e) { Dispatcher.UIThread.Post(() => { + // A run that started while the pane was open can finish after it closed. Taking its + // update would pin the tree straight back into a pane nobody is looking at, undoing + // the release that closing just performed. + if (!IsRecording) + return; if (activeLanguage != null) { SetStepsSource(activeLanguage.Stepper.Steps); @@ -322,6 +334,9 @@ namespace ICSharpCode.ILSpy.ViewModels static void SnapshotExpansion(StepNodeViewModel node) { node.ExpansionBeforeFilter = node.IsExpanded; + // A subtree nobody has expanded has no wrappers and so no expansion state to save. + if (!node.HasMaterializedChildren) + return; foreach (var child in node.Children) SnapshotExpansion(child); } @@ -332,6 +347,8 @@ namespace ICSharpCode.ILSpy.ViewModels if (node.ExpansionBeforeFilter is bool expanded) node.IsExpanded = expanded; node.ExpansionBeforeFilter = null; + if (!node.HasMaterializedChildren) + return; foreach (var child in node.Children) RestoreExpansion(child); } @@ -343,16 +360,33 @@ namespace ICSharpCode.ILSpy.ViewModels /// static bool ApplyFilterToNode(StepNodeViewModel node, string filter) { - bool descendantMatches = false; - foreach (var child in node.Children) - descendantMatches |= ApplyFilterToNode(child, filter); - bool selfMatches = node.Description.Contains(filter, System.StringComparison.OrdinalIgnoreCase); + bool selfMatches = Matches(node.Description, filter); + // Ask the recorded steps, not the wrappers: a subtree with no match stays hidden, and + // wrapping it only to hide it would put a view-model on the UI thread for every step of a + // recorded type - tens of thousands of them, on the first keystroke. + bool descendantMatches = node.Step.Children.Any(step => SubtreeMatches(step, filter)); node.IsVisible = selfMatches || descendantMatches; if (descendantMatches) node.IsExpanded = true; + // Descend into wrappers that exist either because the path is being revealed or because + // an earlier pass built them: those carry visibility state that has to be corrected. + if (descendantMatches || node.HasMaterializedChildren) + { + foreach (var child in node.Children) + ApplyFilterToNode(child, filter); + } return selfMatches || descendantMatches; } + static bool SubtreeMatches(Stepper.Node step, string filter) + { + return Matches(step.Description, filter) + || step.Children.Any(child => SubtreeMatches(child, filter)); + } + + static bool Matches(string description, string filter) + => description.Contains(filter, System.StringComparison.OrdinalIgnoreCase); + void OnSelectionChanged(object? sender, AssemblyTreeSelectionChangedEventArgs e) { // User picked a new tree node — the previous run's stepper is stale until the next @@ -385,12 +419,51 @@ namespace ICSharpCode.ILSpy.ViewModels RequestRedecompile(lastSelectedStep, isDebug: false); } + /// + /// The C# language whether or not it is the current one. The steps are pinned on the MEF-shared + /// language instance, so releasing them cannot depend on which language happens to be selected: + /// switching to IL detaches this pane but leaves the recorded tree exactly where it was. + /// + CSharpLanguage? StepRecordingLanguage => + activeLanguage ?? languageService?.Languages.OfType().FirstOrDefault(); + + /// + /// Turns step recording on while the pane is on screen. Enabling re-runs the current decompile, + /// because the run that produced the displayed output recorded no steps; disabling drops the + /// tree the language is still holding, which is where the retained ILAst lives. + /// + internal void SetRecordingEnabled(bool enabled) + { + if (IsRecording == enabled) + return; + IsRecording = enabled; + // The workspace carries the flag into every run it starts, including the ones the user + // triggers by selecting another node, so closing the pane has to clear it there too. + if (AppComposition.TryGetExport() is { } workspace) + workspace.RecordSteps = enabled; + if (enabled) + { + // C# is the only language that records anything, so re-running any other one would + // throw away the view the user is looking at to produce a tree that stays empty. + if (activeLanguage != null) + RequestRedecompile(int.MaxValue, isDebug: false); + } + else + { + SetStepsSource(null); + StepRecordingLanguage?.ReleaseSteps(); + } + } + void RequestRedecompile(int stepLimit, bool isDebug, int? highlightStep = null) { lastSelectedStep = stepLimit; // Composition unavailable in design-time previews; the gesture is a no-op there. var dock = AppComposition.TryGetExport(); - dock?.ActiveDecompilerTab?.RestartDecompileWithStepLimit(stepLimit, isDebug, highlightStep); + if (dock == null) + return; + dock.RecordSteps = IsRecording; + dock.ActiveDecompilerTab?.RestartDecompileWithStepLimit(stepLimit, isDebug, highlightStep); } } } diff --git a/ILSpy/ViewModels/StepNodeViewModel.cs b/ILSpy/ViewModels/StepNodeViewModel.cs index ed09b1138..adb2ecada 100644 --- a/ILSpy/ViewModels/StepNodeViewModel.cs +++ b/ILSpy/ViewModels/StepNodeViewModel.cs @@ -23,6 +23,8 @@ using System.Collections.Generic; using CommunityToolkit.Mvvm.ComponentModel; using ICSharpCode.Decompiler.DebugSteps; +using ICSharpCode.Decompiler.CSharp.Syntax; +using ICSharpCode.Decompiler.IL; namespace ICSharpCode.ILSpy.ViewModels { @@ -39,9 +41,51 @@ namespace ICSharpCode.ILSpy.ViewModels { public Stepper.Node Step { get; } public StepNodeViewModel? Parent { get; } - public IReadOnlyList Children { get; } public string Description => Step.Description; + /// + /// Which half of the pipeline the step belongs to, shown beside the description so the seam is + /// visible at a glance. Taken from what the step points at: the IL transforms anchor their + /// steps to instructions, the C# ones to syntax nodes. Empty when a step carries no anchor. + /// + public string Phase => PhaseOf(Step); + + static string PhaseOf(Stepper.Node step) + { + switch (step.Position) + { + case ILInstruction: + return "IL"; + case AstNode: + return "C#"; + } + // A group opener carries no position of its own when anchoring it would misattribute a + // halt landing on it, so take the phase from the first step underneath that has one. + foreach (var child in step.Children) + { + string phase = PhaseOf(child); + if (phase.Length > 0) + return phase; + } + return string.Empty; + } + + IReadOnlyList? children; + + /// + /// Wrappers for this step's children, built on first access. A recorded type runs to tens of + /// thousands of steps, so materialising the whole tree up front would put that many view-models + /// on the UI thread for the handful of rows an expanded path actually shows. + /// + public IReadOnlyList Children => children ??= Wrap(Step.Children, this); + + /// + /// Whether has been built. Lets a walk that only needs to fix up state + /// it previously set skip the subtrees nobody has looked at, instead of wrapping them to find + /// out there is nothing to fix. + /// + internal bool HasMaterializedChildren => children != null; + /// Two-way bound to the row's TreeViewItem.IsExpanded. [ObservableProperty] bool isExpanded; @@ -60,20 +104,16 @@ namespace ICSharpCode.ILSpy.ViewModels { Step = step; Parent = parent; - var children = new List(step.Children.Count); - foreach (var child in step.Children) - { - children.Add(new StepNodeViewModel(child, this)); - } - Children = children; } - public static IReadOnlyList Wrap(IList steps) + public static IReadOnlyList Wrap(IList steps) => Wrap(steps, null); + + static IReadOnlyList Wrap(IList steps, StepNodeViewModel? parent) { var wrapped = new List(steps.Count); foreach (var step in steps) { - wrapped.Add(new StepNodeViewModel(step, null)); + wrapped.Add(new StepNodeViewModel(step, parent)); } return wrapped; } diff --git a/ILSpy/Views/DebugSteps.axaml b/ILSpy/Views/DebugSteps.axaml index f258334d5..29e997924 100644 --- a/ILSpy/Views/DebugSteps.axaml +++ b/ILSpy/Views/DebugSteps.axaml @@ -3,7 +3,6 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:vm="using:ICSharpCode.ILSpy.ViewModels" - xmlns:il="using:ICSharpCode.Decompiler.IL" mc:Ignorable="d" d:DesignWidth="400" d:DesignHeight="300" x:Class="ICSharpCode.ILSpy.Views.DebugSteps" x:DataType="vm:DebugStepsPaneModel"> @@ -13,47 +12,46 @@ Margin="8" TextWrapping="Wrap" Opacity="0.7" Text="Debug steps are not available for the current language." /> - + - - - - - - - - - - - - + + + + + + + DoubleTapped="OnTreeDoubleTapped"> - - - + + + + + + + diff --git a/ILSpy/Views/DebugSteps.axaml.cs b/ILSpy/Views/DebugSteps.axaml.cs index 65cb1ea9f..4af4a3400 100644 --- a/ILSpy/Views/DebugSteps.axaml.cs +++ b/ILSpy/Views/DebugSteps.axaml.cs @@ -83,7 +83,12 @@ namespace ICSharpCode.ILSpy.Views DetachModel(); attachedModel = model; if (model != null) + { model.SelectionRevealRequested += OnSelectionRevealRequested; + // The view is in the tree exactly while the pane is open, which is the only time the + // recorded IL steps are worth their memory. + model.SetRecordingEnabled(true); + } } void DetachModel() @@ -91,6 +96,7 @@ namespace ICSharpCode.ILSpy.Views if (attachedModel != null) { attachedModel.SelectionRevealRequested -= OnSelectionRevealRequested; + attachedModel.SetRecordingEnabled(false); attachedModel = null; } }