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); } } }