From 7eed5704dca82c78157942077ae2cfd83b5bd0c7 Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Sun, 28 Jun 2026 21:58:50 +0200 Subject: [PATCH] Exclude leading indentation from highlight position ranges MarkNodeStart and BeginSpan captured builder.Length to anchor a node range or highlight span, but indentation is written lazily on the first token of a line. A node or span opened at the start of an indented line therefore recorded its start before the leading tabs, so the debug-step highlight (and any span) extended back across the indentation to column 0. Flush the pending indent in both before capturing the offset, matching the WPF AvalonEditTextOutput.BeginSpan the Avalonia port derived from. The emitted text is unchanged -- the indent is written either way, in the same place; only the recorded start moves to the first real character. Assisted-by: Claude:claude-opus-4-8:Claude Code --- ILSpy.Tests/Views/DebugStepsTests.cs | 43 ++++++++++++++++++++++++ ILSpy/TextView/AvaloniaEditTextOutput.cs | 20 ++++++----- 2 files changed, 55 insertions(+), 8 deletions(-) diff --git a/ILSpy.Tests/Views/DebugStepsTests.cs b/ILSpy.Tests/Views/DebugStepsTests.cs index 1413e30ac..39014f515 100644 --- a/ILSpy.Tests/Views/DebugStepsTests.cs +++ b/ILSpy.Tests/Views/DebugStepsTests.cs @@ -288,6 +288,49 @@ public class DebugStepsTests return Task.CompletedTask; } + [AvaloniaTest] + public Task MarkNodeStart_Excludes_Leading_Indentation() + { + // A node opened at the start of an indented line must record its range from the first real + // character, so the debug-step highlight does not extend across the indentation to column 0. + var output = new AvaloniaEditTextOutput(); + output.Indent(); + output.WriteLine(); + + var node = new object(); + output.MarkNodeStart(node); + output.Write("statement;"); + output.MarkNodeEnd(node); + + output.NodeLookup.TryGetRange(node, out var range).Should().BeTrue(); + output.GetText().Substring(range.Start, range.Length).Should().Be("statement;"); + return Task.CompletedTask; + } + + [AvaloniaTest] + public Task MarkNodeEnd_Records_Nodes_Regardless_Of_Close_Order() + { + // Node spans are keyed by identity, so closing an outer node before the inner one it still + // contains must not discard either range. A stack that popped by position would lose both. + var output = new AvaloniaEditTextOutput(); + var outer = new object(); + var inner = new object(); + + output.MarkNodeStart(outer); + output.Write("a("); + output.MarkNodeStart(inner); + output.Write("b"); + output.MarkNodeEnd(outer); + output.Write(")"); + output.MarkNodeEnd(inner); + + output.NodeLookup.TryGetRange(outer, out var outerRange).Should().BeTrue(); + output.GetText().Substring(outerRange.Start, outerRange.Length).Should().Be("a(b"); + output.NodeLookup.TryGetRange(inner, out var innerRange).Should().BeTrue(); + output.GetText().Substring(innerRange.Start, innerRange.Length).Should().Be("b)"); + return Task.CompletedTask; + } + [AvaloniaTest] public Task Pane_Reports_Not_Available_For_Languages_Without_Debug_Steps() { diff --git a/ILSpy/TextView/AvaloniaEditTextOutput.cs b/ILSpy/TextView/AvaloniaEditTextOutput.cs index 3ad943b1c..7c3c25f2a 100644 --- a/ILSpy/TextView/AvaloniaEditTextOutput.cs +++ b/ILSpy/TextView/AvaloniaEditTextOutput.cs @@ -54,7 +54,10 @@ namespace ICSharpCode.ILSpy.TextView public int LengthLimit { get; set; } = int.MaxValue; readonly Stack<(int Offset, HighlightingColor Color)> openSpans = new(); - readonly Stack<(object Node, int Offset)> openNodes = new(); + // Keyed by node rather than a stack: node writes are strictly nested today, but keying by + // identity means a stray or out-of-order MarkNodeEnd is a clean no-op that can't desync the + // remaining open nodes. Each node is written exactly once, so there is no self-nesting. + readonly Dictionary openNodeStarts = new(ReferenceEqualityComparer.Instance); readonly Stack<(NewFolding Folding, int StartLine)> openFoldings = new(); readonly List foldings = new(); int indent; @@ -290,6 +293,7 @@ namespace ICSharpCode.ILSpy.TextView public void BeginSpan(HighlightingColor highlightingColor) { + WriteIndentIfNeeded(); openSpans.Push((builder.Length, highlightingColor)); } @@ -308,17 +312,17 @@ namespace ICSharpCode.ILSpy.TextView public void MarkNodeStart(object node) { - openNodes.Push((node, builder.Length)); + // Flush a pending indent before capturing the offset so a node opened at the start of a + // line records its range from the first real character, not from column 0 across the + // leading indentation. + WriteIndentIfNeeded(); + openNodeStarts[node] = builder.Length; } public void MarkNodeEnd(object node) { - if (openNodes.Count == 0) - return; - var (currentNode, start) = openNodes.Pop(); - if (!ReferenceEquals(currentNode, node)) - return; - NodeLookup.AddNode(node, start, builder.Length - start); + if (openNodeStarts.Remove(node, out var start)) + NodeLookup.AddNode(node, start, builder.Length - start); } } }