diff --git a/CLAUDE.md b/CLAUDE.md index f8c53b8c0..c4388a7c4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -90,6 +90,7 @@ Solutions & filters: `ILSpy.sln` builds everything; `ILSpy.XPlat.slnf` is the de - Always run the test suite with `--report-trx` so failures survive: `dotnet test --solution ILSpy.sln --report-trx` (the repo pins Microsoft.Testing.Platform in `global.json`; the bare `dotnet test ` form is the old VSTest syntax). Don't dismiss failures as flaky without first reproducing in isolation, then running repeatedly. - The decompiler test suite (test kinds, fixture structure, how to write tests, the compiler-matrix model) is documented in [ICSharpCode.Decompiler.Tests/CLAUDE.md](ICSharpCode.Decompiler.Tests/CLAUDE.md). - After matcher / rewriter edits, **run the relevant tests, not just the build.** `dotnet build` green ≠ behaviour correct. +- **To see what a transform did, dump the ILAst:** `ilspycmd -m --ilast` prints the IL transform pipeline's result, and `--after-transform ` stops the pipeline early so two stages can be diffed. Debug builds only (like the UI's ILAst language), so run it from a local build, not the installed tool. ## Investigating dependencies diff --git a/ICSharpCode.ILSpyCmd.Tests/ILAstOptionTests.cs b/ICSharpCode.ILSpyCmd.Tests/ILAstOptionTests.cs new file mode 100644 index 000000000..a4df43978 --- /dev/null +++ b/ICSharpCode.ILSpyCmd.Tests/ILAstOptionTests.cs @@ -0,0 +1,130 @@ +// 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. + +// The --ilast options exist in debug builds only, so this fixture compiles away with them. +#if DEBUG + +using System.Threading.Tasks; + +using NUnit.Framework; + +using static ICSharpCode.ILSpyCmd.Tests.CliTestRunner; + +namespace ICSharpCode.ILSpyCmd.Tests +{ + [TestFixture] + public class ILAstOptionTests + { + static readonly string testAssemblyPath = typeof(ILAstOptionTests).Assembly.Location; + + const string sumLoopId = "M:ICSharpCode.ILSpyCmd.Tests.ILAstSample.SumLoop(System.Int32)"; + + static Task<(int ExitCode, string Output, string Error)> RunILAstAsync(params string[] args) + { + string[] common = { testAssemblyPath, "--disable-updatecheck", "-m", sumLoopId }; + string[] all = new string[common.Length + args.Length]; + common.CopyTo(all, 0); + args.CopyTo(all, common.Length); + return RunAsync(all); + } + + [Test] + public async Task ILAstOfSelectedMethodIsWritten() + { + var result = await RunILAstAsync("--ilast"); + + Assert.That(result.ExitCode, Is.EqualTo(0), result.Error); + Assert.That(result.Output, Does.Contain(nameof(ILAstSample.SumLoop))); + Assert.That(result.Output, Does.Contain("ILFunction")); + } + + [Test] + public async Task StoppingAfterATransformYieldsDifferentILAst() + { + var full = await RunILAstAsync("--ilast"); + // ILInlining is the third transform of the pipeline; stopping there leaves the + // method as unstructured blocks, while the full run has loops and expressions. + var partial = await RunILAstAsync("--after-transform", "ILInlining"); + + Assert.That(full.ExitCode, Is.EqualTo(0), full.Error); + Assert.That(partial.ExitCode, Is.EqualTo(0), partial.Error); + Assert.That(partial.Output, Does.Contain(nameof(ILAstSample.SumLoop))); + Assert.That(partial.Output, Is.Not.EqualTo(full.Output)); + } + + [Test] + public async Task TransformCanBeSelectedByIndex() + { + var partial = await RunILAstAsync("--after-transform", "1"); + var full = await RunILAstAsync("--ilast"); + + Assert.That(partial.ExitCode, Is.EqualTo(0), partial.Error); + Assert.That(partial.Output, Is.Not.EqualTo(full.Output)); + } + + [Test] + public async Task UnknownTransformNameListsThePipeline() + { + var result = await RunILAstAsync("--after-transform", "NoSuchTransform"); + + Assert.That(result.ExitCode, Is.EqualTo(ProgramExitCodes.EX_USAGE)); + // the error must be actionable on its own: it lists the pipeline in run order + Assert.That(result.Error, Does.Contain("ControlFlowSimplification")); + Assert.That(result.Error, Does.Contain("AssignVariableNames")); + } + + [Test] + public async Task AmbiguousTransformNameReportsItsOccurrences() + { + // SplitVariables runs three times; the name alone cannot identify a stop point + var result = await RunILAstAsync("--after-transform", "SplitVariables"); + + Assert.That(result.ExitCode, Is.EqualTo(ProgramExitCodes.EX_USAGE)); + Assert.That(result.Error, Does.Contain("SplitVariables")); + Assert.That(result.Error, Does.Contain("2")); + } + + [Test] + public async Task WholeTypeCanBeDumped() + { + var result = await RunAsync(testAssemblyPath, "--disable-updatecheck", + "-t", "ICSharpCode.ILSpyCmd.Tests.ILAstSample", "--ilast"); + + Assert.That(result.ExitCode, Is.EqualTo(0), result.Error); + Assert.That(result.Output, Does.Contain(nameof(ILAstSample.SumLoop))); + Assert.That(result.Output, Does.Contain(nameof(ILAstSample.Identity))); + } + } + + public static class ILAstSample + { + public static int SumLoop(int n) + { + int sum = 0; + for (int i = 0; i < n; i++) + { + sum += i; + } + return sum; + } + + public static string Identity(string value) => value; + } +} + +#endif diff --git a/ICSharpCode.ILSpyCmd/ILAstDumper.cs b/ICSharpCode.ILSpyCmd/ILAstDumper.cs new file mode 100644 index 000000000..4924e0ffd --- /dev/null +++ b/ICSharpCode.ILSpyCmd/ILAstDumper.cs @@ -0,0 +1,157 @@ +// 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; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Reflection.Metadata; +using System.Threading; + +using ICSharpCode.Decompiler; +using ICSharpCode.Decompiler.CSharp; +using ICSharpCode.Decompiler.IL; +using ICSharpCode.Decompiler.IL.Transforms; +using ICSharpCode.Decompiler.Metadata; +using ICSharpCode.Decompiler.TypeSystem; + +namespace ICSharpCode.ILSpyCmd +{ + /// + /// Writes the decompiler's intermediate representation (ILAst) of a method body, optionally + /// stopping the IL transform pipeline after a chosen transform. This is the command-line + /// counterpart of the UI's "ILAst" language, and makes transform output diffable. + /// + class ILAstDumper + { + readonly IReadOnlyList transforms = CSharpDecompiler.GetILTransforms(); + readonly ILAstWritingOptions writingOptions = new ILAstWritingOptions(); + + /// + /// Names of the IL transforms, in the order they run. Transforms nested inside a + /// BlockILTransform (LoopDetection, ConditionDetection, the statement transforms, ...) + /// are not listed: they run as part of their containing entry and cannot be stopped + /// after individually. + /// + public static IReadOnlyList TransformNames { get; } = + CSharpDecompiler.GetILTransforms().Select(t => t.GetType().Name).ToArray(); + + public static int TransformCount => TransformNames.Count; + + /// + /// The pipeline as displayed to the user: one transform per line, prefixed by the + /// 1-based index that --after-transform accepts. + /// + public static string DescribePipeline() + { + return string.Join(Environment.NewLine, + TransformNames.Select((name, index) => $" {index + 1,3} {name}")); + } + + /// + /// Maps the value of --after-transform to the number of transforms to run. + /// Accepts a 1-based pipeline index, or a transform name if it occurs exactly once; + /// names that run repeatedly (SplitVariables, ControlFlowSimplification, ...) have to + /// be selected by index. + /// + public static bool TryResolveTransformCount(string nameOrIndex, out int count, out string error) + { + count = 0; + error = null; + string trimmed = nameOrIndex.Trim(); + + if (int.TryParse(trimmed, NumberStyles.None, CultureInfo.InvariantCulture, out int index)) + { + if (index < 1 || index > TransformCount) + { + error = $"'{trimmed}' is out of range; the pipeline has {TransformCount} transforms:{Environment.NewLine}{DescribePipeline()}"; + return false; + } + count = index; + return true; + } + + var occurrences = TransformNames + .Select((name, i) => (name, position: i + 1)) + .Where(t => string.Equals(t.name, trimmed, StringComparison.OrdinalIgnoreCase)) + .Select(t => t.position) + .ToArray(); + + if (occurrences.Length == 0) + { + error = $"Unknown transform '{trimmed}'. Pass one of these names, or its index:{Environment.NewLine}{DescribePipeline()}"; + return false; + } + if (occurrences.Length > 1) + { + error = $"'{trimmed}' runs {occurrences.Length} times, at index {string.Join(", ", occurrences)}. Pass the index of the occurrence to stop after."; + return false; + } + + count = occurrences[0]; + return true; + } + + /// + /// Writes the ILAst of a single method, or nothing at all if the method has no body + /// (abstract, extern or a runtime-provided implementation). + /// + public void WriteMethod(CSharpDecompiler decompiler, DecompilerSettings settings, IMethod method, + int transformCount, ITextOutput output, CancellationToken cancellationToken) + { + if (method.MetadataToken.IsNil || method.MetadataToken.Kind != HandleKind.MethodDefinition) + return; + var metadataFile = decompiler.TypeSystem.MainModule.MetadataFile; + var handle = (MethodDefinitionHandle)method.MetadataToken; + var methodDefinition = metadataFile.Metadata.GetMethodDefinition(handle); + if (!methodDefinition.HasBody()) + return; + + output.WriteLine($"// {method.FullName}"); + output.WriteLine($"// ILAst after {transformCount} of {TransformCount} transforms ({TransformNames[transformCount - 1]})"); + + var reader = new ILReader(decompiler.TypeSystem.MainModule) { + UseDebugSymbols = settings.UseDebugSymbols, + UseRefLocalsForAccurateOrderOfEvaluation = settings.UseRefLocalsForAccurateOrderOfEvaluation, + }; + var body = metadataFile.GetMethodBody(methodDefinition.RelativeVirtualAddress); + ILFunction function = reader.ReadIL(handle, body, kind: ILFunctionKind.TopLevelFunction, + cancellationToken: cancellationToken); + ILTransformContext context = decompiler.CreateILTransformContext(function); + try + { + function.RunTransforms(transforms.Take(transformCount), context); + } + catch (Exception ex) + { + // Showing how far the pipeline got is the point of this command, so a crashing + // transform prints its exception and then the partially transformed function + // rather than aborting the whole dump. + output.WriteLine(ex.ToString()); + output.WriteLine("// ILAst after the crash:"); + } + function.WriteTo(output, writingOptions); + output.WriteLine(); + output.WriteLine(); + } + } +} + +#endif diff --git a/ICSharpCode.ILSpyCmd/IlspyCmdProgram.cs b/ICSharpCode.ILSpyCmd/IlspyCmdProgram.cs index 9ecc66986..ad37d39e1 100644 --- a/ICSharpCode.ILSpyCmd/IlspyCmdProgram.cs +++ b/ICSharpCode.ILSpyCmd/IlspyCmdProgram.cs @@ -117,6 +117,17 @@ Examples: [Option("--il-sequence-points", "Show IL with sequence points. Implies -il.", CommandOptionType.NoValue)] public bool ShowILSequencePointsFlag { get; } +#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 + // 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; } + + [Option("--after-transform ", "Stop the IL transform pipeline after the named transform (or after the transform at the given 1-based pipeline index) and show the ILAst at that point. Implies --ilast. Pass an unknown name to list the pipeline.", CommandOptionType.SingleValue)] + public string AfterTransformName { get; } +#endif + [Option("-genpdb|--generate-pdb", "Generate PDB.", CommandOptionType.NoValue)] public bool CreateDebugInfoFlag { get; } @@ -347,6 +358,18 @@ Examples: return ShowIL(fileName, output); } +#if DEBUG + else if (ShowILAstFlag || AfterTransformName != null) + { + if (outputDirectory != null) + { + string outputName = Path.GetFileNameWithoutExtension(fileName); + output = File.CreateText(Path.Combine(outputDirectory, outputName) + ".ilast"); + } + + return ShowILAst(fileName, output, app); + } +#endif else if (CreateDebugInfoFlag) { string pdbFileName = null; @@ -627,6 +650,65 @@ Examples: return 0; } +#if DEBUG + int ShowILAst(string assemblyFileName, TextWriter output, CommandLineApplication app) + { + if (MemberIdString != null && TypeName != null) + { + app.Error.WriteLine("The --type and --member options are mutually exclusive."); + return ProgramExitCodes.EX_USAGE; + } + + int transformCount = ILAstDumper.TransformCount; + if (AfterTransformName != null + && !ILAstDumper.TryResolveTransformCount(AfterTransformName, out transformCount, out string transformError)) + { + app.Error.WriteLine(transformError); + return ProgramExitCodes.EX_USAGE; + } + + var settings = GetSettings(new PEFile(assemblyFileName)); + CSharpDecompiler decompiler = GetDecompiler(assemblyFileName); + IEnumerable methods; + + if (MemberIdString != null) + { + if (!TryResolveMember(decompiler.TypeSystem, MemberIdString, out EntityHandle handle, out string error)) + { + Console.Error.WriteLine(error); + return ProgramExitCodes.EX_DATAERR; + } + if (handle.Kind != HandleKind.MethodDefinition) + { + Console.Error.WriteLine($"'{MemberIdString}' does not name a method; ILAst exists for method bodies only."); + return ProgramExitCodes.EX_DATAERR; + } + methods = new[] { decompiler.TypeSystem.MainModule.GetDefinition((MethodDefinitionHandle)handle) }; + } + else if (TypeName != null) + { + if (!TryResolveType(decompiler.TypeSystem, TypeName, out ITypeDefinition typeDefinition, out string error)) + { + Console.Error.WriteLine(error); + return ProgramExitCodes.EX_DATAERR; + } + methods = typeDefinition.Methods; + } + else + { + methods = decompiler.TypeSystem.MainModule.TypeDefinitions.SelectMany(type => type.Methods); + } + + var textOutput = new PlainTextOutput(output); + var dumper = new ILAstDumper(); + foreach (var method in methods) + { + dumper.WriteMethod(decompiler, settings, method, transformCount, textOutput, CancellationToken.None); + } + return 0; + } +#endif + readonly List decompilationErrors = new(); ///