From 457985c313e2c299c5fdd8a1cd0189222f67a588 Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Sat, 22 Aug 2026 10:00:31 +1000 Subject: [PATCH] Keep a stale hint from reaching into the next member The recorded line is tried before anything else, so that two snapshots in one member stay apart. But it was tried whenever it sat at or below the member's declaration, with nothing above it - and a member's declaration bounds the search from below only, so "below" reaches to the end of the file. That makes the hint outrank the member name in exactly the case the member name exists for. A hint goes stale as soon as anything above it is edited, and a stale one pointing into the test next door rewrote that test's identical snapshot and left this one as it was, reporting Applied. Only take the hint when no declaration sits between the member's own and it, which is the cheap half of asking whether the hint is still inside the member. When no member was named there is nothing to be outside of, and the hint is all there is - so that path is untouched. --- src/DiffEngine.Tests/InlinePatcherTests.cs | 31 ++++++++++++++ src/DiffEngine/Inline/InlinePatcher.cs | 48 +++++++++++++++++++++- 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/src/DiffEngine.Tests/InlinePatcherTests.cs b/src/DiffEngine.Tests/InlinePatcherTests.cs index 6c7db593..33d5fbb0 100644 --- a/src/DiffEngine.Tests/InlinePatcherTests.cs +++ b/src/DiffEngine.Tests/InlinePatcherTests.cs @@ -123,6 +123,37 @@ public async Task ASnapshotBeforeAVerbatimStringOpeningOnAnEscapedQuoteIsStillFo const string q3 = "\"\"\""; const string q4 = "\"\"\"\""; + /// + /// A hint that has gone stale and now points into another member. The recorded line is tried + /// first so two snapshots in one member stay apart, but it is only evidence while it is still + /// inside that member - and a declaration between the two says it is not. Trying it anyway + /// rewrote the other test's identical snapshot and left this one as it was. + /// + [Test] + public async Task AStaleHintDoesNotReachIntoTheNextMember() + { + var source = + "class Tests\n" + + "{\n" + + " async Task First()\n" + + " {\n" + + " await Snapshot(\"dup\");\n" + + " }\n" + + "\n" + + " async Task Second()\n" + + " {\n" + + " await Snapshot(\"dup\");\n" + + " }\n" + + "}"; + + // Line 10 is Second's snapshot; the patch came from First + var status = TryApply(source, 10, InlinePatchMode.Set, "\"dup\"", "new", out var newSource, out _, memberName: "First"); + + await Assert.That(status).IsEqualTo(PatchStatus.Applied); + var patched = newSource.Split('\n'); + await Assert.That(patched[4]).Contains("\"new\""); + await Assert.That(patched[9]).Contains("\"dup\""); + } [Test] public async Task ReplaceRegularLiteral() { diff --git a/src/DiffEngine/Inline/InlinePatcher.cs b/src/DiffEngine/Inline/InlinePatcher.cs index e44cace5..cc7c07db 100644 --- a/src/DiffEngine/Inline/InlinePatcher.cs +++ b/src/DiffEngine/Inline/InlinePatcher.cs @@ -583,7 +583,14 @@ static bool TryFindCall( lineHint = Clamp(lineHint, lineCount); var floor = memberLine is null ? 1 : Clamp(memberLine.Value, lineCount); var origin = memberLine is null ? lineHint : floor; - if (lineHint >= floor) + // The recorded line is tried first so that two snapshots in one member stay apart. It is + // only evidence about this member while it is still inside it, though, and a declaration + // between the two says it is not: the hint went stale, something above it moved, and it + // now points into the test next door. Trying it anyway rewrote that test's snapshot and + // left this one alone, which is the failure the member name exists to prevent + if (lineHint >= floor && + (memberLine is null || + !DeclarationBetween(source, scan, lineStarts, floor, lineHint))) { foreach (var call in CallsOnLine(source, scan, lineStarts, lineHint, names, byPrefix)) { @@ -621,6 +628,45 @@ static bool TryFindCall( } } + /// + /// Whether a declaration sits after and at or before + /// . Cheap because it only ever runs over the span between a + /// member's declaration and the recorded line. + /// + static bool DeclarationBetween(string source, SourceScan scan, List lineStarts, int afterLine, int uptoLine) + { + if (uptoLine <= afterLine || + afterLine >= lineStarts.Count) + { + return false; + } + + var start = lineStarts[afterLine]; + var end = uptoLine < lineStarts.Count ? lineStarts[uptoLine] : source.Length; + for (var index = start; index < end; index++) + { + if (!scan.IsIdentifierChar(source[index]) || + !scan.IsCode(index) || + !StartsToken(source, scan, index)) + { + continue; + } + + if (scan.IsDeclaration(index)) + { + return true; + } + + while (index + 1 < end && + scan.IsIdentifierChar(source[index + 1])) + { + index++; + } + } + + return false; + } + static int Clamp(int line, int lineCount) => Math.Min(Math.Max(line, 1), lineCount);