diff --git a/src/DiffEngine.Tests/InlinePatcherTests.cs b/src/DiffEngine.Tests/InlinePatcherTests.cs index 277b6ee1..dc41c54c 100644 --- a/src/DiffEngine.Tests/InlinePatcherTests.cs +++ b/src/DiffEngine.Tests/InlinePatcherTests.cs @@ -123,6 +123,38 @@ 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\""); + } + /// /// A CRLF file holding a literal whose own lines are LF. The anchor is normalised to the /// file's dominant ending before the search, which is right for writing and wrong for diff --git a/src/DiffEngine/Inline/InlinePatcher.cs b/src/DiffEngine/Inline/InlinePatcher.cs index 449cd743..cb99189d 100644 --- a/src/DiffEngine/Inline/InlinePatcher.cs +++ b/src/DiffEngine/Inline/InlinePatcher.cs @@ -604,7 +604,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)) { @@ -642,6 +649,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);