diff --git a/src/DiffEngine.Tests/InlinePatcherTests.cs b/src/DiffEngine.Tests/InlinePatcherTests.cs
index dc41c54c..6dd990ad 100644
--- a/src/DiffEngine.Tests/InlinePatcherTests.cs
+++ b/src/DiffEngine.Tests/InlinePatcherTests.cs
@@ -123,6 +123,41 @@ public async Task ASnapshotBeforeAVerbatimStringOpeningOnAnEscapedQuoteIsStillFo
const string q3 = "\"\"\"";
const string q4 = "\"\"\"\"";
+ ///
+ /// A retire is anchored the same way a set is. It used to delete whichever call sat nearest
+ /// the recorded line, and that line stops being true the moment anything above it is edited -
+ /// so a stale hint retired the snapshot in the test next door and reported Applied.
+ ///
+ [Test]
+ public async Task RemoveTakesTheCallTheAnchorNamesRatherThanTheNearest()
+ {
+ var source = Method(
+ " await A().Snapshot(\"one\");\n" +
+ " await B().Snapshot(\"two\");");
+
+ // Hint on the second call, anchor on the first
+ var status = TryApply(source, 6, InlinePatchMode.Remove, "\"one\"", "", out var newSource, out _);
+
+ await Assert.That(status).IsEqualTo(PatchStatus.Applied);
+ await Assert.That(newSource).DoesNotContain("\"one\"");
+ // The other test's snapshot is left alone
+ await Assert.That(newSource).Contains("Snapshot(\"two\")");
+ }
+
+ ///
+ /// And an anchor that matches nothing is reported rather than resolved to the nearest call.
+ ///
+ [Test]
+ public async Task RemoveReportsWhenTheAnchorIsGone()
+ {
+ var source = Method(" await A().Snapshot(\"two\");");
+
+ var status = TryApply(source, 5, InlinePatchMode.Remove, "\"one\"", "", out _, out var reason);
+
+ await Assert.That(status).IsEqualTo(PatchStatus.NotFound);
+ await Assert.That(reason).Contains("still the one the test run saw");
+ }
+
///
/// 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
diff --git a/src/DiffEngine/Inline/InlinePatcher.cs b/src/DiffEngine/Inline/InlinePatcher.cs
index cb99189d..7034122c 100644
--- a/src/DiffEngine/Inline/InlinePatcher.cs
+++ b/src/DiffEngine/Inline/InlinePatcher.cs
@@ -80,7 +80,7 @@ public static PatchStatus TryApply(
if (mode == InlinePatchMode.Remove)
{
- return TryRemove(source, scan, lineStarts, lineHint, memberLine, ref newSource, ref failReason);
+ return TryRemove(language, source, scan, lineStarts, lineHint, memberLine, originalExpression, originalValue, eol, ref newSource, ref failReason);
}
var fileUnit = DetectIndentUnit(source, scan, lineStarts);
@@ -406,17 +406,24 @@ static PatchStatus TryAppend(
/// blank line is left behind.
///
static PatchStatus TryRemove(
+ SourceLanguage language,
string source,
SourceScan scan,
List lineStarts,
int lineHint,
int? memberLine,
+ string? originalExpression,
+ string? originalValue,
+ string eol,
ref string newSource,
ref string failReason)
{
- if (!TryFindCall(source, scan, lineStarts, lineHint, memberLine, snapshotName, false, out var nameStart, out var openParen))
+ var anchored = !string.IsNullOrEmpty(originalExpression) || originalValue != null;
+ if (!TryFindAnchoredCall(language, source, scan, lineStarts, lineHint, memberLine, originalExpression, originalValue, eol, out var nameStart, out var openParen))
{
- failReason = $"Could not find a {methodName} call near line {lineHint}. The source may have changed since the test run. Re-run the test.";
+ failReason = anchored
+ ? $"Could not find a {methodName} call near line {lineHint} whose expected argument is still the one the test run saw. The source may have changed since the test run. Re-run the test."
+ : $"Could not find a {methodName} call near line {lineHint}. The source may have changed since the test run. Re-run the test.";
return PatchStatus.NotFound;
}
@@ -546,6 +553,83 @@ static string LeadingWhitespace(string source, List lineStarts, int offset)
static readonly string[] snapshotName = [methodName];
+ ///
+ /// The call the anchor names, rather than whichever one sits nearest the hint.
+ ///
+ /// Set and Append locate by content for a reason - the same literal is just as likely to be in
+ /// the test next door - and a Remove has exactly the same problem with none of the protection.
+ /// It deleted the nearest call to a line number that stops being true as soon as anything
+ /// above it is edited, so a stale hint retired somebody else's snapshot and reported Applied.
+ ///
+ ///
+ /// With no anchor there is nothing to match on and nearest-to-the-hint is all there is, which
+ /// is the case for a producer whose language withholds CallerArgumentExpression and sends no
+ /// value either.
+ ///
+ ///
+ static bool TryFindAnchoredCall(
+ SourceLanguage language,
+ string source,
+ SourceScan scan,
+ List lineStarts,
+ int lineHint,
+ int? memberLine,
+ string? originalExpression,
+ string? originalValue,
+ string eol,
+ out int nameStart,
+ out int openParen)
+ {
+ if (string.IsNullOrEmpty(originalExpression) &&
+ originalValue == null)
+ {
+ return TryFindCall(source, scan, lineStarts, lineHint, memberLine, snapshotName, false, out nameStart, out openParen);
+ }
+
+ // ReSharper disable once RedundantSuppressNullableWarningExpression
+ var needle = string.IsNullOrEmpty(originalExpression) ? null : NormalizeTo(originalExpression!, eol);
+ var previous = originalValue == null ? null : SourceLanguage.NormalizeNewlines(originalValue);
+
+ foreach (var (candidateName, candidateParen) in FindCalls(source, scan, lineStarts, lineHint, memberLine, snapshotName, false))
+ {
+ if (!TryReadArguments(source, scan, candidateParen, out var expected))
+ {
+ continue;
+ }
+
+ if (needle != null)
+ {
+ if (!expected.Matches(source, needle))
+ {
+ continue;
+ }
+ }
+ else
+ {
+ if (expected.IsAbsent ||
+ expected.BlockedByName)
+ {
+ continue;
+ }
+
+ var argument = source.Substring(expected.Start, expected.End - expected.Start);
+ if (!language.TryParse(argument, out var value) ||
+ value != previous)
+ {
+ continue;
+ }
+ }
+
+ nameStart = candidateName;
+ openParen = candidateParen;
+ return true;
+ }
+
+ nameStart = -1;
+ openParen = -1;
+ return false;
+ }
+
static bool TryFindCall(string source, SourceScan scan, List lineStarts, int lineHint, int? memberLine, out int openParen) =>
TryFindCall(source, scan, lineStarts, lineHint, memberLine, snapshotName, false, out _, out openParen);