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