From ec6018a67d6ab45a961e1e8da6b31e69e864a2da Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Fri, 14 Aug 2026 09:11:39 +1000 Subject: [PATCH] Inline ForeignReceiver detection --- src/DiffEngine.Tests/InlinePatcherTests.cs | 59 ++++++++++++++++++++++ src/DiffEngine/Inline/CsScan.cs | 2 +- src/DiffEngine/Inline/InlinePatcher.cs | 54 ++++++++++++++++++++ 3 files changed, 114 insertions(+), 1 deletion(-) 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;