Browse Source

Merge pull request #4029 from icsharpcode/refactor/debug-steps-typed-il

Walk the whole decompiler pipeline in the Debug Steps pane
pull/4065/head
Siegfried Pammer 3 weeks ago committed by GitHub
parent
commit
8997904b72
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 2
      CLAUDE.md
  2. 366
      ICSharpCode.Decompiler.Tests/DebugStepRecordingTests.cs
  3. 34
      ICSharpCode.Decompiler.Tests/DecompilationErrorRecoveryTests.cs
  4. 57
      ICSharpCode.Decompiler.Tests/Helpers/StepperTesting.cs
  5. 158
      ICSharpCode.Decompiler/CSharp/CSharpDecompiler.cs
  6. 31
      ICSharpCode.Decompiler/DebugSteps/Stepper.cs
  7. 7
      ICSharpCode.Decompiler/IL/Instructions/ILFunction.cs
  8. 4
      ICSharpCode.ILSpyCmd/ILAstDumper.cs
  9. 2
      ICSharpCode.ILSpyCmd/IlspyCmdProgram.cs
  10. 10
      ILSpy.Tests/Editor/DecompilerViewTests.cs
  11. 321
      ILSpy.Tests/Views/DebugStepsTests.cs
  12. 25
      ILSpy/DecompilationOptions.cs
  13. 9
      ILSpy/Docking/DockWorkspace.cs
  14. 55
      ILSpy/Languages/CSharpLanguage.DebugSteps.cs
  15. 88
      ILSpy/Languages/CSharpLanguage.cs
  16. 52
      ILSpy/Languages/IDebugStepProvider.cs
  17. 201
      ILSpy/Languages/ILAstLanguage.cs
  18. 72
      ILSpy/Languages/TypedILLanguage.cs
  19. 6
      ILSpy/TextView/DecompilerTabPageModel.cs
  20. 139
      ILSpy/ViewModels/DebugStepsPaneModel.cs
  21. 58
      ILSpy/ViewModels/StepNodeViewModel.cs
  22. 48
      ILSpy/Views/DebugSteps.axaml
  23. 6
      ILSpy/Views/DebugSteps.axaml.cs

2
CLAUDE.md

@ -101,7 +101,7 @@ Never put more than one C# language feature on one branch. @@ -101,7 +101,7 @@ Never put more than one C# language feature on one branch.
- Always run the test suite with `--report-trx` so failures survive: `dotnet test --solution ILSpy.sln --report-trx` (the repo pins Microsoft.Testing.Platform in `global.json`; the bare `dotnet test <sln>` form is the old VSTest syntax). Don't dismiss failures as flaky without first reproducing in isolation, then running repeatedly.
- The decompiler test suite (test kinds, fixture structure, how to write tests, the compiler-matrix model) is documented in [ICSharpCode.Decompiler.Tests/CLAUDE.md](ICSharpCode.Decompiler.Tests/CLAUDE.md).
- After matcher / rewriter edits, **run the relevant tests, not just the build.** `dotnet build` green ≠ behaviour correct.
- **To see what a transform did, dump the ILAst:** `ilspycmd <assembly> -m <doc-id> --ilast` prints the IL transform pipeline's result, and `--after-transform <name-or-index>` stops the pipeline early so two stages can be diffed. Debug builds only (like the UI's ILAst language), so run it from a local build, not the installed tool.
- **To see what a transform did, dump the ILAst:** `ilspycmd <assembly> -m <doc-id> --ilast` prints the IL transform pipeline's result, and `--after-transform <name-or-index>` stops the pipeline early so two stages can be diffed. Debug builds only (like the UI's Debug Steps pane), so run it from a local build, not the installed tool.
## Investigating dependencies

366
ICSharpCode.Decompiler.Tests/DebugStepRecordingTests.cs

@ -0,0 +1,366 @@ @@ -0,0 +1,366 @@
// Copyright (c) 2026 Siegfried Pammer
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of this
// software and associated documentation files (the "Software"), to deal in the Software
// without restriction, including without limitation the rights to use, copy, modify, merge,
// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
// to whom the Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all copies or
// substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System;
using System.Collections.Generic;
using System.Linq;
using ICSharpCode.Decompiler.CSharp;
using ICSharpCode.Decompiler.DebugSteps;
using ICSharpCode.Decompiler.IL;
using ICSharpCode.Decompiler.Util;
using ICSharpCode.Decompiler.IL.Transforms;
using ICSharpCode.Decompiler.Metadata;
using ICSharpCode.Decompiler.Tests.Helpers;
using ICSharpCode.Decompiler.TypeSystem;
using NUnit.Framework;
namespace ICSharpCode.Decompiler.Tests
{
/// <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";
const string SeamStep = "C# AST built from ILAst";
static readonly FullTypeName SampleType =
new FullTypeName("ICSharpCode.Decompiler.CSharp.ProjectDecompiler.WholeProjectDecompiler");
[Test]
public void ILTransformStepsLandOnTheDecompilerStepper()
{
var decompiler = CreateRecordingDecompiler();
decompiler.ILTransforms.Add(new RecordingILTransform());
decompiler.DecompileTypeAsString(SampleType);
Assert.That(TreeTraversal.PreOrder(decompiler.Stepper.Steps, n => n.Children).Select(n => n.Description), Has.Some.Contains(RecordedStep));
}
/// <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(TreeTraversal.PreOrder(decompiler.Stepper.Steps, n => n.Children).Select(n => n.Description), 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>
/// A step index only means anything against the run that produced it, so there must not be a
/// second numbering to confuse it with: with recording off the pipeline counts nothing at all,
/// neither the IL transforms nor the C# AST transforms that used to be recorded regardless.
/// </summary>
[Test]
public void NothingIsRecordedWhileRecordingIsOff()
{
var decompiler = StepperTesting.CreateDecompiler();
decompiler.DecompileTypeAsString(SampleType);
using (Assert.EnterMultipleScope())
{
Assert.That(decompiler.RecordSteps, Is.False, "recording is off unless a caller asks for it");
Assert.That(decompiler.Stepper.Steps, Is.Empty, "no step may be recorded while recording is off");
Assert.That(decompiler.Stepper.CurrentStep, Is.Zero, "the step counter must not advance either");
}
}
/// <summary>
/// The crashed-member attribution compares the step counter against the limit, which is only
/// meaningful while the counter is actually counting. With recording off it sits at zero, so a
/// limit of zero would otherwise match at every throwing transform and hand the pane an
/// unrelated member's ILAst.
/// </summary>
[Test]
public void ACrashWhileRecordingIsOffAttributesNoHaltedFunction()
{
var decompiler = StepperTesting.CreateDecompiler();
decompiler.ILTransforms.Add(new StepperTesting.ThrowingILTransform("CleanUpFileName"));
decompiler.Stepper.StepLimit = 0;
decompiler.DecompileTypeAsString(SampleType);
Assert.That(decompiler.StepLimitHaltedFunction, Is.Null,
"a step limit cannot be reached by a run that records no steps");
}
/// <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>
/// The plain ExpressionBuilder/StatementBuilder output is a state worth looking at, so it has
/// a step of its own at the top level rather than being reachable only as "before the first
/// AST transform". Halting there has to print C#: the whole type is converted by then, so
/// nothing is left in ILAst.
/// </summary>
[Test]
public void TheStateAtTheSeamIsUntransformedCSharp()
{
if (!Stepper.SteppingAvailable)
Assert.Ignore("Transform stepping is compiled out without the STEP symbol, so there is no seam step.");
var decompiler = CreateRecordingDecompiler();
string full = decompiler.DecompileTypeAsString(SampleType);
var seam = decompiler.Stepper.Steps.SingleOrDefault(n => n.Description.Contains(SeamStep));
Assert.That(seam, Is.Not.Null, $"'{SeamStep}' must be recorded once, at the top level");
var replay = CreateRecordingDecompiler();
replay.Stepper.StepLimit = seam!.BeginStep;
string atSeam = replay.DecompileTypeAsString(SampleType);
using (Assert.EnterMultipleScope())
{
Assert.That(replay.StepLimitHaltedFunction, Is.Null,
"the seam is past every member's IL phase, so there is no halted function to dump");
Assert.That(atSeam, Does.Contain("class WholeProjectDecompiler"),
"halting at the seam still prints C#");
Assert.That(atSeam, Is.Not.EqualTo(full),
"the AST transforms have not run yet, so this cannot equal the finished output");
}
}
/// <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 = TreeTraversal.PreOrder(decompiler.Stepper.Steps, n => n.Children).Single(n => n.Description.Contains(DetachedStep)).BeginStep;
var replay = CreateRecordingDecompiler();
var replayStep = new DetachedFunctionILTransform("CleanUpFileName");
replay.ILTransforms.Add(replayStep);
replay.Stepper.StepLimit = haltAt;
replay.DecompileTypeAsString(SampleType);
Assert.That(replay.StepLimitHaltedFunction, Is.SameAs(replayStep.DetachedFunction));
}
/// <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 CSharpDecompiler CreateRecordingDecompiler()
{
var decompiler = StepperTesting.CreateDecompiler();
decompiler.RecordSteps = 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);
}
}
}
}

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

