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