From 259c8434839964a8d3c7c5f5ab7b516e65a4f366 Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Sat, 15 Aug 2026 14:25:07 +0200 Subject: [PATCH 1/2] Add ILAst output to ilspycmd debug builds The decompiler's intermediate representation was reachable only through the GUI's ILAst language, so anyone debugging a transform or a matcher had to do it by hand in the UI. --ilast writes the same representation to stdout, and --after-transform truncates the pipeline at a chosen point, which makes the effect of a single transform diffable and scriptable. Debug-only, like the UI language it mirrors: ILAst serves ILSpy's own development, not the users of the released tool, so it stays out of the shipped NuGet package and out of the README's option list. Nested per-block transforms stay unaddressable: they run inside a BlockILTransform entry, and reaching them needs the Stepper, which is compiled out of release builds anyway. Assisted-by: Claude:claude-opus-5[1m]:Claude Code --- CLAUDE.md | 1 + .../ILAstOptionTests.cs | 130 +++++++++++++++ ICSharpCode.ILSpyCmd/ILAstDumper.cs | 157 ++++++++++++++++++ ICSharpCode.ILSpyCmd/IlspyCmdProgram.cs | 82 +++++++++ 4 files changed, 370 insertions(+) create mode 100644 ICSharpCode.ILSpyCmd.Tests/ILAstOptionTests.cs create mode 100644 ICSharpCode.ILSpyCmd/ILAstDumper.cs diff --git a/CLAUDE.md b/CLAUDE.md index f8c53b8c09..c4388a7c44 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 0000000000..a4df43978f --- /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 0000000000..4924e0ffd2 --- /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 9ecc669868..ad37d39e10 100644 --- a/ICSharpCode.ILSpyCmd/IlspyCmdProgram.cs +++ b/ICSharpCode.ILSpyCmd/IlspyCmdProgram.cs @@ -117,6 +117,17 @@ class ILSpyCmdProgram [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 @@ int PerformPerFileAction(string fileName) 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 @@ int ShowIL(string assemblyFileName, TextWriter output) 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(); /// From 0566a815107ab8074c8e9fd852f9ad870c56f03d Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Sun, 16 Aug 2026 12:14:05 +0200 Subject: [PATCH 2/2] Make the ILAst dump faithful to what the decompiler runs The dump is only useful if it shows the same thing the real pipeline is fed and covers everything a transform bug can hide in, so: accessor bodies are reached through the metadata handles (ITypeDefinition.Methods hides every method that has method semantics), the PDB reaches the ILReader (UseDebugSymbols alone is inert without DebugInfo), and the writing options carry the same sugar as the UI's ILAst pane. A method whose body cannot be read, transformed or written no longer aborts the run, and the failure now travels through the decompilation-error path, so a crashing transform is visible in stderr and in the exit code instead of being buried in the output a script just collected. BlockILTransform entries name the transforms they contain: two of them ran as identical rows before, and asking for a nested transform by name reported it as unknown while the listing did in fact run it. Assisted-by: Claude:claude-opus-5[1m]:Claude Code --- .../ILAstOptionTests.cs | 83 +++++++++++++- ICSharpCode.ILSpyCmd/ILAstDumper.cs | 104 +++++++++++++----- ICSharpCode.ILSpyCmd/IlspyCmdProgram.cs | 33 ++++-- 3 files changed, 179 insertions(+), 41 deletions(-) diff --git a/ICSharpCode.ILSpyCmd.Tests/ILAstOptionTests.cs b/ICSharpCode.ILSpyCmd.Tests/ILAstOptionTests.cs index a4df43978f..b2e0efd167 100644 --- a/ICSharpCode.ILSpyCmd.Tests/ILAstOptionTests.cs +++ b/ICSharpCode.ILSpyCmd.Tests/ILAstOptionTests.cs @@ -19,6 +19,7 @@ // The --ilast options exist in debug builds only, so this fixture compiles away with them. #if DEBUG +using System.IO; using System.Threading.Tasks; using NUnit.Framework; @@ -53,6 +54,11 @@ public async Task ILAstOfSelectedMethodIsWritten() Assert.That(result.Output, Does.Contain("ILFunction")); } + // the loop of SumLoop is only recognised by HighLevelLoopTransform, at the very end of the + // pipeline: its presence tells a full run from a truncated one, while the header line + // (which names the requested transform count) would differ either way + const string structuredLoop = "BlockContainer (for)"; + [Test] public async Task StoppingAfterATransformYieldsDifferentILAst() { @@ -64,17 +70,20 @@ public async Task StoppingAfterATransformYieldsDifferentILAst() 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)); + Assert.That(full.Output, Does.Contain(structuredLoop)); + Assert.That(partial.Output, Does.Not.Contain(structuredLoop)); } [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)); + Assert.That(partial.Output, Does.Contain(nameof(ILAstSample.SumLoop))); + Assert.That(partial.Output, Does.Not.Contain(structuredLoop)); + // AssignVariableNames is the last transform, so the locals still carry their IL names + Assert.That(partial.Output, Does.Contain("local V_0")); } [Test] @@ -91,12 +100,39 @@ public async Task UnknownTransformNameListsThePipeline() [Test] public async Task AmbiguousTransformNameReportsItsOccurrences() { - // SplitVariables runs three times; the name alone cannot identify a stop point + // SplitVariables runs more than once; 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")); + // the message has to be the ambiguity one, not the unknown-name listing, which + // mentions every transform of the pipeline as well + Assert.That(result.Error, Does.Contain("'SplitVariables' runs")); + Assert.That(result.Error, Does.Contain("times, at index")); + Assert.That(result.Error, Does.Not.Contain("Unknown transform")); + } + + [Test] + public async Task NestedTransformNamesTheEntryThatRunsIt() + { + // LoopDetection runs inside a BlockILTransform, so it has no stop point of its own; + // the pipeline listing has to show where it runs instead of hiding it + var result = await RunILAstAsync("--after-transform", "LoopDetection"); + + Assert.That(result.ExitCode, Is.EqualTo(ProgramExitCodes.EX_USAGE)); + Assert.That(result.Error, Does.Contain("runs inside the transform at index")); + Assert.That(result.Error, Does.Contain("BlockILTransform (LoopDetection")); + } + + [Test] + public async Task DebugSymbolsProvideLocalVariableNames() + { + var withPdb = await RunILAstAsync("--ilast", "-usepdb"); + var withoutPdb = await RunILAstAsync("--ilast"); + + Assert.That(withPdb.ExitCode, Is.EqualTo(0), withPdb.Error); + // the PDB's name for the accumulator, instead of the generated 'num' + Assert.That(withPdb.Output, Does.Contain("local sum")); + Assert.That(withoutPdb.Output, Does.Not.Contain("local sum")); } [Test] @@ -108,6 +144,39 @@ public async Task WholeTypeCanBeDumped() 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))); + // accessors are methods with bodies too, and the type system's Methods hides them + Assert.That(result.Output, Does.Contain("get_" + nameof(ILAstSample.Counter))); + } + + [Test] + public async Task OutputDirWritesEveryAssemblyCompletely() + { + // Two input assemblies, so the per-file output writer is swapped between files; + // a writer that is replaced without being flushed truncates the earlier file. + string ilspyCmdAssemblyPath = typeof(ILSpyCmdProgram).Assembly.Location; + string outputDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(outputDir); + try + { + // one transform only: this dumps every method of both assemblies, and the + // truncation it guards against does not depend on the pipeline length + var result = await RunAsync(testAssemblyPath, ilspyCmdAssemblyPath, + "--disable-updatecheck", "--after-transform", "1", "-o", outputDir); + + Assert.That(result.ExitCode, Is.EqualTo(0), result.Error); + foreach (string assemblyPath in new[] { testAssemblyPath, ilspyCmdAssemblyPath }) + { + string outputFile = Path.Combine(outputDir, Path.GetFileNameWithoutExtension(assemblyPath) + ".ilast"); + Assert.That(File.Exists(outputFile), Is.True, outputFile); + // every function ends with its closing brace; a truncated file breaks off + // wherever the writer's buffer happened to end + Assert.That(File.ReadAllText(outputFile).TrimEnd(), Does.EndWith("}"), outputFile); + } + } + finally + { + Directory.Delete(outputDir, recursive: true); + } } } @@ -124,6 +193,8 @@ public static int SumLoop(int n) } public static string Identity(string value) => value; + + public static int Counter { get; set; } } } diff --git a/ICSharpCode.ILSpyCmd/ILAstDumper.cs b/ICSharpCode.ILSpyCmd/ILAstDumper.cs index 4924e0ffd2..808e5544a7 100644 --- a/ICSharpCode.ILSpyCmd/ILAstDumper.cs +++ b/ICSharpCode.ILSpyCmd/ILAstDumper.cs @@ -41,20 +41,53 @@ namespace ICSharpCode.ILSpyCmd /// class ILAstDumper { - readonly IReadOnlyList transforms = CSharpDecompiler.GetILTransforms(); - readonly ILAstWritingOptions writingOptions = new ILAstWritingOptions(); + static readonly IReadOnlyList transforms = CSharpDecompiler.GetILTransforms(); + + readonly ILAstWritingOptions writingOptions = new ILAstWritingOptions { + // same sugar as the UI's ILAst pane, so its output and this one are diffable + UseFieldSugar = true, + UseLogicOperationSugar = true, + }; /// - /// 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. + /// Names of the IL transforms, in the order they run, as --after-transform accepts + /// them. Transforms nested inside a BlockILTransform (LoopDetection, ConditionDetection, + /// the statement transforms, ...) have no name of their own here: 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(); + transforms.Select(t => t.GetType().Name).ToArray(); public static int TransformCount => TransformNames.Count; + /// + /// How a pipeline entry is displayed: a BlockILTransform also names the transforms it + /// runs, which are otherwise invisible - and two BlockILTransform entries would be + /// indistinguishable. + /// + static string Describe(int index) + { + return transforms[index] is BlockILTransform block ? block.ToString() : TransformNames[index]; + } + + /// + /// The 1-based index of the pipeline entry running as one of its + /// nested transforms, or 0 if no entry does. + /// + static int FindContainingEntry(string name) + { + for (int i = 0; i < transforms.Count; i++) + { + if (transforms[i] is BlockILTransform block + && block.PreOrderTransforms.Concat(block.PostOrderTransforms) + .Any(t => string.Equals(t.GetType().Name, name, StringComparison.OrdinalIgnoreCase))) + { + return i + 1; + } + } + return 0; + } + /// /// The pipeline as displayed to the user: one transform per line, prefixed by the /// 1-based index that --after-transform accepts. @@ -62,7 +95,7 @@ class ILAstDumper public static string DescribePipeline() { return string.Join(Environment.NewLine, - TransformNames.Select((name, index) => $" {index + 1,3} {name}")); + Enumerable.Range(0, TransformCount).Select(index => $" {index + 1,3} {Describe(index)}")); } /// @@ -96,7 +129,10 @@ public static bool TryResolveTransformCount(string nameOrIndex, out int count, o if (occurrences.Length == 0) { - error = $"Unknown transform '{trimmed}'. Pass one of these names, or its index:{Environment.NewLine}{DescribePipeline()}"; + int containingEntry = FindContainingEntry(trimmed); + error = containingEntry > 0 + ? $"'{trimmed}' runs inside the transform at index {containingEntry} and cannot be stopped after on its own. Pass that index to stop after the whole entry:{Environment.NewLine}{DescribePipeline()}" + : $"Unknown transform '{trimmed}'. Pass one of these names, or its index:{Environment.NewLine}{DescribePipeline()}"; return false; } if (occurrences.Length > 1) @@ -111,32 +147,38 @@ public static bool TryResolveTransformCount(string nameOrIndex, out int count, o /// /// Writes the ILAst of a single method, or nothing at all if the method has no body - /// (abstract, extern or a runtime-provided implementation). + /// (abstract, extern or a runtime-provided implementation). Returns the failure if the + /// body could not be read, transformed or written, so that one broken method neither + /// aborts the dump nor makes the run look successful. /// - public void WriteMethod(CSharpDecompiler decompiler, DecompilerSettings settings, IMethod method, + public DecompilerException 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; + if (method == null || method.MetadataToken.IsNil || method.MetadataToken.Kind != HandleKind.MethodDefinition) + return null; + var module = decompiler.TypeSystem.MainModule; + var metadataFile = module.MetadataFile; var handle = (MethodDefinitionHandle)method.MetadataToken; var methodDefinition = metadataFile.Metadata.GetMethodDefinition(handle); if (!methodDefinition.HasBody()) - return; + return null; 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); + output.WriteLine($"// ILAst after {transformCount} of {TransformCount} transforms ({Describe(transformCount - 1)})"); + + DecompilerException error = null; + ILFunction function = null; try { + var reader = new ILReader(module) { + UseDebugSymbols = settings.UseDebugSymbols, + UseRefLocalsForAccurateOrderOfEvaluation = settings.UseRefLocalsForAccurateOrderOfEvaluation, + DebugInfo = decompiler.DebugInfoProvider, + }; + var body = metadataFile.GetMethodBody(methodDefinition.RelativeVirtualAddress); + function = reader.ReadIL(handle, body, kind: ILFunctionKind.TopLevelFunction, + cancellationToken: cancellationToken); + ILTransformContext context = decompiler.CreateILTransformContext(function); function.RunTransforms(transforms.Take(transformCount), context); } catch (Exception ex) @@ -146,10 +188,20 @@ public void WriteMethod(CSharpDecompiler decompiler, DecompilerSettings settings // rather than aborting the whole dump. output.WriteLine(ex.ToString()); output.WriteLine("// ILAst after the crash:"); + error = new DecompilerException(module, method, ex); + } + try + { + function?.WriteTo(output, writingOptions); + } + catch (Exception ex) + { + output.WriteLine(ex.ToString()); + error ??= new DecompilerException(module, method, ex); } - function.WriteTo(output, writingOptions); output.WriteLine(); output.WriteLine(); + return error; } } } diff --git a/ICSharpCode.ILSpyCmd/IlspyCmdProgram.cs b/ICSharpCode.ILSpyCmd/IlspyCmdProgram.cs index ad37d39e10..4932aa375c 100644 --- a/ICSharpCode.ILSpyCmd/IlspyCmdProgram.cs +++ b/ICSharpCode.ILSpyCmd/IlspyCmdProgram.cs @@ -363,8 +363,12 @@ int PerformPerFileAction(string fileName) { if (outputDirectory != null) { + // per-file writer, disposed here: the shared 'output' is only closed once + // at the end of the run, which would lose the buffered tail of every file + // but the last when dumping multiple assemblies string outputName = Path.GetFileNameWithoutExtension(fileName); - output = File.CreateText(Path.Combine(outputDirectory, outputName) + ".ilast"); + using var ilastOutput = File.CreateText(Path.Combine(outputDirectory, outputName) + ".ilast"); + return ShowILAst(fileName, ilastOutput, app); } return ShowILAst(fileName, output, app); @@ -533,7 +537,9 @@ DecompilerSettings GetSettings(PEFile module) return decompilerSettings; } - CSharpDecompiler GetDecompiler(string assemblyFileName) + CSharpDecompiler GetDecompiler(string assemblyFileName) => GetDecompiler(assemblyFileName, out _); + + CSharpDecompiler GetDecompiler(string assemblyFileName, out DecompilerSettings settings) { var module = new PEFile(assemblyFileName); var resolver = new UniversalAssemblyResolver(assemblyFileName, false, module.Metadata.DetectTargetFrameworkId()); @@ -541,7 +547,8 @@ CSharpDecompiler GetDecompiler(string assemblyFileName) { resolver.AddSearchDirectory(path); } - return new CSharpDecompiler(assemblyFileName, resolver, GetSettings(module)) { + settings = GetSettings(module); + return new CSharpDecompiler(assemblyFileName, resolver, settings) { DebugInfoProvider = TryLoadPDB(module) }; } @@ -667,8 +674,9 @@ int ShowILAst(string assemblyFileName, TextWriter output, CommandLineApplication return ProgramExitCodes.EX_USAGE; } - var settings = GetSettings(new PEFile(assemblyFileName)); - CSharpDecompiler decompiler = GetDecompiler(assemblyFileName); + CSharpDecompiler decompiler = GetDecompiler(assemblyFileName, out var settings); + var mainModule = decompiler.TypeSystem.MainModule; + var metadata = mainModule.MetadataFile.Metadata; IEnumerable methods; if (MemberIdString != null) @@ -683,7 +691,7 @@ int ShowILAst(string assemblyFileName, TextWriter output, CommandLineApplication 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) }; + methods = new[] { mainModule.GetDefinition((MethodDefinitionHandle)handle) }; } else if (TypeName != null) { @@ -692,19 +700,26 @@ int ShowILAst(string assemblyFileName, TextWriter output, CommandLineApplication Console.Error.WriteLine(error); return ProgramExitCodes.EX_DATAERR; } - methods = typeDefinition.Methods; + // via the metadata handles, not ITypeDefinition.Methods: the latter drops every + // method that has method semantics, i.e. all property and event accessors + methods = metadata.GetTypeDefinition((TypeDefinitionHandle)typeDefinition.MetadataToken) + .GetMethods().Select(mainModule.GetDefinition); } else { - methods = decompiler.TypeSystem.MainModule.TypeDefinitions.SelectMany(type => type.Methods); + methods = metadata.MethodDefinitions.Select(mainModule.GetDefinition); } var textOutput = new PlainTextOutput(output); var dumper = new ILAstDumper(); + var errors = new List(); foreach (var method in methods) { - dumper.WriteMethod(decompiler, settings, method, transformCount, textOutput, CancellationToken.None); + var error = dumper.WriteMethod(decompiler, settings, method, transformCount, textOutput, CancellationToken.None); + if (error != null) + errors.Add(error); } + ReportDecompilationErrors(assemblyFileName, errors); return 0; } #endif