Browse Source

Walk the whole decompiler pipeline in the Debug Steps pane

The pane used to split the pipeline across two languages: the ILAst language
stepped the IL transforms, the C# language stepped the AST transforms, and
nothing showed the seam between them, so a step index meant a different thing
depending on which language happened to be selected. Recording both halves into
one Stepper makes an index replayable across the whole pipeline; a limit that
lands in the IL phase has no C# to print, so the halted function is rendered as
ILAst instead.

Which function that is takes some care, because a member group's EndStep is the
next member's first step: a halt standing on a member's opening step belongs to
the member that just finished, a transform that throws where the limit was aimed
has to hand over the ILAst it half-transformed (what the ILAst language showed
as "ILAst after the crash"), and a step recorded on a helper function the
pipeline has not attached yet belongs to that function's own tree.

Retention stays opt-in twice over: the decompiler records IL steps only when
asked to, and the pane asks only while its view is on screen. Every kept step
pins the ILAst it captured, which for one type runs to tens of thousands of
nodes, so a closed pane would be paying for a tree nobody displays.

What is left of the ILAst language is its typed-IL dump, which runs no
transforms at all. That stays, as TypedILLanguage. IDebugStepProvider was down
to a single implementation and is removed.

Assisted-by: Claude:claude-opus-5[1m]:Claude Code
pull/4029/head
Siegfried Pammer 3 weeks ago
parent
commit
a5bd230e14
  1. 311
      ICSharpCode.Decompiler.Tests/DebugStepRecordingTests.cs
  2. 34
      ICSharpCode.Decompiler.Tests/DecompilationErrorRecoveryTests.cs
  3. 59
      ICSharpCode.Decompiler.Tests/Helpers/StepperTesting.cs
  4. 143
      ICSharpCode.Decompiler/CSharp/CSharpDecompiler.cs
  5. 30
      ICSharpCode.Decompiler/DebugSteps/Stepper.cs
  6. 10
      ILSpy.Tests/Editor/DecompilerViewTests.cs
  7. 203
      ILSpy.Tests/Views/DebugStepsTests.cs
  8. 17
      ILSpy/DecompilationOptions.cs
  9. 56
      ILSpy/Languages/CSharpLanguage.DebugSteps.cs
  10. 89
      ILSpy/Languages/CSharpLanguage.cs
  11. 52
      ILSpy/Languages/IDebugStepProvider.cs
  12. 201
      ILSpy/Languages/ILAstLanguage.cs
  13. 72
      ILSpy/Languages/TypedILLanguage.cs
  14. 77
      ILSpy/ViewModels/DebugStepsPaneModel.cs
  15. 22
      ILSpy/ViewModels/StepNodeViewModel.cs
  16. 39
      ILSpy/Views/DebugSteps.axaml
  17. 6
      ILSpy/Views/DebugSteps.axaml.cs

311
ICSharpCode.Decompiler.Tests/DebugStepRecordingTests.cs

