diff --git a/src/DiffEngine.Tests/InlinePatcherTests.cs b/src/DiffEngine.Tests/InlinePatcherTests.cs
index c57741e5..7bd02c6b 100644
--- a/src/DiffEngine.Tests/InlinePatcherTests.cs
+++ b/src/DiffEngine.Tests/InlinePatcherTests.cs
@@ -375,6 +375,65 @@ public async Task AppendWithNoVerifyCall()
await Assert.That(reason).Contains("Could not find a Verify call");
}
+ // A project helper named Verify is not the entry point, and a stale hint must not drift onto one
+ [Test]
+ public async Task AppendSkipsAVerifyOnAnotherReceiver()
+ {
+ var source = Method(" Assert.Empty(ContentValidation.Verify(value));");
+
+ var status = InlinePatcher.TryApply(source, 5, InlinePatchMode.Append, null, "new", out _, out var reason);
+
+ await Assert.That(status).IsEqualTo(PatchStatus.NotFound);
+ await Assert.That(reason).Contains("Could not find a Verify call");
+ }
+
+ [Test]
+ public async Task AppendSkipsAVerifyOnAnInstance()
+ {
+ var source = Method(" mock.VerifyAll();");
+
+ var status = InlinePatcher.TryApply(source, 5, InlinePatchMode.Append, null, "new", out _, out var reason);
+
+ await Assert.That(status).IsEqualTo(PatchStatus.NotFound);
+ await Assert.That(reason).Contains("Could not find a Verify call");
+ }
+
+ // The entry point wrapping a helper of the same name: the outer call is the one to append to
+ [Test]
+ public async Task AppendPrefersTheEntryPointOverANestedHelper()
+ {
+ var source = Method(" await Verify(ContentValidation.Verify(value));");
+
+ var status = InlinePatcher.TryApply(source, 5, InlinePatchMode.Append, null, "new", out var newSource, out _);
+
+ await Assert.That(status).IsEqualTo(PatchStatus.Applied);
+ await Assert.That(newSource).Contains(
+ " await Verify(ContentValidation.Verify(value))\n" +
+ " .Snapshot(\"\"\"");
+ }
+
+ [Test]
+ public async Task AppendToAVerifierQualifiedCall()
+ {
+ var source = Method(" await Verifier.Verify(value);");
+
+ var status = InlinePatcher.TryApply(source, 5, InlinePatchMode.Append, null, "new", out var newSource, out _);
+
+ await Assert.That(status).IsEqualTo(PatchStatus.Applied);
+ await Assert.That(newSource).Contains("await Verifier.Verify(value)\n .Snapshot(\"\"\"");
+ }
+
+ [Test]
+ public async Task AppendToAThisQualifiedCall()
+ {
+ var source = Method(" await this.Verify(value);");
+
+ var status = InlinePatcher.TryApply(source, 5, InlinePatchMode.Append, null, "new", out var newSource, out _);
+
+ await Assert.That(status).IsEqualTo(PatchStatus.Applied);
+ await Assert.That(newSource).Contains("await this.Verify(value)\n .Snapshot(\"\"\"");
+ }
+
[Test]
public async Task RemoveTakesTheWholeLine()
{
diff --git a/src/DiffEngine/Inline/CsScan.cs b/src/DiffEngine/Inline/CsScan.cs
index b28ede4d..90c7d71a 100644
--- a/src/DiffEngine/Inline/CsScan.cs
+++ b/src/DiffEngine/Inline/CsScan.cs
@@ -188,7 +188,7 @@ public bool IsDeclaration(int nameStart)
/// The offset of the last character before that is neither
/// whitespace nor inside a comment, or -1 when there is none.
///
- int PreviousSignificant(int index)
+ public int PreviousSignificant(int index)
{
index--;
while (index >= 0 &&
diff --git a/src/DiffEngine/Inline/InlinePatcher.cs b/src/DiffEngine/Inline/InlinePatcher.cs
index cdc97ad5..1928b50e 100644
--- a/src/DiffEngine/Inline/InlinePatcher.cs
+++ b/src/DiffEngine/Inline/InlinePatcher.cs
@@ -28,6 +28,12 @@ static class InlinePatcher
///
const string verifyPrefix = "Verify";
+ ///
+ /// The only receiver a verify entry point is reached through. Every adapter exposes the entry
+ /// points unqualified (a static using, or inherited from VerifyBase) or on this one class.
+ ///
+ const string verifierType = "Verifier";
+
public static PatchStatus TryApply(
string source,
int lineHint,
@@ -494,6 +500,7 @@ static bool TryFindCall(
if (scan.IsCode(index) &&
StartsToken(source, index) &&
!scan.IsDeclaration(index) &&
+ !(byPrefix && IsForeignReceiver(source, scan, index)) &&
TrySkipToParen(source, scan, identifierEnd, out var paren))
{
yield return (index, paren);
@@ -509,6 +516,53 @@ static bool StartsToken(string source, int index) =>
index == 0 ||
!CsScan.IsIdentifierChar(source[index - 1]);
+ ///
+ /// True when the name is reached through a member access on anything other than the verify
+ /// entry point class. Only used for the prefix search, where the name is a guess at a verify
+ /// entry point rather than something already known to be one.
+ ///
+ /// Verify is an ordinary enough name that a project has its own: ContentValidation.Verify,
+ /// validator.Verify, mock.VerifyAll. Those read exactly like an entry point to a token scan,
+ /// and appending a Snapshot call to one splices the snapshot into a call that never produced
+ /// it, in a test that may not even be the one the patch came from.
+ ///
+ ///
+ static bool IsForeignReceiver(string source, CsScan scan, int nameStart)
+ {
+ var dot = scan.PreviousSignificant(nameStart);
+ if (dot < 0 ||
+ source[dot] != '.')
+ {
+ // Unqualified: a static using, or inherited from VerifyBase
+ return false;
+ }
+
+ var end = scan.PreviousSignificant(dot);
+ if (end >= 0 &&
+ source[end] == '?')
+ {
+ end = scan.PreviousSignificant(end);
+ }
+
+ if (end < 0 ||
+ !CsScan.IsIdentifierChar(source[end]))
+ {
+ // Not a plain receiver, so a literal, an indexer or a call result
+ return true;
+ }
+
+ var start = end;
+ while (start > 0 &&
+ CsScan.IsIdentifierChar(source[start - 1]))
+ {
+ start--;
+ }
+
+ var receiver = source.Substring(start, end - start + 1);
+ return receiver != verifierType &&
+ receiver != "this";
+ }
+
static bool TrySkipToParen(string source, CsScan scan, int index, out int paren)
{
paren = -1;