@ -0,0 +1,57 @@ @@ -0,0 +1,57 @@
// Copyright (c) 2026 Siegfried Pammer
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of this
// software and associated documentation files (the "Software"), to deal in the Software
// without restriction, including without limitation the rights to use, copy, modify, merge,
// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
// to whom the Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all copies or
// substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System;
using ICSharpCode.Decompiler.CSharp;
using ICSharpCode.Decompiler.IL;
using ICSharpCode.Decompiler.IL.Transforms;
using ICSharpCode.Decompiler.Metadata;
using ICSharpCode.Decompiler.TypeSystem;
namespace ICSharpCode.Decompiler.Tests.Helpers
{
/// <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()
{
return new CSharpDecompiler("ICSharpCode.Decompiler.dll",
new UniversalAssemblyResolver(null, false, null), new DecompilerSettings());
}
/// <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);
}
}
}
}

158
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,35 @@ namespace ICSharpCode.Decompiler.CSharp @@ -232,6 +235,35 @@ namespace ICSharpCode.Decompiler.CSharp
public Stepper Stepper { get; set; } = new Stepper();
/// <summary>
/// Gets or sets whether the pipeline records its steps into <see cref="Stepper"/>, so that one
/// step tree spans all of it: the IL transforms of every member, the ILAst-to-C# conversion,
/// and the C# AST transforms.
/// Off by default, and off means the whole pipeline records nothing: every phase keeps the
/// stepper its context creates for itself, which nothing reads. Turning it on is what makes the
/// steps <i>retained</i>, and each retained step pins the ILAst it captured, including the
/// instructions its transform removed. That is affordable for the single type a step view
/// displays and not for a whole-module decompile, so only callers that show the steps opt in.
/// A step index is only meaningful against a run with the same value: numbering the whole
/// pipeline and numbering nothing are not the same scale, so a full run and the step-limited
/// re-run that replays one of its indices have to agree on it.
/// Has no effect unless <see cref="Stepper.SteppingAvailable"/>.
/// </summary>
public bool RecordSteps { 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>
/// Returns all built-in transforms of the C# AST pipeline.
/// </summary>
@ -816,6 +848,10 @@ namespace ICSharpCode.Decompiler.CSharp @@ -816,6 +848,10 @@ namespace ICSharpCode.Decompiler.CSharp
// previous one stop counting - otherwise a reused instance reports them again against
// members that decompiled cleanly.
errors.Clear();
// Same reasoning for the halted function: a stale one would make the caller render the
// previous run's ILAst, and the "already halted" guard in DecompileBody would skip every
// member from here on.
StepLimitHaltedFunction = null;
List<INamespace> resolvedNamespaces = new List<INamespace>();
foreach (var ns in namespaces)
{
@ -841,20 +877,36 @@ namespace ICSharpCode.Decompiler.CSharp @@ -841,20 +877,36 @@ namespace ICSharpCode.Decompiler.CSharp
void RunTransforms(AstNode rootNode, DecompileRun decompileRun, ITypeResolveContext decompilationContext)
{
// The IL phase halted at the step limit, so this tree is missing the bodies it was supposed
// to transform and the caller renders the halted ILAst instead. Running the AST pipeline over
// it would only waste the work and hit the same limit again - and that second hit would
// overwrite Stepper.LimitReachedStep with a node from a tree nobody displays, losing the
// position the halted step is highlighted at.
if (StepLimitHaltedFunction != null)
return;
var typeSystemAstBuilder = CreateAstBuilder(decompileRun.Settings);
var context = new TransformContext(typeSystem, decompileRun, decompilationContext, typeSystemAstBuilder) {
Stepper = Stepper
};
var context = new TransformContext(typeSystem, decompileRun, decompilationContext, typeSystemAstBuilder);
// Off means off for the whole pipeline: leaving the AST half recording would give the same
// pipeline two numbering bases, and an index recorded under one of them means a different
// step under the other. Without this the context keeps a stepper of its own, which nothing
// reads.
if (RecordSteps)
context.Stepper = Stepper;
// The tree handed to the pipeline must already be well-formed; check it once up front so a
// malformed builder output is caught here rather than blamed on the first transform (DEBUG only).
rootNode.CheckInvariant();
bool traceTransforms = DecompilerEventSource.Log.IsTransformTracingEnabled();
try
{
// The whole type has been converted and nothing has transformed it yet, so this is the
// one index whose state is the plain ExpressionBuilder/StatementBuilder output. Without
// it that state is only reachable as "before the first AST transform", which names a
// transform rather than the thing being shown and sits after every member's group.
context.Step("C# AST built from ILAst", rootNode);
foreach (var transform in astTransforms)
{
CancellationToken.ThrowIfCancellationRequested();
context.StepStartGroup(transform.GetType().Name);
context.StepStartGroup(transform.GetType().Name, rootNode);
long traceStart = traceTransforms ? Stopwatch.GetTimestamp() : 0;
transform.Run(rootNode, context);
if (traceTransforms)
@ -2257,8 +2309,53 @@ namespace ICSharpCode.Decompiler.CSharp @@ -2257,8 +2309,53 @@ namespace ICSharpCode.Decompiler.CSharp
return method.ReturnType.Kind == TypeKind.Void && method.Name == "InitializeComponent" && method.DeclaringTypeDefinition!.GetNonInterfaceBaseTypes().Any(t => t.FullName == "System.Windows.Forms.Control");
}
/// <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>
/// <summary>
/// The function a halt belongs to, taken from the stepper rather than reconstructed. The step
/// the limit stopped on is the first choice; on a member's opening group step that step belongs
/// to the next member, so the last step actually recorded - the previous member's - is the
/// state the halt is showing. Following the instruction to its outermost function also covers
/// a step recorded in a helper the pipeline has not attached yet.
/// </summary>
ILFunction? HaltedStepFunction()
{
return FunctionOf(Stepper.LimitReachedStep) ?? FunctionOf(Stepper.LastStep);
static ILFunction? FunctionOf(Stepper.Node? step)
=> (step?.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.
ILFunction? function = null;
// Only the groups this member opens may be closed when an exception unwinds out of it: a
// caller that wrapped this call in a group of its own still owns that group afterwards.
int groupDepth = Stepper.GroupDepth;
try
{
var ilReader = new ILReader(typeSystem.MainModule) {
@ -2290,7 +2387,7 @@ namespace ICSharpCode.Decompiler.CSharp @@ -2290,7 +2387,7 @@ namespace ICSharpCode.Decompiler.CSharp
entityDecl.AddChild(body, Slots.Body);
return;
}
var function = ilReader.ReadIL((MethodDefinitionHandle)method.MetadataToken, methodBody, cancellationToken: CancellationToken);
function = ilReader.ReadIL((MethodDefinitionHandle)method.MetadataToken, methodBody, cancellationToken: CancellationToken);
function.CheckInvariant(ILPhase.Normal);
AddAnnotationsToDeclaration(method, entityDecl, function, parameterOffset);
@ -2309,17 +2406,14 @@ namespace ICSharpCode.Decompiler.CSharp @@ -2309,17 +2406,14 @@ namespace ICSharpCode.Decompiler.CSharp
CancellationToken = CancellationToken,
DecompileRun = decompileRun
};
foreach (var transform in ilTransforms)
{
CancellationToken.ThrowIfCancellationRequested();
transform.Run(function, context);
function.CheckInvariant(ILPhase.Normal);
// When decompiling definitions only, we can cancel decompilation of all steps
// after yield and async detection, because only those are needed to properly set
// IsAsync/IsIterator flags on ILFunction.
if (!localSettings.DecompileMemberBodies && transform is AsyncAwaitDecompiler)
break;
}
if (RecordSteps)
context.Stepper = Stepper;
// Deliberately unanchored: a member's opening step is where the *previous* member's
// state ends, so giving it this member's function would attribute a halt on the
// boundary to the member the pipeline is only about to start.
context.StepStartGroup(method.FullName);
function.RunTransforms(SelectILTransforms(localSettings.DecompileMemberBodies), context);
// Generate C# AST only if bodies should be displayed.
if (localSettings.DecompileMemberBodies)
@ -2333,6 +2427,10 @@ namespace ICSharpCode.Decompiler.CSharp @@ -2333,6 +2427,10 @@ namespace ICSharpCode.Decompiler.CSharp
decompileRun,
CancellationToken
);
// The seam between the two halves of the pipeline. Besides marking where the IL steps
// end and the C# AST steps begin, it is the only handle on the fully transformed
// ILAst: every IL step shows the state before some transform, never after the last.
context.Step("Convert ILAst to C#", function.Body);
body = statementBuilder.ConvertAsBlock(function.Body);
var warningAnchor = body.Statements.FirstOrDefault();
@ -2349,14 +2447,42 @@ namespace ICSharpCode.Decompiler.CSharp @@ -2349,14 +2447,42 @@ namespace ICSharpCode.Decompiler.CSharp
entityDecl.AddChild(body, Slots.Body);
}
context.StepEndGroup(keepIfEmpty: true);
CleanUpMethodDeclaration(entityDecl, body, function, localSettings.DecompileMemberBodies);
}
catch (StepLimitReachedException)
{
// A step limit reached in the IL phase stops the pipeline for good: this member and every
// member after it stay body-less, and the caller renders StepLimitHaltedFunction as ILAst
// rather than the C# it would otherwise print. This clause has to stay above the general
// handler below, which would turn the halt into an error comment and report the member as
// a decompilation failure.
// A halt on this member's opening group step is the state right after the previous member
// finished - the index the pane replays for "show state after" its last step - so that is
// the function to render, not the untouched IL of the member the exception unwound from.
// A step recorded on a helper function the pipeline has not attached yet (a proxy body, a
// nested function on its way into place) belongs to that function's own tree, which the
// member's function does not contain - so follow the halted step's instruction to the
// function that actually holds it.
StepLimitHaltedFunction = HaltedStepFunction() ?? function;
Stepper.EndOpenGroups(groupDepth);
}
catch (Exception innerException) when (!(innerException is OperationCanceledException))
{
// One method the decompiler cannot handle must not cost the user the type or, when
// exporting a project, the assembly around it: keep the signature, put the error in
// front of it, and let the remaining members decompile.
errors.Add(innerException as DecompilerException ?? new DecompilerException(module, method, innerException));
// The unwind left this member's step groups open; close them so the members after it are
// recorded as its siblings instead of disappearing into the group that failed.
Stepper.EndOpenGroups(groupDepth);
// A replay aiming at the state after the crashed group stops exactly here: the transform
// throws before any step can reach the limit, so without this the halt would be attributed
// to the next member and the half-transformed ILAst the crash left behind - the one thing
// worth looking at when debugging a throwing transform - would be unreachable.
if (RecordSteps && function != null && Stepper.CurrentStep == Stepper.StepLimit)
StepLimitHaltedFunction = function;
entityDecl.GetChild(Slots.Body)?.Remove();
if (settings.DecompileMemberBodies)
{

31
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,30 @@ namespace ICSharpCode.Decompiler.DebugSteps @@ -256,6 +263,30 @@ 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"/>, which every caller must state: a group that
/// was already open before the
/// unwinding code ran belongs to whoever opened it, not to the unwind.
/// </summary>
public void EndOpenGroups(int targetDepth)
{
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();

7
ICSharpCode.Decompiler/IL/Instructions/ILFunction.cs

@ -406,13 +406,16 @@ namespace ICSharpCode.Decompiler.IL @@ -406,13 +406,16 @@ namespace ICSharpCode.Decompiler.IL
foreach (var transform in transforms)
{
context.CancellationToken.ThrowIfCancellationRequested();
// 'near: this' is what lets a halt on a group opener be traced back to the function it
// belongs to; without a position the step carries no anchor and the halt cannot be
// attributed to any member.
if (transform is BlockILTransform blockTransform)
{
context.StepStartGroup(blockTransform.ToString());
context.StepStartGroup(blockTransform.ToString(), this);
}
else
{
context.StepStartGroup(transform.GetType().Name);
context.StepStartGroup(transform.GetType().Name, this);
}
long traceStart = traceTransforms ? System.Diagnostics.Stopwatch.GetTimestamp() : 0;
transform.Run(this, context);

4
ICSharpCode.ILSpyCmd/ILAstDumper.cs

@ -37,14 +37,14 @@ namespace ICSharpCode.ILSpyCmd @@ -37,14 +37,14 @@ namespace ICSharpCode.ILSpyCmd
/// <summary>
/// Writes the decompiler's intermediate representation (ILAst) of a method body, optionally
/// stopping the IL transform pipeline after a chosen transform. This is the command-line
/// counterpart of the UI's "ILAst" language, and makes transform output diffable.
/// counterpart of the UI's Debug Steps pane, and makes transform output diffable.
/// </summary>
class ILAstDumper
{
static readonly IReadOnlyList<IILTransform> transforms = CSharpDecompiler.GetILTransforms();
readonly ILAstWritingOptions writingOptions = new ILAstWritingOptions {
// same sugar as the UI's ILAst pane, so its output and this one are diffable
// same sugar as the Debug Steps pane's ILAst dump, so its output and this one are diffable
UseFieldSugar = true,
UseLogicOperationSugar = true,
};

2
ICSharpCode.ILSpyCmd/IlspyCmdProgram.cs

@ -119,7 +119,7 @@ Examples: @@ -119,7 +119,7 @@ Examples:
#if DEBUG
// ILAst is the decompiler's own working representation: it exists to debug transforms
// while developing ILSpy, so - like the UI's ILAst language - it ships in debug builds
// while developing ILSpy, so - like the UI's Debug Steps pane - it ships in debug builds
// only and is absent from the released tool.
[Option("--ilast", "Show the decompiler's intermediate representation (ILAst) of method bodies, after the full IL transform pipeline. Select what to dump with --type or --member; without either, every method of the assembly is dumped.", CommandOptionType.NoValue)]
public bool ShowILAstFlag { get; }

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
{

321
ILSpy.Tests/Views/DebugStepsTests.cs

@ -73,7 +73,7 @@ public class DebugStepsTests @@ -73,7 +73,7 @@ public class DebugStepsTests
// The default writing options match the WPF baseline: field sugar and
// logic-operation sugar on; IL ranges and child-index-in-block off. The four
// CheckBoxes in DebugSteps.axaml bind two-way against these defaults.
var options = DebugStepsPaneModel.WritingOptions;
var options = AppComposition.Current.GetExport<DebugStepsPaneModel>().WritingOptions;
options.UseFieldSugar.Should().BeTrue();
options.UseLogicOperationSugar.Should().BeTrue();
options.ShowILRanges.Should().BeFalse();
@ -82,19 +82,21 @@ public class DebugStepsTests @@ -82,19 +82,21 @@ public class DebugStepsTests
}
[AvaloniaTest]
public async Task Debug_Steps_VM_Populates_After_ILAst_Decompile_Regardless_Of_View_Lifecycle()
public async Task Debug_Steps_VM_Populates_After_Decompile_Regardless_Of_View_Lifecycle()
{
// End-to-end repro of the user-reported "Debug Steps pane is empty" bug:
// 1. Boot the window, load assemblies.
// 2. Select a method.
// 3. Switch the active language to BlockIL (ILAst).
// 4. Wait for the BlockIL decompile to finish — its OnStepperUpdated event fires.
// 5. Assert the DebugStepsPaneModel's Steps property is populated.
// 3. Wait for the decompile to finish — its OnStepperUpdated event fires.
// 4. Assert the DebugStepsPaneModel's Steps property is populated.
//
// Asserting against the VM (not the View) decouples this test from the dock layout's
// view-realisation timing — which is the whole point of the fix that moved state
// from the View into the VM. If `Steps` is populated, any view that binds to it (now
// or later) will render the correct content.
//
// Recording is what produces steps at all, and no view is realized to switch it on here.
AppComposition.Current.GetExport<DebugStepsPaneModel>().SetRecordingEnabled(true);
var window = AppComposition.Current.GetExport<MainWindow>();
window.Show();
@ -110,26 +112,26 @@ public class DebugStepsTests @@ -110,26 +112,26 @@ 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]
public async Task CSharp_DebugSteps_Are_Grouped_By_Ast_Transform()
{
// Steps exist only while the pane asks for them, and nothing realizes the pane's view here,
// so this test asks the same way the view does.
AppComposition.Current.GetExport<DebugStepsPaneModel>().SetRecordingEnabled(true);
var window = AppComposition.Current.GetExport<MainWindow>();
window.Show();
var vm = (MainWindowViewModel)window.DataContext!;
@ -152,16 +154,19 @@ public class DebugStepsTests @@ -152,16 +154,19 @@ public class DebugStepsTests
() => debugStepsVm.Steps?.Count > 0,
description: "DebugStepsPaneModel.Steps to be populated after the C# decompile");
var astTransformNames = CSharpDecompiler.GetAstTransforms()
.Select(transform => transform.GetType().Name)
.ToArray();
// The AST transforms close the tree, after the member groups of the IL half.
debugStepsVm.Steps!
.Select(step => StripStepNumber(step.Description))
.Should().Equal(astTransformNames,
"C# debug steps must use AST transforms as top-level groups");
.Should().EndWith(astTransformNames,
"C# debug steps must use AST transforms as the top-level groups of the C# half");
var transformGroupWithChanges = debugStepsVm.Steps!
.Where(step => astTransformNames.Contains(StripStepNumber(step.Description)))
.FirstOrDefault(step => step.Children.Count > 0);
transformGroupWithChanges.Should().NotBeNull(
"individual C# AST mutation steps must be nested under their transform group");
@ -186,16 +191,177 @@ public class DebugStepsTests @@ -186,16 +191,177 @@ public class DebugStepsTests
AssertPreciseHighlight(tab, "C# replay after a selected AST mutation step must locate the changed node");
debugStepsVm.Steps.Should().BeSameAs(collectedSteps,
"a step-limited C# replay must preserve the current full-run step tree and selection context");
}
[AvaloniaTest]
public async Task IL_Steps_Are_Recorded_Only_While_The_Pane_Asks_For_Them()
{
// Every retained step pins the ILAst it captured, which for one type runs to tens of thousands
// of nodes. A closed pane displays none of them, so the decompile must not collect any - not
// the IL transforms, and not the C# AST transforms either: a run that records half a pipeline
// would number its steps on a scale no displayed tree was recorded on.
var debugStepsVm = AppComposition.Current.GetExport<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;
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();
ICSharpCode.Decompiler.Util.TreeTraversal.PreOrder(csharp.Stepper.Steps, n => n.Children).Select(n => n.Description).Should().NotContain(
description => ilTransformNames.Contains(StripStepNumber(description)),
"a closed pane leaves the IL transforms unrecorded");
csharp.Stepper.Steps.Should().BeEmpty("a closed pane leaves the whole pipeline unrecorded");
}
[AvaloniaTest]
public async Task Closing_The_Pane_Releases_The_Steps_Even_When_Another_Language_Is_Current()
{
// The steps are pinned on the MEF-shared CSharpLanguage, not on whichever language happens to
// be selected. Open the pane on C#, switch to IL, close the pane: the release has to reach the
// C# language anyway, or its whole IL tree stays alive until the next full C# run.
var window = AppComposition.Current.GetExport<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;
var debugStepsVm = AppComposition.Current.GetExport<DebugStepsPaneModel>();
debugStepsVm.SetRecordingEnabled(true);
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();
await Waiters.WaitForAsync(
() => debugStepsVm.Steps?.Count > 0,
description: "DebugStepsPaneModel.Steps to be populated after the C# decompile");
languageService.CurrentLanguage = languageService.GetLanguage("IL");
Dispatcher.UIThread.RunJobs();
debugStepsVm.SetRecordingEnabled(false);
csharp.Stepper.Steps.Should().BeEmpty(
"closing the pane must release the recorded steps whatever language is selected");
}
[AvaloniaTest]
public async Task Opening_The_Pane_Does_Not_Redecompile_A_Language_That_Records_Nothing()
{
// Only the C# language records steps, so re-running any other language's decompile buys
// nothing and throws away the view the user is looking at.
var window = AppComposition.Current.GetExport<MainWindow>();
window.Show();
var vm = (MainWindowViewModel)window.DataContext!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3);
var debugStepsVm = AppComposition.Current.GetExport<DebugStepsPaneModel>();
debugStepsVm.SetRecordingEnabled(false);
var languageService = AppComposition.Current.GetExport<LanguageService>();
languageService.CurrentLanguage = languageService.GetLanguage("IL");
Dispatcher.UIThread.RunJobs();
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 tab = vm.DockWorkspace.ActiveDecompilerTab!;
tab.IsDecompiling.Should().BeFalse("the IL decompile the test awaited has finished");
debugStepsVm.SetRecordingEnabled(true);
tab.IsDecompiling.Should().BeFalse(
"opening the pane on a language that records no steps must leave the tab alone");
}
[AvaloniaTest]
public async Task A_Run_Finishing_After_The_Pane_Closed_Does_Not_Repin_Its_Steps()
{
// Closing the pane drops the tree, but the language still raises StepperUpdated at the end of
// every full run. Taking that update would pin the tree straight back into a pane nobody is
// looking at - which is the retention closing was meant to end.
var window = AppComposition.Current.GetExport<MainWindow>();
window.Show();
var vm = (MainWindowViewModel)window.DataContext!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3);
static string StripStepNumber(string description)
var languageService = AppComposition.Current.GetExport<LanguageService>();
var csharp = languageService.Languages.OfType<CSharpLanguage>().Single();
languageService.CurrentLanguage = csharp;
var debugStepsVm = AppComposition.Current.GetExport<DebugStepsPaneModel>();
debugStepsVm.SetRecordingEnabled(true);
var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"System.Linq", "System.Linq", "System.Linq.Enumerable");
typeNode.IsExpanded = true;
var first = typeNode.Children.OfType<MethodTreeNode>()
.First(m => m.MethodDefinition.Name == "Range");
vm.AssemblyTreeModel.SelectNode(first);
await vm.DockWorkspace.WaitForDecompiledTextAsync();
await Waiters.WaitForAsync(
() => debugStepsVm.Steps?.Count > 0,
description: "DebugStepsPaneModel.Steps to be populated after the C# decompile");
debugStepsVm.SetRecordingEnabled(false);
debugStepsVm.Steps.Should().BeNull("closing the pane drops the tree it was showing");
// A later full run raises StepperUpdated exactly the way the in-flight one would have.
var second = typeNode.Children.OfType<MethodTreeNode>()
.First(m => m.MethodDefinition.Name == "AsEnumerable");
vm.AssemblyTreeModel.SelectNode(second);
await vm.DockWorkspace.WaitForDecompiledTextAsync();
Dispatcher.UIThread.RunJobs();
debugStepsVm.Steps.Should().BeNull(
"a run finishing while the pane is closed must not pin its steps back into it");
}
[AvaloniaTest]
public async Task CSharp_DebugSteps_Cover_IL_Transforms_And_Replay_Renders_ILAst()
{
// Recording the IL half is what an open pane switches on; nothing realizes the pane's view
// here, so this test asks for it the same way the view does.
var debugStepsVm = AppComposition.Current.GetExport<DebugStepsPaneModel>();
debugStepsVm.SetRecordingEnabled(true);
try
{
await CoverILTransformsAndReplay();
}
finally
{
var separatorIndex = description.IndexOf(": ", StringComparison.Ordinal);
return separatorIndex >= 0 ? description[(separatorIndex + 2)..] : description;
debugStepsVm.SetRecordingEnabled(false);
}
}
[AvaloniaTest]
public async Task ILAst_DebugStep_Replay_Highlights_Changed_Instruction()
static async Task CoverILTransformsAndReplay()
{
var window = AppComposition.Current.GetExport<MainWindow>();
window.Show();
@ -203,8 +369,8 @@ public class DebugStepsTests @@ -203,8 +369,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 +383,49 @@ public class DebugStepsTests @@ -217,25 +383,49 @@ 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");
var beforeTransforms = topLevel.Take(topLevel.Length - astTransformNames.Length).ToArray();
beforeTransforms.Should().EndWith(new[] { "C# AST built from ILAst" },
"the seam closes the IL half, right before the first AST transform");
beforeTransforms.SkipLast(1)
.Should().OnlyContain(description => description.Contains("System.Linq.Enumerable"),
"nothing but the decompiled members' groups precedes the seam");
// Replaying an individual mutation step is what surfaces a single IL change; the leaf
// step's changed instruction (or a surviving ancestor) must map to a rendered text range.
var replayStep = FirstLeafStep(debugStepsVm.Steps!);
replayStep.Should().NotBeNull("the ILAst stepper must record individual mutation steps");
var replayStep = FirstLeafStep(memberGroup!.Children);
replayStep.Should().NotBeNull("the IL transforms must record individual mutation steps");
var collectedSteps = debugStepsVm.Steps;
var tab = vm.DockWorkspace.ActiveDecompilerTab!;
await tab.RestartDecompileWithStepLimit(replayStep!.BeginStep, isDebug: false, replayStep.BeginStep);
tab.Text.Should().NotBeNullOrWhiteSpace("ILAst replay before a selected step must still emit IL");
AssertPreciseHighlight(tab, "ILAst replay before a selected step must locate the changed instruction");
tab.Text.Should().NotBeNullOrWhiteSpace("replaying an IL-phase step must still emit output");
tab.SyntaxExtension.Should().Be(".il",
"an IL-phase step halts before there is any C#, so the editor shows the ILAst it stopped in");
AssertPreciseHighlight(tab, "an IL-phase replay must locate the changed instruction");
debugStepsVm.Steps.Should().BeSameAs(collectedSteps,
"a step-limited ILAst replay must not replace the full step tree shown by the pane");
"a step-limited replay must not replace the full step tree shown by the pane");
await tab.RestartDecompileWithStepLimit(replayStep.EndStep, isDebug: false, replayStep.BeginStep);
tab.Text.Should().NotBeNullOrWhiteSpace("ILAst replay after a selected step must still emit IL");
AssertPreciseHighlight(tab, "ILAst replay after a selected step must locate the changed instruction");
tab.Text.Should().NotBeNullOrWhiteSpace("replaying the state after an IL-phase step must still emit output");
AssertPreciseHighlight(tab, "an IL-phase replay must locate the changed instruction");
// The first leaf step that acts on a concrete instruction; a step whose Position is null
// (e.g. an empty transform group) has nothing to highlight and is not what a user replays.
@ -258,17 +448,23 @@ public class DebugStepsTests @@ -258,17 +448,23 @@ public class DebugStepsTests
}
[AvaloniaTest]
public Task ILAst_And_TypedIL_Languages_Are_Registered_In_Debug_Builds()
public Task Stepping_And_Language_Version_Selection_Coexist_On_The_CSharp_Language()
{
// Two ILAstLanguage subclasses: BlockILLanguage ("ILAst") drives the stepper,
// TypedILLanguage ("Typed IL") writes type-annotated raw IL without transforms.
// Both register via [Export(typeof(Language))]; the language picker uses them
// in addition to C# and the disassembler-IL language.
// Stepping belongs to the C# language, which offers language versions: selecting a version and
// walking the pipeline have to work at the same time, on the same language.
var languageService = AppComposition.Current.GetExport<LanguageService>();
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 +572,9 @@ public class DebugStepsTests @@ -376,9 +572,9 @@ public class DebugStepsTests
[AvaloniaTest]
public Task DebugSteps_View_Loads_With_Filter_Applied()
{
// Guards the filter wiring in the XAML -- the per-row style Setter bindings -- against a
// structural break that x:CompileBindings="False" would not catch at build time.
// Realising the view with a populated tree and a live filter must not throw.
// Guards the filter wiring in the XAML -- the per-row style Setter bindings, which apply to
// containers the compiler never sees typed. Realising the view with a populated tree and a
// live filter must not throw.
var vm = new DebugStepsPaneModel();
var group = new Stepper.Node("CombineQueryExpressions");
group.Children.Add(new Stepper.Node("3: Introduce query continuation"));
@ -394,6 +590,43 @@ public class DebugStepsTests @@ -394,6 +590,43 @@ public class DebugStepsTests
return Task.CompletedTask;
}
[AvaloniaTest]
public Task Writing_Option_CheckBoxes_Are_Bound_To_The_ILAst_Writing_Options()
{
// The checkboxes drive how a halted IL step's ILAst is rendered, through the options the pane
// owns: what the compiler cannot check is that the two-way path reaches the pane's own instance
// rather than a copy, and a dead binding here is a dead feature.
var vm = new DebugStepsPaneModel { IsAvailable = true };
var window = new Window { Width = 500, Height = 300, Content = new DebugSteps { DataContext = vm } };
window.Show();
Dispatcher.UIThread.RunJobs();
try
{
var fieldSugar = window.GetVisualDescendants().OfType<CheckBox>()
.Single(box => (box.Content as string) == "Field sugar");
fieldSugar.IsChecked.Should().Be(vm.WritingOptions.UseFieldSugar,
"the checkbox must show the current writing option");
fieldSugar.IsChecked = false;
Dispatcher.UIThread.RunJobs();
vm.WritingOptions.UseFieldSugar.Should().BeFalse(
"toggling the checkbox must reach the options the ILAst dump is written with");
}
finally
{
window.Close();
}
return Task.CompletedTask;
}
// Step descriptions are prefixed with their index ("42: Foo"); the name behind it is what
// identifies the transform that recorded them.
static string StripStepNumber(string description)
{
var separatorIndex = description.IndexOf(": ", StringComparison.Ordinal);
return separatorIndex >= 0 ? description[(separatorIndex + 2)..] : description;
}
// A replay highlight must land on the changed node, not merely be non-null: in bounds, not a
// flood of the whole document, and (unless it is a zero-length removal caret) on rendered code
// rather than whitespace. This is what keeps the ancestor fallback from silently widening every
@ -474,7 +707,7 @@ public class DebugStepsTests @@ -474,7 +707,7 @@ public class DebugStepsTests
[AvaloniaTest]
public Task Pane_Reports_Not_Available_For_Languages_Without_Debug_Steps()
{
// The step tree only makes sense for IDebugStepProvider languages (C#, ILAst, Typed IL).
// The step tree only makes sense for the C# language, whose pipeline records it.
// For the plain IL disassembler the pane must not keep showing the previous language's
// stale step tree (whose commands would trigger pointless re-decompiles); it reports
// unavailability so the view swaps in a "not available" message instead.

25
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,12 +71,21 @@ namespace ICSharpCode.ILSpy @@ -69,12 +71,21 @@ 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; }
/// <summary>
/// When true, the pipeline records its steps so a step view can display them. It travels with
/// the request rather than being read from the view: a step index only means anything against
/// a run recorded the same way, so the full run and the step-limited replay of one of its
/// indices have to agree on it, and the decompile that reads it happens on a background task.
/// </summary>
public bool RecordSteps { get; set; }
/// <summary>
/// Optional sink for whole-project decompilation progress. Wired onto
/// <see cref="ICSharpCode.Decompiler.CSharp.ProjectDecompiler.WholeProjectDecompiler.ProgressIndicator"/>

9
ILSpy/Docking/DockWorkspace.cs

@ -84,6 +84,15 @@ namespace ICSharpCode.ILSpy.Docking @@ -84,6 +84,15 @@ namespace ICSharpCode.ILSpy.Docking
/// behaviour (e.g. ShowOptionsCommand).</summary>
public IDocumentDock? Documents => factory.Documents;
/// <summary>
/// Whether decompiles started in this workspace record their transform steps. Set on the UI
/// thread by whoever displays them and copied into each run's <see cref="DecompilationOptions"/>,
/// so a background decompile never samples live view state - and every tab, including ones
/// opened later, records the same way. A step index only means anything against a run
/// recorded like the one the index was taken from.
/// </summary>
public bool RecordSteps { get; set; }
public IRelayCommand NavigateBackCommand { get; }
public IRelayCommand NavigateForwardCommand { get; }
public IRelayCommand<NavigationEntry> NavigateToHistoryCommand { get; }

55
ILSpy/Languages/CSharpLanguage.DebugSteps.cs

@ -23,6 +23,7 @@ using System; @@ -23,6 +23,7 @@ using System;
using ICSharpCode.Decompiler;
using ICSharpCode.Decompiler.CSharp;
using ICSharpCode.Decompiler.DebugSteps;
using ICSharpCode.Decompiler.IL;
using ICSharpCode.ILSpy.AppEnv;
using ICSharpCode.ILSpy.Docking;
@ -32,12 +33,13 @@ using ICSharpCode.ILSpy.ViewModels; @@ -32,12 +33,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 +47,48 @@ namespace ICSharpCode.ILSpy.Languages @@ -45,13 +47,48 @@ namespace ICSharpCode.ILSpy.Languages
public event EventHandler? StepperUpdated;
// The C# AST step view has no options of its own (yet); the pane shows nothing above the
// tree for C#, unlike ILAst's writing-options checkboxes.
public object? StepOptions => null;
/// <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>
private static partial bool TryWriteILAst(ITextOutput output, DecompilationOptions options, CSharpDecompiler decompiler)
{
if (decompiler.StepLimitHaltedFunction is not { } function)
return false;
if (output is AvaloniaEditTextOutput avaloniaOutput)
{
// The dump is IL, not C#; without this the editor would highlight it as C#.
avaloniaOutput.SyntaxExtensionOverride = ".il";
}
output.WriteLine();
function.WriteTo(output, DebugStepsPane?.WritingOptions ?? new ILAstWritingOptions());
return true;
}
/// <summary>
/// The Debug Steps pane, or null before composition is up (design-time previews) and in the
/// window between startup and the pane being created. It is a [Shared] export, so a decompile
/// running on a background task resolves the same instance the view is bound to.
/// </summary>
static DebugStepsPaneModel? DebugStepsPane => AppComposition.TryGetExport<DebugStepsPaneModel>();
/// <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 {

88
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 record into the same
// stepper as the C# AST transforms. Which runs record is decided by whoever started the
// decompile, not by the pane's state at the moment this runs on a background task.
decompiler.RecordSteps = options.RecordSteps;
if (options.EscapeInvalidIdentifiers)
decompiler.AstTransforms.Add(new EscapeInvalidIdentifiers());
return decompiler;
@ -354,11 +358,11 @@ namespace ICSharpCode.ILSpy.Languages @@ -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,54 @@ namespace ICSharpCode.ILSpy.Languages @@ -732,38 +736,54 @@ namespace ICSharpCode.ILSpy.Languages
}
}
static void WriteCode(ITextOutput output, DecompilationOptions options, SyntaxTree syntaxTree, Stepper stepper)
static void WriteCode(ITextOutput output, DecompilationOptions options, SyntaxTree syntaxTree, CSharpDecompiler decompiler)
{
var settings = options.DecompilerSettings;
syntaxTree.AcceptVisitor(new InsertParenthesesVisitor { InsertParenthesesForReadability = true });
output.IndentationString = settings.CSharpFormattingOptions.IndentationString;
TokenWriter tokenWriter = new TextTokenWriter(output, settings);
// Node-range tracking (NodeLookup) is only consumed by the debug-step highlighter, which
// resolves nothing without a step limit. Skip it on a normal decompile so the common path
// doesn't pay the per-node/per-annotation bookkeeping; AvaloniaEditTextOutput is an
// ISmartTextOutput, so the branch below still gives it full syntax highlighting.
if (output is TextView.AvaloniaEditTextOutput avaloniaOutput && options.StepLimit != int.MaxValue)
tokenWriter = new CSharpHighlightingTokenWriter(tokenWriter, avaloniaOutput);
else if (output is TextView.ISmartTextOutput smartOutput)
tokenWriter = new CSharpHighlightingTokenWriter(tokenWriter, smartOutput);
// For the on-screen C# view, harvest the IL-offset/line map for body bookmarks during this
// single formatting pass (see Bookmarks.BookmarkDebugInfoCollector). The collector is the
// outermost writer so its StartNode sees each node's start line before any token is written.
// Other outputs (IL view, ilspycmd's plain text) are not AvaloniaEditTextOutput and are unaffected.
Bookmarks.BookmarkDebugInfoCollector? bookmarkCollector = null;
if (output is TextView.AvaloniaEditTextOutput bookmarkOutput)
tokenWriter = bookmarkCollector = new Bookmarks.BookmarkDebugInfoCollector(tokenWriter, bookmarkOutput);
syntaxTree.AcceptVisitor(new CSharpOutputVisitor(tokenWriter, settings.CSharpFormattingOptions));
bookmarkCollector?.Publish();
if (!TryWriteILAst(output, options, decompiler))
{
var settings = options.DecompilerSettings;
syntaxTree.AcceptVisitor(new InsertParenthesesVisitor { InsertParenthesesForReadability = true });
output.IndentationString = settings.CSharpFormattingOptions.IndentationString;
TokenWriter tokenWriter = new TextTokenWriter(output, settings);
// Node-range tracking (NodeLookup) is only consumed by the debug-step highlighter, which
// resolves nothing without a step limit. Skip it on a normal decompile so the common path
// doesn't pay the per-node/per-annotation bookkeeping; AvaloniaEditTextOutput is an
// ISmartTextOutput, so the branch below still gives it full syntax highlighting.
if (output is TextView.AvaloniaEditTextOutput avaloniaOutput && options.StepLimit != int.MaxValue)
tokenWriter = new CSharpHighlightingTokenWriter(tokenWriter, avaloniaOutput);
else if (output is TextView.ISmartTextOutput smartOutput)
tokenWriter = new CSharpHighlightingTokenWriter(tokenWriter, smartOutput);
// For the on-screen C# view, harvest the IL-offset/line map for body bookmarks during this
// single formatting pass (see Bookmarks.BookmarkDebugInfoCollector). The collector is the
// outermost writer so its StartNode sees each node's start line before any token is written.
// Other outputs (IL view, ilspycmd's plain text) are not AvaloniaEditTextOutput and are unaffected.
Bookmarks.BookmarkDebugInfoCollector? bookmarkCollector = null;
if (output is TextView.AvaloniaEditTextOutput bookmarkOutput)
tokenWriter = bookmarkCollector = new Bookmarks.BookmarkDebugInfoCollector(tokenWriter, bookmarkOutput);
syntaxTree.AcceptVisitor(new CSharpOutputVisitor(tokenWriter, settings.CSharpFormattingOptions));
bookmarkCollector?.Publish();
}
// Shared by both renderings: ILInstruction.WriteTo marks its node ranges the same way the
// C# token writer marks the AST's, so the highlighter resolves a step against whichever
// text was just written.
if (output is TextView.AvaloniaEditTextOutput nodeOutput
&& TextView.DebugStepHighlighter.TryResolve(stepper, options.StepLimit, options.HighlightStep, nodeOutput.NodeLookup, out var range))
&& TextView.DebugStepHighlighter.TryResolve(decompiler.Stepper, options.StepLimit, options.HighlightStep, nodeOutput.NodeLookup, out var range))
{
nodeOutput.DebugStepHighlight = range;
}
}
// Writes the ILAst the pipeline was halted in, when a Debug Steps replay stopped it before
// there was any C# to show, and reports whether it wrote anything. Implemented under DEBUG in
// CSharpLanguage.DebugSteps.cs; in Release nothing sets a step limit, so it never writes.
private static partial bool TryWriteILAst(ITextOutput output, DecompilationOptions options, CSharpDecompiler decompiler);
#if !DEBUG
private static partial bool TryWriteILAst(ITextOutput output, DecompilationOptions options, CSharpDecompiler decompiler)
=> false;
#endif
void AddWarningMessage(MetadataFile module, ITextOutput output, string line1, string? line2 = null,
string? buttonText = null, global::Avalonia.Media.IImage? buttonImage = null,
System.EventHandler<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 Siegfried Pammer
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of this
// software and associated documentation files (the "Software"), to deal in the Software
// without restriction, including without limitation the rights to use, copy, modify, merge,
// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
// to whom the Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all copies or
// substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
#if DEBUG
using System.Composition;
using ICSharpCode.Decompiler;
using ICSharpCode.Decompiler.Disassembler;
using ICSharpCode.Decompiler.IL;
using ICSharpCode.Decompiler.TypeSystem;
using ICSharpCode.ILSpyX;
using SRM = System.Reflection.Metadata;
namespace ICSharpCode.ILSpy.Languages
{
/// <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

6
ILSpy/TextView/DecompilerTabPageModel.cs

@ -395,6 +395,7 @@ namespace ICSharpCode.ILSpy.TextView @@ -395,6 +395,7 @@ namespace ICSharpCode.ILSpy.TextView
int? pendingHighlightStep;
bool pendingIsDebug;
/// <summary>
/// Output-length safety limits (characters): a decompile that produces more than the active
/// limit is stopped and replaced with a "too much code" message rather than hanging/OOMing the
@ -559,6 +560,10 @@ namespace ICSharpCode.ILSpy.TextView @@ -559,6 +560,10 @@ namespace ICSharpCode.ILSpy.TextView
var stepLimit = pendingStepLimit;
var highlightStep = pendingHighlightStep;
var isDebug = pendingIsDebug;
// Unlike the per-run overrides above, this is not reset: it describes the tab, and
// every run has to record the same way or a step index picked from one tree would
// select a different step on replay.
var recordSteps = AppEnv.AppComposition.TryGetExport<Docking.DockWorkspace>()?.RecordSteps ?? false;
pendingStepLimit = int.MaxValue;
pendingHighlightStep = null;
pendingIsDebug = false;
@ -580,6 +585,7 @@ namespace ICSharpCode.ILSpy.TextView @@ -580,6 +585,7 @@ namespace ICSharpCode.ILSpy.TextView
StepLimit = stepLimit,
HighlightStep = highlightStep,
IsDebug = isDebug,
RecordSteps = recordSteps,
};
try
{

139
ILSpy/ViewModels/DebugStepsPaneModel.cs

@ -22,6 +22,7 @@ using System; @@ -22,6 +22,7 @@ using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Composition;
using System.Linq;
using Avalonia.Threading;
@ -40,15 +41,14 @@ using ICSharpCode.ILSpy.Util; @@ -40,15 +41,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,29 +57,38 @@ namespace ICSharpCode.ILSpy.ViewModels @@ -57,29 +57,38 @@ 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. Reached from the C# language through composition: the pane is a
/// [Shared] export, so the background decompile resolves this very instance.
/// </summary>
public 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
/// checkboxes toggle their values). Static singleton state because the language is
/// MEF-shared and decompiles on background tasks that have no view-model reference.
/// App-wide ILAst writing options shared between the C# language (which reads them while
/// emitting the ILAst a halted IL step stopped in) and the DebugSteps view (whose four
/// checkboxes toggle their values). Reached from the C# language through composition, the same
/// way <see cref="IsRecording"/> is.
/// </summary>
public static ILAstWritingOptions WritingOptions { get; } = new() {
public ILAstWritingOptions WritingOptions { get; } = new() {
UseFieldSugar = true,
UseLogicOperationSugar = true,
};
/// <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 +118,9 @@ namespace ICSharpCode.ILSpy.ViewModels @@ -109,9 +118,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 +183,7 @@ namespace ICSharpCode.ILSpy.ViewModels @@ -174,7 +183,7 @@ namespace ICSharpCode.ILSpy.ViewModels
// ToolPaneRegistry which materialises this VM which would import DockWorkspace).
// Lazy lookup at command-execution time breaks the cycle.
// Language flips go through LanguageService.CurrentLanguage; the BlockILLanguage
// Language flips go through LanguageService.CurrentLanguage; the C# language
// pumps StepperUpdated when its decompile finishes. The selection-changed event
// is the signal that the user picked a new tree node — clear the step list so
// the previous run's nodes aren't shown against a fresh selection. All three
@ -210,13 +219,13 @@ namespace ICSharpCode.ILSpy.ViewModels @@ -210,13 +219,13 @@ namespace ICSharpCode.ILSpy.ViewModels
void TryAttachToCurrentLanguage()
{
if (languageService?.CurrentLanguage is IDebugStepProvider il)
AttachToLanguage(il);
if (languageService?.CurrentLanguage is CSharpLanguage csharp)
AttachToLanguage(csharp);
else
DetachFromLanguage();
}
void AttachToLanguage(IDebugStepProvider language)
void AttachToLanguage(CSharpLanguage language)
{
if (ReferenceEquals(activeLanguage, language))
{
@ -230,7 +239,6 @@ namespace ICSharpCode.ILSpy.ViewModels @@ -230,7 +239,6 @@ namespace ICSharpCode.ILSpy.ViewModels
activeLanguage = language;
language.StepperUpdated += OnStepperUpdated;
SetStepsSource(language.Stepper.Steps);
Options = language.StepOptions;
IsAvailable = true;
}
@ -242,13 +250,17 @@ namespace ICSharpCode.ILSpy.ViewModels @@ -242,13 +250,17 @@ namespace ICSharpCode.ILSpy.ViewModels
{
activeLanguage.StepperUpdated -= OnStepperUpdated;
activeLanguage = null;
Options = null;
}
}
void OnStepperUpdated(object? sender, System.EventArgs e)
{
Dispatcher.UIThread.Post(() => {
// A run that started while the pane was open can finish after it closed. Taking its
// update would pin the tree straight back into a pane nobody is looking at, undoing
// the release that closing just performed.
if (!IsRecording)
return;
if (activeLanguage != null)
{
SetStepsSource(activeLanguage.Stepper.Steps);
@ -322,6 +334,9 @@ namespace ICSharpCode.ILSpy.ViewModels @@ -322,6 +334,9 @@ namespace ICSharpCode.ILSpy.ViewModels
static void SnapshotExpansion(StepNodeViewModel node)
{
node.ExpansionBeforeFilter = node.IsExpanded;
// A subtree nobody has expanded has no wrappers and so no expansion state to save.
if (!node.HasMaterializedChildren)
return;
foreach (var child in node.Children)
SnapshotExpansion(child);
}
@ -332,6 +347,8 @@ namespace ICSharpCode.ILSpy.ViewModels @@ -332,6 +347,8 @@ namespace ICSharpCode.ILSpy.ViewModels
if (node.ExpansionBeforeFilter is bool expanded)
node.IsExpanded = expanded;
node.ExpansionBeforeFilter = null;
if (!node.HasMaterializedChildren)
return;
foreach (var child in node.Children)
RestoreExpansion(child);
}
@ -343,16 +360,33 @@ namespace ICSharpCode.ILSpy.ViewModels @@ -343,16 +360,33 @@ namespace ICSharpCode.ILSpy.ViewModels
/// </summary>
static bool ApplyFilterToNode(StepNodeViewModel node, string filter)
{
bool descendantMatches = false;
foreach (var child in node.Children)
descendantMatches |= ApplyFilterToNode(child, filter);
bool selfMatches = node.Description.Contains(filter, System.StringComparison.OrdinalIgnoreCase);
bool selfMatches = Matches(node.Description, filter);
// Ask the recorded steps, not the wrappers: a subtree with no match stays hidden, and
// wrapping it only to hide it would put a view-model on the UI thread for every step of a
// recorded type - tens of thousands of them, on the first keystroke.
bool descendantMatches = node.Step.Children.Any(step => SubtreeMatches(step, filter));
node.IsVisible = selfMatches || descendantMatches;
if (descendantMatches)
node.IsExpanded = true;
// Descend into wrappers that exist either because the path is being revealed or because
// an earlier pass built them: those carry visibility state that has to be corrected.
if (descendantMatches || node.HasMaterializedChildren)
{
foreach (var child in node.Children)
ApplyFilterToNode(child, filter);
}
return selfMatches || descendantMatches;
}
static bool SubtreeMatches(Stepper.Node step, string filter)
{
return Matches(step.Description, filter)
|| step.Children.Any(child => SubtreeMatches(child, filter));
}
static bool Matches(string description, string filter)
=> description.Contains(filter, System.StringComparison.OrdinalIgnoreCase);
void OnSelectionChanged(object? sender, AssemblyTreeSelectionChangedEventArgs e)
{
// User picked a new tree node — the previous run's stepper is stale until the next
@ -385,12 +419,51 @@ namespace ICSharpCode.ILSpy.ViewModels @@ -385,12 +419,51 @@ namespace ICSharpCode.ILSpy.ViewModels
RequestRedecompile(lastSelectedStep, isDebug: false);
}
/// <summary>
/// The C# language whether or not it is the current one. The steps are pinned on the MEF-shared
/// language instance, so releasing them cannot depend on which language happens to be selected:
/// switching to IL detaches this pane but leaves the recorded tree exactly where it was.
/// </summary>
CSharpLanguage? StepRecordingLanguage =>
activeLanguage ?? languageService?.Languages.OfType<CSharpLanguage>().FirstOrDefault();
/// <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 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;
// The workspace carries the flag into every run it starts, including the ones the user
// triggers by selecting another node, so closing the pane has to clear it there too.
if (AppComposition.TryGetExport<DockWorkspace>() is { } workspace)
workspace.RecordSteps = enabled;
if (enabled)
{
// C# is the only language that records anything, so re-running any other one would
// throw away the view the user is looking at to produce a tree that stays empty.
if (activeLanguage != null)
RequestRedecompile(int.MaxValue, isDebug: false);
}
else
{
SetStepsSource(null);
StepRecordingLanguage?.ReleaseSteps();
}
}
void RequestRedecompile(int stepLimit, bool isDebug, int? highlightStep = null)
{
lastSelectedStep = stepLimit;
// Composition unavailable in design-time previews; the gesture is a no-op there.
var dock = AppComposition.TryGetExport<DockWorkspace>();
dock?.ActiveDecompilerTab?.RestartDecompileWithStepLimit(stepLimit, isDebug, highlightStep);
if (dock == null)
return;
dock.RecordSteps = IsRecording;
dock.ActiveDecompilerTab?.RestartDecompileWithStepLimit(stepLimit, isDebug, highlightStep);
}
}
}

58
ILSpy/ViewModels/StepNodeViewModel.cs

@ -23,6 +23,8 @@ using System.Collections.Generic; @@ -23,6 +23,8 @@ using System.Collections.Generic;
using CommunityToolkit.Mvvm.ComponentModel;
using ICSharpCode.Decompiler.DebugSteps;
using ICSharpCode.Decompiler.CSharp.Syntax;
using ICSharpCode.Decompiler.IL;
namespace ICSharpCode.ILSpy.ViewModels
{
@ -39,9 +41,51 @@ namespace ICSharpCode.ILSpy.ViewModels @@ -39,9 +41,51 @@ namespace ICSharpCode.ILSpy.ViewModels
{
public Stepper.Node Step { get; }
public StepNodeViewModel? Parent { get; }
public IReadOnlyList<StepNodeViewModel> Children { get; }
public string Description => Step.Description;
/// <summary>
/// Which half of the pipeline the step belongs to, shown beside the description so the seam is
/// visible at a glance. Taken from what the step points at: the IL transforms anchor their
/// steps to instructions, the C# ones to syntax nodes. Empty when a step carries no anchor.
/// </summary>
public string Phase => PhaseOf(Step);
static string PhaseOf(Stepper.Node step)
{
switch (step.Position)
{
case ILInstruction:
return "IL";
case AstNode:
return "C#";
}
// A group opener carries no position of its own when anchoring it would misattribute a
// halt landing on it, so take the phase from the first step underneath that has one.
foreach (var child in step.Children)
{
string phase = PhaseOf(child);
if (phase.Length > 0)
return phase;
}
return string.Empty;
}
IReadOnlyList<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>
/// Whether <see cref="Children"/> has been built. Lets a walk that only needs to fix up state
/// it previously set skip the subtrees nobody has looked at, instead of wrapping them to find
/// out there is nothing to fix.
/// </summary>
internal bool HasMaterializedChildren => children != null;
/// <summary>Two-way bound to the row's TreeViewItem.IsExpanded.</summary>
[ObservableProperty]
bool isExpanded;
@ -60,20 +104,16 @@ namespace ICSharpCode.ILSpy.ViewModels @@ -60,20 +104,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;
}

48
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,47 +12,46 @@ @@ -13,47 +12,46 @@
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}">
<TextBlock Text="{Binding Description}" />
<TreeDataTemplate DataType="vm:StepNodeViewModel" ItemsSource="{Binding Children}">
<StackPanel Orientation="Horizontal" Spacing="6">
<Border Background="{DynamicResource ThemeControlMidBrush}"
CornerRadius="3" Padding="4,0" VerticalAlignment="Center"
IsVisible="{Binding Phase, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock Text="{Binding Phase}" FontSize="10" />
</Border>
<TextBlock Text="{Binding Description}" />
</StackPanel>
</TreeDataTemplate>
</TreeView.ItemTemplate>
<TreeView.ContextMenu>

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