@ -0,0 +1,311 @@ @@ -0,0 +1,311 @@
// 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.IL.Transforms;
using ICSharpCode.Decompiler.Metadata;
using ICSharpCode.Decompiler.Tests.Helpers;
using ICSharpCode.Decompiler.TypeSystem;
using NUnit.Framework;
namespace ICSharpCode.Decompiler.Tests
{
/// <summary>
/// The Debug Steps view replays a decompilation by index, so a single <see cref="Stepper"/> 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.
/// </summary>
[TestFixture]
public class DebugStepRecordingTests
{
const string RecordedStep = "recorded IL step";
const string DetachedStep = "step on a detached function";
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(Descriptions(decompiler.Stepper.Steps), Has.Some.Contains(RecordedStep));
}
/// <summary>
/// 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.
/// </summary>
[Test]
public void ILTransformStepsAreNotRecordedWithoutOptIn()
{
var decompiler = StepperTesting.CreateDecompiler();
decompiler.ILTransforms.Add(new RecordingILTransform());
decompiler.DecompileTypeAsString(SampleType);
Assert.That(Descriptions(decompiler.Stepper.Steps), Has.None.Contains(RecordedStep));
}
/// <summary>
/// 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.
/// </summary>
[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<string>(
name => decompiler.Stepper.LimitReachedStep!.Description.Contains(name)),
"the halt must still report the IL step it stopped on");
}
}
/// <summary>
/// A limit in the C# AST phase prints the partially transformed tree, and nothing claims the
/// run halted in the IL phase.
/// </summary>
[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");
}
}
/// <summary>
/// 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.
/// </summary>
[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<Stepper.Node>(n => n.Description.Contains("SanitizeFileName")),
"the members after the failing one stay top-level siblings");
}
}
/// <summary>
/// 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.
/// </summary>
[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");
}
}
/// <summary>
/// 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.
/// </summary>
[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"));
}
/// <summary>
/// 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.
/// </summary>
[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"));
}
/// <summary>
/// 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.
/// </summary>
[Test]
public void AHaltOnADetachedFunctionRendersThatFunction()
{
var detachedStep = new DetachedFunctionILTransform("CleanUpFileName");
var decompiler = CreateRecordingDecompiler();
decompiler.ILTransforms.Add(detachedStep);
decompiler.DecompileTypeAsString(SampleType);
int haltAt = AllNodes(decompiler.Stepper.Steps).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));
}
/// <summary>
/// Runs the decompiler once and returns the top-level group recording the named member's IL phase.
/// </summary>
static Stepper.Node MemberGroup(string methodName, CSharpDecompiler decompiler)
{
decompiler.DecompileTypeAsString(SampleType);
return decompiler.Stepper.Steps.Single(n => n.Description.EndsWith("." + methodName, StringComparison.Ordinal));
}
static IEnumerable<Stepper.Node> AllNodes(IEnumerable<Stepper.Node> nodes)
{
foreach (var node in nodes)
{
yield return node;
foreach (var child in AllNodes(node.Children))
yield return child;
}
}
static IEnumerable<string> Descriptions(IEnumerable<Stepper.Node> nodes)
{
foreach (var node in nodes)
{
yield return node.Description;
foreach (var description in Descriptions(node.Children))
yield return description;
}
}
static CSharpDecompiler CreateRecordingDecompiler()
{
var decompiler = StepperTesting.CreateDecompiler();
decompiler.RecordILTransformSteps = true;
return decompiler;
}
/// <summary>
/// Records one step per top-level function through <see cref="Stepper.Step"/> rather than
/// <c>context.Step</c>: the latter is <c>[Conditional("STEP")]</c>, and that is resolved where
/// the call is compiled - this assembly, which never defines STEP - so a <c>context.Step</c>
/// call would vanish and leave the tests passing vacuously in every configuration.
/// </summary>
/// <summary>
/// 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.
/// </summary>
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);
}
}
}
}

34
ICSharpCode.Decompiler.Tests/DecompilationErrorRecoveryTests.cs

@ -26,6 +26,7 @@ using ICSharpCode.Decompiler.CSharp.Syntax; @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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);
}
}
}
}

59
ICSharpCode.Decompiler.Tests/Helpers/StepperTesting.cs

@ -0,0 +1,59 @@ @@ -0,0 +1,59 @@
// 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
{
/// <summary>
/// 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.
/// </summary>
static class StepperTesting
{
public const string SimulatedFailure = "Simulated transform failure";
public 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);
}
/// <summary>
/// Throws while transforming the named method's top-level function, leaving the pipeline to
/// unwind out of whatever step groups it had opened.
/// </summary>
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);
}
}
}
}

143
ICSharpCode.Decompiler/CSharp/CSharpDecompiler.cs

