From b1ac351c934cd809b31018e4f51f23924107ce2c Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Thu, 14 May 2026 23:50:25 +0200 Subject: [PATCH] DEBUG-only "Show CFG" context-menu entry + GraphViz emitter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors WPF's `ShowCFGContextMenuEntry` and `GraphVizGraph` utility behind `#if DEBUG`. Right-click a `BlockContainer` reference in the IL output ("DEBUG -- Show CFG") → builds the ControlFlowGraph for the method, writes a `.gv` file in the temp dir, shells out to `dot` (Graphviz) to render PNG, opens the result with the OS default image viewer. Assisted-by: Claude:claude-opus-4-7:Claude Code --- ILSpy.Tests/Diagnostics/GraphVizGraphTests.cs | 92 ++++++++ ILSpy/Commands/ShowCFGContextMenuEntry.cs | 98 +++++++++ ILSpy/Util/GraphVizGraph.cs | 204 ++++++++++++++++++ 3 files changed, 394 insertions(+) create mode 100644 ILSpy.Tests/Diagnostics/GraphVizGraphTests.cs create mode 100644 ILSpy/Commands/ShowCFGContextMenuEntry.cs create mode 100644 ILSpy/Util/GraphVizGraph.cs diff --git a/ILSpy.Tests/Diagnostics/GraphVizGraphTests.cs b/ILSpy.Tests/Diagnostics/GraphVizGraphTests.cs new file mode 100644 index 000000000..2ddd3ada2 --- /dev/null +++ b/ILSpy.Tests/Diagnostics/GraphVizGraphTests.cs @@ -0,0 +1,92 @@ +// 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.IO; + +using AwesomeAssertions; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests; + +/// +/// The CFG viewer ultimately shells out to dot, which can't run reliably in +/// CI. These tests pin the GraphViz-DOT emitter on its own — the pure-text shape +/// of digraph G { node [...]; edge -> edge; } that dot consumes. +/// +[TestFixture] +public class GraphVizGraphTests +{ + [Test] + public void Save_Writes_Digraph_Header_With_Default_Node_Font_Size() + { + // Every GraphVizGraph must start with `digraph G {` + the default `node [fontsize=16];` + // preamble. That preamble is what makes the generated DOT readable when dot renders it. + var g = new global::ILSpy.Util.GraphVizGraph(); + var sw = new StringWriter(); + g.Save(sw); + var dot = sw.ToString(); + dot.Should().Contain("digraph G {"); + dot.Should().Contain("node [fontsize = 16];"); + dot.Should().Contain("}"); + } + + [Test] + public void Nodes_Render_With_Id_Label_And_Shape() + { + // A node emits as ` [label="...", shape=...]` — the box shape is what the + // CFG viewer relies on so every basic block reads as a rectangle. + var g = new global::ILSpy.Util.GraphVizGraph(); + g.AddNode(new global::ILSpy.Util.GraphVizNode(42) { label = "BB.42", shape = "box" }); + var sw = new StringWriter(); + g.Save(sw); + var dot = sw.ToString(); + dot.Should().Contain("42 ["); + dot.Should().Contain("label=\"BB.42\""); + dot.Should().Contain("shape=box"); + } + + [Test] + public void Edges_Render_Source_Target_With_Optional_Color() + { + // Successor edges have no color (rendered as black); dominator edges use green + // — that's how the CFG viewer distinguishes the two relationship overlays. + var g = new global::ILSpy.Util.GraphVizGraph(); + g.AddEdge(new global::ILSpy.Util.GraphVizEdge(1, 2)); + g.AddEdge(new global::ILSpy.Util.GraphVizEdge(1, 2) { color = "green" }); + var sw = new StringWriter(); + g.Save(sw); + var dot = sw.ToString(); + dot.Should().Contain("1 -> 2 ["); + dot.Should().Contain("color=green"); + } + + [Test] + public void Label_With_Quote_Is_Escaped_For_DOT_Syntax() + { + // A literal `"` inside a label would otherwise terminate the DOT string token + // early. The escaper turns it into `\"` so dot parses the whole label as one token. + var g = new global::ILSpy.Util.GraphVizGraph(); + g.AddNode(new global::ILSpy.Util.GraphVizNode(0) { label = "a \"quoted\" label" }); + var sw = new StringWriter(); + g.Save(sw); + sw.ToString().Should().Contain("\"a \\\"quoted\\\" label\""); + } +} +#endif diff --git a/ILSpy/Commands/ShowCFGContextMenuEntry.cs b/ILSpy/Commands/ShowCFGContextMenuEntry.cs new file mode 100644 index 000000000..963360c8d --- /dev/null +++ b/ILSpy/Commands/ShowCFGContextMenuEntry.cs @@ -0,0 +1,98 @@ +// 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.FlowAnalysis; +using ICSharpCode.Decompiler.IL; +using ICSharpCode.Decompiler.IL.ControlFlow; + +using ILSpy.Util; + +namespace ILSpy.Commands +{ + /// + /// DEBUG-only: right-click a reference in the IL output + /// → "DEBUG -- Show CFG". Builds the control-flow graph for the containing method, + /// writes it as a GraphViz .gv file in the temp dir, shells out to dot + /// to render PNG, and opens the result with the OS default image viewer. + /// + [ExportContextMenuEntry(Header = "DEBUG -- Show CFG")] + [Shared] + internal sealed class ShowCFGContextMenuEntry : IContextMenuEntry + { + public bool IsVisible(TextViewContext context) + => context.Reference?.Reference is BlockContainer; + + public bool IsEnabled(TextViewContext context) + => context.Reference?.Reference is BlockContainer; + + public void Execute(TextViewContext context) + { + try + { + var container = (BlockContainer)context.Reference!.Reference!; + var cfg = new ControlFlowGraph(container); + ExportGraph(cfg.Nodes).Show(); + } + catch (Exception ex) + { + // Most likely failure: `dot` isn't on PATH. Surface the full exception via + // stderr/console so the developer running a DEBUG build sees it (there's no + // MessageBox primitive on the Avalonia side and this entry is dev-only). + System.Diagnostics.Debug.WriteLine("Error generating CFG — requires GraphViz dot on PATH: " + ex); + } + } + + internal static GraphVizGraph ExportGraph(IReadOnlyList nodes, Func? labelFunc = null) + { + labelFunc ??= node => { + var block = node.UserData as Block; + return block != null ? block.Label : node.UserData?.ToString(); + }; + var g = new GraphVizGraph(); + var n = new GraphVizNode[nodes.Count]; + for (int i = 0; i < n.Length; i++) + { + n[i] = new GraphVizNode(nodes[i].UserIndex) { + shape = "box", + label = labelFunc(nodes[i]), + }; + g.AddNode(n[i]); + } + foreach (var source in nodes) + { + foreach (var target in source.Successors) + { + g.AddEdge(new GraphVizEdge(source.UserIndex, target.UserIndex)); + } + if (source.ImmediateDominator != null) + { + g.AddEdge(new GraphVizEdge(source.ImmediateDominator.UserIndex, source.UserIndex) { + color = "green", + }); + } + } + return g; + } + } +} +#endif diff --git a/ILSpy/Util/GraphVizGraph.cs b/ILSpy/Util/GraphVizGraph.cs new file mode 100644 index 000000000..b6aebf33f --- /dev/null +++ b/ILSpy/Util/GraphVizGraph.cs @@ -0,0 +1,204 @@ +// 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. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Text.RegularExpressions; + +namespace ILSpy.Util +{ +#if DEBUG + /// + /// Minimal GraphViz DOT-language emitter. Used by the DEBUG-only CFG viewer to dump + /// a method's control-flow graph and then call out to dot on PATH to render + /// it as PNG. Pure-text producer here; process spawning lives in . + /// + internal sealed class GraphVizGraph + { + readonly List nodes = new(); + readonly List edges = new(); + + public string? rankdir; + public string? Title; + + public void AddEdge(GraphVizEdge edge) => edges.Add(edge); + public void AddNode(GraphVizNode node) => nodes.Add(node); + + public void Save(string fileName) + { + using var writer = new StreamWriter(fileName); + Save(writer); + } + + public void Show() => Show(null); + + public void Show(string? name) + { + name ??= Title; + if (name != null) + foreach (var c in Path.GetInvalidFileNameChars()) + name = name.Replace(c, '-'); + var fileName = name != null ? Path.Combine(Path.GetTempPath(), name) : Path.GetTempFileName(); + Save(fileName + ".gv"); + // `dot` is from Graphviz; the user must have it on PATH for this to work. + // Same precondition as WPF — we surface the failure as an exception so the + // context-menu entry's catch can render an informative message. + Process.Start("dot", $"\"{fileName}.gv\" -Tpng -o \"{fileName}.png\"")?.WaitForExit(); + // Cross-platform "open file with default app": Process.Start with + // UseShellExecute=true works on Windows and Mac; on Linux fall back to xdg-open. + OpenWithDefaultApplication(fileName + ".png"); + } + + static void OpenWithDefaultApplication(string path) + { + if (OperatingSystem.IsWindows() || OperatingSystem.IsMacOS()) + { + Process.Start(new ProcessStartInfo(path) { UseShellExecute = true }); + } + else + { + Process.Start(new ProcessStartInfo("xdg-open", path) { UseShellExecute = false }); + } + } + + static string Escape(string text) + { + if (Regex.IsMatch(text, @"^[\w\d]+$")) + return text; + return "\"" + text.Replace("\\", "\\\\").Replace("\r", "").Replace("\n", "\\n").Replace("\"", "\\\"") + "\""; + } + + static void WriteGraphAttribute(TextWriter writer, string name, string? value) + { + if (value != null) + writer.WriteLine("{0}={1};", name, Escape(value)); + } + + internal static void WriteAttribute(TextWriter writer, string name, double? value, ref bool isFirst) + { + if (value != null) + WriteAttribute(writer, name, value.Value.ToString(CultureInfo.InvariantCulture), ref isFirst); + } + + internal static void WriteAttribute(TextWriter writer, string name, bool? value, ref bool isFirst) + { + if (value != null) + WriteAttribute(writer, name, value.Value ? "true" : "false", ref isFirst); + } + + internal static void WriteAttribute(TextWriter writer, string name, string? value, ref bool isFirst) + { + if (value != null) + { + if (isFirst) + isFirst = false; + else + writer.Write(','); + writer.Write("{0}={1}", name, Escape(value)); + } + } + + public void Save(TextWriter writer) + { + ArgumentNullException.ThrowIfNull(writer); + writer.WriteLine("digraph G {"); + writer.WriteLine("node [fontsize = 16];"); + WriteGraphAttribute(writer, "rankdir", rankdir); + foreach (var node in nodes) + node.Save(writer); + foreach (var edge in edges) + edge.Save(writer); + writer.WriteLine("}"); + } + } + + internal sealed class GraphVizEdge + { + public readonly string Source, Target; + + public string? color; + public bool? constraint; + public string? label; + public string? style; + public int? fontsize; + + public GraphVizEdge(string source, string target) + { + this.Source = source ?? throw new ArgumentNullException(nameof(source)); + this.Target = target ?? throw new ArgumentNullException(nameof(target)); + } + + public GraphVizEdge(int source, int target) + { + this.Source = source.ToString(CultureInfo.InvariantCulture); + this.Target = target.ToString(CultureInfo.InvariantCulture); + } + + public void Save(TextWriter writer) + { + writer.Write("{0} -> {1} [", Source, Target); + bool isFirst = true; + GraphVizGraph.WriteAttribute(writer, "label", label, ref isFirst); + GraphVizGraph.WriteAttribute(writer, "style", style, ref isFirst); + GraphVizGraph.WriteAttribute(writer, "fontsize", fontsize, ref isFirst); + GraphVizGraph.WriteAttribute(writer, "color", color, ref isFirst); + GraphVizGraph.WriteAttribute(writer, "constraint", constraint, ref isFirst); + writer.WriteLine("];"); + } + } + + internal sealed class GraphVizNode + { + public readonly string ID; + public string? label; + public string? labelloc; + public int? fontsize; + public double? height; + public string? margin; + public string? shape; + + public GraphVizNode(string id) + { + this.ID = id ?? throw new ArgumentNullException(nameof(id)); + } + + public GraphVizNode(int id) + { + this.ID = id.ToString(CultureInfo.InvariantCulture); + } + + public void Save(TextWriter writer) + { + writer.Write(ID); + writer.Write(" ["); + bool isFirst = true; + GraphVizGraph.WriteAttribute(writer, "label", label, ref isFirst); + GraphVizGraph.WriteAttribute(writer, "labelloc", labelloc, ref isFirst); + GraphVizGraph.WriteAttribute(writer, "fontsize", fontsize, ref isFirst); + GraphVizGraph.WriteAttribute(writer, "margin", margin, ref isFirst); + GraphVizGraph.WriteAttribute(writer, "shape", shape, ref isFirst); + writer.WriteLine("];"); + } + } +#endif +}