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..b2e0efd167 --- /dev/null +++ b/ICSharpCode.ILSpyCmd.Tests/ILAstOptionTests.cs @@ -0,0 +1,201 @@ +// 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.IO; +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")); + } + + // 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() + { + 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(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"); + + Assert.That(partial.ExitCode, Is.EqualTo(0), partial.Error); + 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] + 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 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)); + // 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] + 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))); + // 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); + } + } + } + + 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; + + public static int Counter { get; set; } + } +} + +#endif diff --git a/ICSharpCode.ILSpyCmd/ILAstDumper.cs b/ICSharpCode.ILSpyCmd/ILAstDumper.cs new file mode 100644 index 0000000000..808e5544a7 --- /dev/null +++ b/ICSharpCode.ILSpyCmd/ILAstDumper.cs @@ -0,0 +1,209 @@ +// 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 + { + 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, 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; } = + 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. + /// + public static string DescribePipeline() + { + return string.Join(Environment.NewLine, + Enumerable.Range(0, TransformCount).Select(index => $" {index + 1,3} {Describe(index)}")); + } + + /// + /// 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) + { + 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) + { + 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). 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 DecompilerException WriteMethod(CSharpDecompiler decompiler, DecompilerSettings settings, IMethod method, + int transformCount, ITextOutput output, CancellationToken cancellationToken) + { + 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 null; + + output.WriteLine($"// {method.FullName}"); + 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) + { + // 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:"); + error = new DecompilerException(module, method, ex); + } + try + { + function?.WriteTo(output, writingOptions); + } + catch (Exception ex) + { + output.WriteLine(ex.ToString()); + error ??= new DecompilerException(module, method, ex); + } + output.WriteLine(); + output.WriteLine(); + return error; + } + } +} + +#endif diff --git a/ICSharpCode.ILSpyCmd/IlspyCmdProgram.cs b/ICSharpCode.ILSpyCmd/IlspyCmdProgram.cs index 9ecc669868..4932aa375c 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,22 @@ int PerformPerFileAction(string fileName) return ShowIL(fileName, output); } +#if DEBUG + else if (ShowILAstFlag || AfterTransformName != null) + { + 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); + using var ilastOutput = File.CreateText(Path.Combine(outputDirectory, outputName) + ".ilast"); + return ShowILAst(fileName, ilastOutput, app); + } + + return ShowILAst(fileName, output, app); + } +#endif else if (CreateDebugInfoFlag) { string pdbFileName = null; @@ -510,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()); @@ -518,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) }; } @@ -627,6 +657,73 @@ 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; + } + + CSharpDecompiler decompiler = GetDecompiler(assemblyFileName, out var settings); + var mainModule = decompiler.TypeSystem.MainModule; + var metadata = mainModule.MetadataFile.Metadata; + 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[] { 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; + } + // 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 = metadata.MethodDefinitions.Select(mainModule.GetDefinition); + } + + var textOutput = new PlainTextOutput(output); + var dumper = new ILAstDumper(); + var errors = new List(); + foreach (var method in methods) + { + var error = dumper.WriteMethod(decompiler, settings, method, transformCount, textOutput, CancellationToken.None); + if (error != null) + errors.Add(error); + } + ReportDecompilationErrors(assemblyFileName, errors); + return 0; + } +#endif + readonly List decompilationErrors = new(); ///