@ -210,6 +210,9 @@ namespace ICSharpCode.Decompiler.CSharp @@ -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,42 @@ namespace ICSharpCode.Decompiler.CSharp @@ -232,6 +235,42 @@ namespace ICSharpCode.Decompiler.CSharp
public Stepper Stepper { get; set; } = new Stepper();
/// <summary>
/// Gets or sets whether the IL transform phase records its steps into <see cref="Stepper"/>,
/// so that one step tree spans the whole pipeline: IL transforms, the ILAst-to-C# conversion,
/// and the C# AST transforms.
/// Off by default. The recording itself already happens in debug builds - into a per-member
/// stepper that is thrown away - so what this turns on is <i>retention</i>: the step history of
/// every member of the decompiled type stays alive, and each 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.
/// Replaying a step by index requires the same value on the full run and on the step-limited
/// re-run, since the recording decides how the steps are numbered.
/// Has no effect unless <see cref="Stepper.SteppingAvailable"/>.
/// </summary>
public bool RecordILTransformSteps { get; set; }
/// <summary>
/// The <see cref="ILFunction"/> whose IL transforms were halted by <see cref="Stepper.StepLimit"/>,
/// or null when no step limit was reached before the C# AST was built. Reset at the start of
/// every Decompile* call, like <see cref="Errors"/>.
/// 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.
/// </summary>
public ILFunction? StepLimitHaltedFunction { get; private set; }
/// <summary>
/// The last member whose IL transforms ran to completion. A step limit landing on a member's
/// opening group step stops the pipeline at the very point that member's predecessor finished,
/// so this - not the untouched member the halt technically occurred in - is the state the caller
/// is asking to see.
/// </summary>
ILFunction? lastCompletedFunction;
/// <summary>
/// Returns all built-in transforms of the C# AST pipeline.
/// </summary>
@ -816,6 +855,11 @@ namespace ICSharpCode.Decompiler.CSharp @@ -816,6 +855,11 @@ 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;
lastCompletedFunction = null;
List<INamespace> resolvedNamespaces = new List<INamespace>();
foreach (var ns in namespaces)
{
@ -841,6 +885,13 @@ namespace ICSharpCode.Decompiler.CSharp @@ -841,6 +885,13 @@ 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
@ -2257,8 +2308,45 @@ namespace ICSharpCode.Decompiler.CSharp @@ -2257,8 +2308,45 @@ namespace ICSharpCode.Decompiler.CSharp
return method.ReturnType.Kind == TypeKind.Void && method.Name == "InitializeComponent" && method.DeclaringTypeDefinition!.GetNonInterfaceBaseTypes().Any(t => t.FullName == "System.Windows.Forms.Control");
}
/// <summary>
/// The outermost <see cref="ILFunction"/> 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.
/// </summary>
ILFunction? HaltedStepFunction()
{
return (Stepper.LimitReachedStep?.Position as ILInstruction)?
.Ancestors.OfType<ILFunction>().LastOrDefault();
}
/// <summary>
/// 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.
/// </summary>
IEnumerable<IILTransform> 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, and tell a halt inside this member from one on its boundary.
ILFunction? function = null;
bool haltBelongsToThisMember = false;
// 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 +2378,7 @@ namespace ICSharpCode.Decompiler.CSharp @@ -2290,7 +2378,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 +2397,15 @@ namespace ICSharpCode.Decompiler.CSharp @@ -2309,17 +2397,15 @@ 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 (RecordILTransformSteps)
context.Stepper = Stepper;
context.StepStartGroup(method.FullName);
// From here on a halt belongs to this member: it is standing on one of this member's own
// steps, not on the boundary where the previous member finished.
haltBelongsToThisMember = true;
function.RunTransforms(SelectILTransforms(localSettings.DecompileMemberBodies), context);
lastCompletedFunction = function;
// Generate C# AST only if bodies should be displayed.
if (localSettings.DecompileMemberBodies)
@ -2333,6 +2419,10 @@ namespace ICSharpCode.Decompiler.CSharp @@ -2333,6 +2419,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 +2439,43 @@ namespace ICSharpCode.Decompiler.CSharp @@ -2349,14 +2439,43 @@ 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()
?? (haltBelongsToThisMember ? function : lastCompletedFunction ?? 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 (function != null && Stepper.CurrentStep == Stepper.StepLimit)
StepLimitHaltedFunction = function;
entityDecl.GetChild(Slots.Body)?.Remove();
if (settings.DecompileMemberBodies)
{

30
ICSharpCode.Decompiler/DebugSteps/Stepper.cs

@ -183,6 +183,13 @@ namespace ICSharpCode.Decompiler.DebugSteps @@ -183,6 +183,13 @@ namespace ICSharpCode.Decompiler.DebugSteps
readonly IList<Node> steps;
int step = 0;
/// <summary>
/// Index the next recorded step will be given. It equals <see cref="StepLimit"/> 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.
/// </summary>
public int CurrentStep => step;
public Stepper()
{
steps = new List<Node>();
@ -256,6 +263,29 @@ namespace ICSharpCode.Decompiler.DebugSteps @@ -256,6 +263,29 @@ namespace ICSharpCode.Decompiler.DebugSteps
}
}
/// <summary>
/// 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 <see cref="Node.EndStep"/> 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 <see cref="EndGroup"/>'s removal path expects the group to
/// still be the last entry of its parent, which an unwind cannot guarantee.
/// Closing stops at <paramref name="targetDepth"/>: a group that was already open before the
/// unwinding code ran belongs to whoever opened it, not to the unwind.
/// </summary>
public void EndOpenGroups(int targetDepth = 0)
{
while (groups.Count > targetDepth)
EndGroup(keepIfEmpty: true);
}
/// <summary>
/// How many groups are currently open. Take it before entering code that may unwind, and hand
/// it back to <see cref="EndOpenGroups"/> so only the groups that code opened are closed.
/// </summary>
public int GroupDepth => groups.Count;
public void EndGroup(bool keepIfEmpty = false)
{
var node = groups.Pop();

10
ILSpy.Tests/Editor/DecompilerViewTests.cs

@ -547,12 +547,12 @@ public class DecompilerViewTests @@ -547,12 +547,12 @@ public class DecompilerViewTests
// Switch the language — the buggy path was here.
var languageService = AppComposition.Current.GetExport<LanguageService>();
var blockIL = languageService.Languages.OfType<ILAstLanguage>()
.Single(l => l.Name == "ILAst");
languageService.CurrentLanguage = blockIL;
var il = languageService.Languages.OfType<ILLanguage>()
.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 @@ -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
{

203
ILSpy.Tests/Views/DebugStepsTests.cs

@ -82,14 +82,13 @@ public class DebugStepsTests @@ -82,14 +82,13 @@ 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
@ -110,21 +109,17 @@ public class DebugStepsTests @@ -110,21 +109,17 @@ public class DebugStepsTests
await vm.DockWorkspace.WaitForDecompiledTextAsync();
var languageService = AppComposition.Current.GetExport<LanguageService>();
var blockIL = languageService.Languages.OfType<ILAstLanguage>()
.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<CSharpLanguage>().Single();
csharp.Stepper.Steps.Should().NotBeEmpty(
"the C# decompile must populate decompiler.Stepper.Steps when STEP is defined");
var debugStepsVm = AppComposition.Current.GetExport<DebugStepsPaneModel>();
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]
@ -152,16 +147,19 @@ public class DebugStepsTests @@ -152,16 +147,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; with the pane closed, nothing precedes them.
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 +184,71 @@ public class DebugStepsTests @@ -186,16 +184,71 @@ 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 IL 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
// them either - only the C# AST steps, which are few and cheap.
var debugStepsVm = AppComposition.Current.GetExport<DebugStepsPaneModel>();
debugStepsVm.SetRecordingEnabled(false);
var window = AppComposition.Current.GetExport<MainWindow>();
window.Show();
var vm = (MainWindowViewModel)window.DataContext!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3);
var languageService = AppComposition.Current.GetExport<LanguageService>();
var csharp = languageService.Languages.OfType<CSharpLanguage>().Single();
languageService.CurrentLanguage = csharp;
static string StripStepNumber(string description)
var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"System.Linq", "System.Linq", "System.Linq.Enumerable");
typeNode.IsExpanded = true;
var method = typeNode.Children.OfType<MethodTreeNode>()
.First(m => m.MethodDefinition.Name == "Range");
vm.AssemblyTreeModel.SelectNode(method);
await vm.DockWorkspace.WaitForDecompiledTextAsync();
var ilTransformNames = CSharpDecompiler.GetILTransforms()
.Select(transform => transform.GetType().Name)
.ToHashSet();
AllDescriptions(csharp.Stepper.Steps).Should().NotContain(
description => ilTransformNames.Contains(StripStepNumber(description)),
"a closed pane leaves the IL transforms unrecorded");
static System.Collections.Generic.IEnumerable<string> AllDescriptions(
System.Collections.Generic.IEnumerable<Stepper.Node> nodes)
{
var separatorIndex = description.IndexOf(": ", StringComparison.Ordinal);
return separatorIndex >= 0 ? description[(separatorIndex + 2)..] : description;
foreach (var node in nodes)
{
yield return node.Description;
foreach (var description in AllDescriptions(node.Children))
yield return description;
}
}
}
[AvaloniaTest]
public async Task ILAst_DebugStep_Replay_Highlights_Changed_Instruction()
public async Task CSharp_DebugSteps_Cover_IL_Transforms_And_Replay_Renders_ILAst()
{
// Recording the IL half is what an open pane switches on; nothing realises the pane's view
// here, so this test asks for it the same way the view does.
var debugStepsVm = AppComposition.Current.GetExport<DebugStepsPaneModel>();
debugStepsVm.SetRecordingEnabled(true);
try
{
await CoverILTransformsAndReplay();
}
finally
{
debugStepsVm.SetRecordingEnabled(false);
}
}
static async Task CoverILTransformsAndReplay()
{
var window = AppComposition.Current.GetExport<MainWindow>();
window.Show();
@ -203,8 +256,8 @@ public class DebugStepsTests @@ -203,8 +256,8 @@ public class DebugStepsTests
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3);
var languageService = AppComposition.Current.GetExport<LanguageService>();
var blockIL = languageService.Languages.OfType<ILAstLanguage>().Single(l => l.Name == "ILAst");
languageService.CurrentLanguage = blockIL;
var csharp = languageService.Languages.OfType<CSharpLanguage>().Single();
languageService.CurrentLanguage = csharp;
var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"System.Linq", "System.Linq", "System.Linq.Enumerable");
@ -217,25 +270,46 @@ public class DebugStepsTests @@ -217,25 +270,46 @@ public class DebugStepsTests
var debugStepsVm = AppComposition.Current.GetExport<DebugStepsPaneModel>();
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");
topLevel.Take(topLevel.Length - astTransformNames.Length)
.Should().OnlyContain(description => description.Contains("System.Linq.Enumerable"),
"nothing but the decompiled members' groups precedes them");
// 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 +332,23 @@ public class DebugStepsTests @@ -258,17 +332,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>();
languageService.Languages.OfType<ILAstLanguage>().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<CSharpLanguage>().Single();
languageService.CurrentLanguage = csharp;
csharp.HasLanguageVersions.Should().BeTrue();
var debugStepsVm = AppComposition.Current.GetExport<DebugStepsPaneModel>();
debugStepsVm.IsAvailable.Should().BeTrue(
"the language that offers version selection is also the one that records steps");
return Task.CompletedTask;
}
@ -376,9 +456,9 @@ public class DebugStepsTests @@ -376,9 +456,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 +474,45 @@ public class DebugStepsTests @@ -394,6 +474,45 @@ 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 options that live in a
// static singleton: what the compiler cannot check is that the two-way path reaches that
// singleton 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<CheckBox>()
.Single(box => (box.Content as string) == "Field sugar");
fieldSugar.IsChecked.Should().Be(DebugStepsPaneModel.WritingOptions.UseFieldSugar,
"the checkbox must show the current writing option");
fieldSugar.IsChecked = false;
Dispatcher.UIThread.RunJobs();
DebugStepsPaneModel.WritingOptions.UseFieldSugar.Should().BeFalse(
"toggling the checkbox must reach the options the ILAst dump is written with");
}
finally
{
// Shared static state: leave it as the rest of the suite expects to find it.
DebugStepsPaneModel.WritingOptions.UseFieldSugar = true;
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 +593,7 @@ public class DebugStepsTests @@ -474,7 +593,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.

17
ILSpy/DecompilationOptions.cs

@ -54,10 +54,12 @@ namespace ICSharpCode.ILSpy @@ -54,10 +54,12 @@ namespace ICSharpCode.ILSpy
public string? StrongNameKeyFile { get; set; }
/// <summary>
/// Stop the IL-transform pipeline after this many steps. <see cref="int.MaxValue"/>
/// 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
/// <see cref="Languages.BlockILLanguage"/>; ignored by every other language.
/// Stop the decompiler pipeline after this many steps. <see cref="int.MaxValue"/> 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.
/// </summary>
public int StepLimit { get; set; } = int.MaxValue;
@ -69,9 +71,10 @@ namespace ICSharpCode.ILSpy @@ -69,9 +71,10 @@ namespace ICSharpCode.ILSpy
public int? HighlightStep { get; set; }
/// <summary>
/// When true, transforms emit verbose debug information about their behaviour. Only
/// meaningful in combination with <see cref="StepLimit"/> — 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 <see cref="StepLimit"/>
/// and then carries on, instead of halting there. Only meaningful in combination with
/// <see cref="StepLimit"/> — the Debug Steps pane sets it on the "Debug this step"
/// context-menu action.
/// </summary>
public bool IsDebug { get; set; }

56
ILSpy/Languages/CSharpLanguage.DebugSteps.cs

@ -32,12 +32,13 @@ using ICSharpCode.ILSpy.ViewModels; @@ -32,12 +32,13 @@ using ICSharpCode.ILSpy.ViewModels;
namespace ICSharpCode.ILSpy.Languages
{
/// <summary>
/// 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 <see
/// cref="DecompilationOptions.StepLimit"/>.
/// 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
/// <see cref="Stepper"/>; a selected step's index is replayed by re-decompiling with
/// <see cref="DecompilationOptions.StepLimit"/>. A step that halts the IL phase leaves no C# to
/// print, so the replay renders the halted ILAst instead - see <see cref="TryWriteILAst"/>.
/// </summary>
partial class CSharpLanguage : IDebugStepProvider
partial class CSharpLanguage
{
Stepper stepper = new Stepper();
@ -45,13 +46,50 @@ namespace ICSharpCode.ILSpy.Languages @@ -45,13 +46,50 @@ 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;
/// <summary>
/// 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.
/// </summary>
static partial void TryWriteILAst(ITextOutput output, DecompilationOptions options, CSharpDecompiler decompiler, ref bool handled)
{
if (decompiler.StepLimitHaltedFunction is not { } function)
return;
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, DebugStepsPaneModel.WritingOptions);
handled = true;
}
/// <summary>
/// Points the decompiler's IL phase at the shared stepper, but only while the Debug Steps pane
/// is there to display what it records - see <see cref="DebugStepsPaneModel.IsRecording"/>.
/// </summary>
static partial void ConfigureStepRecording(CSharpDecompiler decompiler)
{
decompiler.RecordILTransformSteps = DebugStepsPaneModel.IsRecording;
}
/// <summary>
/// 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.
/// </summary>
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 {

89
ILSpy/Languages/CSharpLanguage.cs

@ -327,6 +327,10 @@ namespace ICSharpCode.ILSpy.Languages @@ -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 have to record into
// the same stepper as the C# AST transforms. Implemented only under DEBUG, where the pane
// exists; a Release decompiler records no steps at all.
ConfigureStepRecording(decompiler);
if (options.EscapeInvalidIdentifiers)
decompiler.AstTransforms.Add(new EscapeInvalidIdentifiers());
return decompiler;
@ -354,11 +358,11 @@ namespace ICSharpCode.ILSpy.Languages @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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,55 @@ namespace ICSharpCode.ILSpy.Languages @@ -732,38 +736,55 @@ 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();
bool handled = false;
TryWriteILAst(output, options, decompiler, ref handled);
if (!handled)
{
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;
}
}
// Implemented only under DEBUG (CSharpLanguage.DebugSteps.cs): enables IL-transform step
// recording while the Debug Steps pane is open. A no-op partial in Release.
static partial void ConfigureStepRecording(CSharpDecompiler decompiler);
// Implemented only under DEBUG (CSharpLanguage.DebugSteps.cs): writes the ILAst the pipeline was
// halted in, when a Debug Steps replay stopped it before there was any C# to show. A no-op
// partial in Release, where nothing sets a step limit.
static partial void TryWriteILAst(ITextOutput output, DecompilationOptions options, CSharpDecompiler decompiler, ref bool handled);
void AddWarningMessage(MetadataFile module, ITextOutput output, string line1, string? line2 = null,
string? buttonText = null, global::Avalonia.Media.IImage? buttonImage = null,
System.EventHandler<global::Avalonia.Interactivity.RoutedEventArgs>? buttonClickHandler = null)

52
ILSpy/Languages/IDebugStepProvider.cs

@ -1,52 +0,0 @@ @@ -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
{
/// <summary>
/// A language that surfaces a <see cref="Stepper"/> of decompiler-pipeline steps for the
/// Debug Steps pane to visualise. Implemented by <see cref="ILAstLanguage"/> (one node per IL
/// transform step) and <see cref="CSharpLanguage"/> (one node per C# AST transform). The pane
/// binds to whichever current language is an <see cref="IDebugStepProvider"/>, so it no longer has to
/// know the concrete language type.
/// </summary>
internal interface IDebugStepProvider
{
/// <summary>The step tree produced by the most recent full decompile.</summary>
Stepper Stepper { get; }
/// <summary>Raised after a full (non step-limited) decompile refreshes <see cref="Stepper"/>.</summary>
event EventHandler? StepperUpdated;
/// <summary>
/// Language-specific options object the Debug Steps pane renders above the step tree
/// (e.g. ILAst's writing-options checkboxes), or <see langword="null"/> 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.
/// </summary>
object? StepOptions { get; }
}
}
#endif

201
ILSpy/Languages/ILAstLanguage.cs

@ -1,201 +0,0 @@ @@ -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
{
/// <summary>
/// Debug-only language that surfaces the decompiler pipeline's intermediate state.
/// Two concrete variants ship: <see cref="TypedIL"/> renders raw IL with type
/// annotations; <see cref="BlockIL"/> runs the decompiler's IL transforms with a
/// <see cref="Stepper"/> attached so the Debug Steps pane can replay each transform.
/// Compiled only when DEBUG is defined — the language list is identical to Release
/// otherwise.
/// </summary>
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();
/// <summary>
/// Fires after a <see cref="DecompileMethod"/> run installs a fresh <see cref="Stepper"/>.
/// The Debug Steps pane subscribes here so it can rebind its TreeView to the new
/// step list whenever the user reruns decompilation.
/// </summary>
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();
}
}
/// <summary>
/// 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.
/// </summary>
[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);
}
}
/// <summary>
/// Runs the full C# decompiler's IL transforms and writes the resulting <see cref="ILFunction"/>.
/// Each transform is recorded by a <see cref="Stepper"/> hooked into the
/// <see cref="ILTransformContext"/>; the resulting step tree is what the Debug Steps
/// pane visualises. The "Show Steps" button on the output reveals the pane.
/// </summary>
[Export(typeof(Language))]
[Shared]
public sealed class BlockILLanguage : ILAstLanguage
{
readonly IReadOnlyList<IILTransform> 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<DockWorkspace>()?.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

72
ILSpy/Languages/TypedILLanguage.cs

@ -0,0 +1,72 @@ @@ -0,0 +1,72 @@
// 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.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
{
/// <summary>
/// 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.
/// </summary>
[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

77
ILSpy/ViewModels/DebugStepsPaneModel.cs

@ -40,15 +40,14 @@ using ICSharpCode.ILSpy.Util; @@ -40,15 +40,14 @@ using ICSharpCode.ILSpy.Util;
namespace ICSharpCode.ILSpy.ViewModels
{
/// <summary>
/// Bottom-aligned tool pane that surfaces the step tree from the active
/// <see cref="Languages.IDebugStepProvider"/> 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 <see cref="Steps"/>
/// 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 <see cref="Steps"/>
/// 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.
/// </summary>
[Export]
[ExportToolPane(ContentId = PaneContentId, Alignment = ToolPaneAlignment.Bottom, Order = 1, IsVisibleByDefault = false)]
@ -57,14 +56,24 @@ namespace ICSharpCode.ILSpy.ViewModels @@ -57,14 +56,24 @@ namespace ICSharpCode.ILSpy.ViewModels
{
public const string PaneContentId = "DebugSteps";
/// <summary>
/// Whether the C# language should record the IL transforms of every member into its
/// <see cref="Stepper"/>. 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. Static because the language is MEF-shared and decompiles on
/// background tasks that have no view-model reference.
/// </summary>
public static bool IsRecording { get; private set; }
readonly LanguageService? languageService;
IDebugStepProvider? activeLanguage;
CSharpLanguage? activeLanguage;
int lastSelectedStep = int.MaxValue;
/// <summary>
/// App-wide ILAst writing options shared between the BlockIL language (which reads
/// them while emitting the transformed IL) and the DebugSteps view (whose four
/// 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). Static singleton state because the language is
/// MEF-shared and decompiles on background tasks that have no view-model reference.
/// </summary>
@ -74,12 +83,11 @@ namespace ICSharpCode.ILSpy.ViewModels @@ -74,12 +83,11 @@ namespace ICSharpCode.ILSpy.ViewModels
};
/// <summary>
/// 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.
/// </summary>
[ObservableProperty]
object? options;
public ILAstWritingOptions Options => WritingOptions;
/// <summary>
/// The recorded transform steps currently backing <see cref="Steps"/>. Tracked so that
@ -109,9 +117,9 @@ namespace ICSharpCode.ILSpy.ViewModels @@ -109,9 +117,9 @@ namespace ICSharpCode.ILSpy.ViewModels
StepNodeViewModel? selectedStep;
/// <summary>
/// True while the current language is an <see cref="IDebugStepProvider"/>. 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).
/// </summary>
[ObservableProperty]
bool isAvailable;
@ -174,7 +182,7 @@ namespace ICSharpCode.ILSpy.ViewModels @@ -174,7 +182,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 +218,13 @@ namespace ICSharpCode.ILSpy.ViewModels @@ -210,13 +218,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 +238,6 @@ namespace ICSharpCode.ILSpy.ViewModels @@ -230,7 +238,6 @@ namespace ICSharpCode.ILSpy.ViewModels
activeLanguage = language;
language.StepperUpdated += OnStepperUpdated;
SetStepsSource(language.Stepper.Steps);
Options = language.StepOptions;
IsAvailable = true;
}
@ -242,7 +249,6 @@ namespace ICSharpCode.ILSpy.ViewModels @@ -242,7 +249,6 @@ namespace ICSharpCode.ILSpy.ViewModels
{
activeLanguage.StepperUpdated -= OnStepperUpdated;
activeLanguage = null;
Options = null;
}
}
@ -385,6 +391,27 @@ namespace ICSharpCode.ILSpy.ViewModels @@ -385,6 +391,27 @@ namespace ICSharpCode.ILSpy.ViewModels
RequestRedecompile(lastSelectedStep, isDebug: false);
}
/// <summary>
/// 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 IL steps; disabling drops the
/// tree the language is still holding, which is where the retained ILAst lives.
/// </summary>
internal void SetRecordingEnabled(bool enabled)
{
if (IsRecording == enabled)
return;
IsRecording = enabled;
if (enabled)
{
RequestRedecompile(int.MaxValue, isDebug: false);
}
else
{
SetStepsSource(null);
activeLanguage?.ReleaseSteps();
}
}
void RequestRedecompile(int stepLimit, bool isDebug, int? highlightStep = null)
{
lastSelectedStep = stepLimit;

22
ILSpy/ViewModels/StepNodeViewModel.cs

@ -39,9 +39,17 @@ namespace ICSharpCode.ILSpy.ViewModels @@ -39,9 +39,17 @@ namespace ICSharpCode.ILSpy.ViewModels
{
public Stepper.Node Step { get; }
public StepNodeViewModel? Parent { get; }
public IReadOnlyList<StepNodeViewModel> Children { get; }
public string Description => Step.Description;
IReadOnlyList<StepNodeViewModel>? children;
/// <summary>
/// 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.
/// </summary>
public IReadOnlyList<StepNodeViewModel> Children => children ??= Wrap(Step.Children, this);
/// <summary>Two-way bound to the row's TreeViewItem.IsExpanded.</summary>
[ObservableProperty]
bool isExpanded;
@ -60,20 +68,16 @@ namespace ICSharpCode.ILSpy.ViewModels @@ -60,20 +68,16 @@ namespace ICSharpCode.ILSpy.ViewModels
{
Step = step;
Parent = parent;
var children = new List<StepNodeViewModel>(step.Children.Count);
foreach (var child in step.Children)
{
children.Add(new StepNodeViewModel(child, this));
}
Children = children;
}
public static IReadOnlyList<StepNodeViewModel> Wrap(IList<Stepper.Node> steps)
public static IReadOnlyList<StepNodeViewModel> Wrap(IList<Stepper.Node> steps) => Wrap(steps, null);
static IReadOnlyList<StepNodeViewModel> Wrap(IList<Stepper.Node> steps, StepNodeViewModel? parent)
{
var wrapped = new List<StepNodeViewModel>(steps.Count);
foreach (var step in steps)
{
wrapped.Add(new StepNodeViewModel(step, null));
wrapped.Add(new StepNodeViewModel(step, parent));
}
return wrapped;
}

39
ILSpy/Views/DebugSteps.axaml

@ -3,7 +3,6 @@ @@ -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,46 +12,38 @@ @@ -13,46 +12,38 @@
Margin="8" TextWrapping="Wrap" Opacity="0.7"
Text="Debug steps are not available for the current language." />
<DockPanel IsVisible="{Binding IsAvailable}">
<!-- Top row: language-contributed options on the left, filter box in the top-right corner.
Options are selected by type: ILAst renders its writing-options checkboxes; C# (null
Options) renders nothing, leaving just the filter box. -->
<!-- Top row: the ILAst writing options on the left, filter box in the top-right corner. The
options govern the ILAst dump an IL-phase step renders. -->
<DockPanel DockPanel.Dock="Top" Margin="2">
<TextBox DockPanel.Dock="Right" Width="160" Margin="4,0,0,0"
VerticalAlignment="Center" PlaceholderText="Filter steps"
Text="{Binding FilterText}" />
<ContentControl Content="{Binding Options}">
<ContentControl.DataTemplates>
<DataTemplate DataType="il:ILAstWritingOptions">
<StackPanel Orientation="Horizontal">
<CheckBox Margin="3" Content="Field sugar"
IsChecked="{Binding UseFieldSugar, Mode=TwoWay}" />
<CheckBox Margin="3" Content="Logic operation sugar"
IsChecked="{Binding UseLogicOperationSugar, Mode=TwoWay}" />
<CheckBox Margin="3" Content="Show IL ranges"
IsChecked="{Binding ShowILRanges, Mode=TwoWay}" />
<CheckBox Margin="3" Content="Show child index in block"
IsChecked="{Binding ShowChildIndexInBlock, Mode=TwoWay}" />
</StackPanel>
</DataTemplate>
</ContentControl.DataTemplates>
</ContentControl>
<StackPanel Orientation="Horizontal">
<CheckBox Margin="3" Content="Field sugar"
IsChecked="{Binding Options.UseFieldSugar, Mode=TwoWay}" />
<CheckBox Margin="3" Content="Logic operation sugar"
IsChecked="{Binding Options.UseLogicOperationSugar, Mode=TwoWay}" />
<CheckBox Margin="3" Content="Show IL ranges"
IsChecked="{Binding Options.ShowILRanges, Mode=TwoWay}" />
<CheckBox Margin="3" Content="Show child index in block"
IsChecked="{Binding Options.ShowChildIndexInBlock, Mode=TwoWay}" />
</StackPanel>
</DockPanel>
<TreeView Name="StepsTree"
ItemsSource="{Binding Steps}"
SelectedItem="{Binding SelectedStep, Mode=TwoWay}"
DoubleTapped="OnTreeDoubleTapped"
x:CompileBindings="False">
DoubleTapped="OnTreeDoubleTapped">
<TreeView.Styles>
<!-- Each row's visibility and expansion are owned by its StepNodeViewModel, where the
pane's filter logic maintains them; expansion is two-way so the user's expander
gestures flow back into the view-model instead of dying with the container. -->
<Style Selector="TreeViewItem">
<Style Selector="TreeViewItem" x:DataType="vm:StepNodeViewModel">
<Setter Property="IsVisible" Value="{Binding IsVisible}" />
<Setter Property="IsExpanded" Value="{Binding IsExpanded, Mode=TwoWay}" />
</Style>
</TreeView.Styles>
<TreeView.ItemTemplate>
<TreeDataTemplate ItemsSource="{Binding Children}">
<TreeDataTemplate DataType="vm:StepNodeViewModel" ItemsSource="{Binding Children}">
<TextBlock Text="{Binding Description}" />
</TreeDataTemplate>
</TreeView.ItemTemplate>

6
ILSpy/Views/DebugSteps.axaml.cs

@ -83,7 +83,12 @@ namespace ICSharpCode.ILSpy.Views @@ -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 @@ -91,6 +96,7 @@ namespace ICSharpCode.ILSpy.Views
if (attachedModel != null)
{
attachedModel.SelectionRevealRequested -= OnSelectionRevealRequested;
attachedModel.SetRecordingEnabled(false);
attachedModel = null;
}
}

Loading…
Cancel
Save