From b59806a22a89213c6731c88b650425ae5ec14750 Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Sat, 22 Aug 2026 10:05:34 +1000 Subject: [PATCH] Match an anchor across mixed line endings The anchor is normalised to the file's dominant line ending before the search. That is right for writing the replacement and wrong for finding what to replace: Matches compared byte for byte, so a literal whose own lines use the other ending was not the expression the test run sent, and the snapshot could not be patched at all. Mixed endings inside one file are ordinary rather than exotic - a merge, an editor that only normalises what it touches, a generated region. The failure is also silent in the unhelpful direction: the report is that the previous expected expression was not found, which reads as "the source moved on", so re-running the test produces the same message forever. Keep the ordinal compare as the fast path and fall back to comparing with newlines normalised on both sides. The splice still uses the argument's real span, so nothing about what gets written changes. --- src/DiffEngine.Tests/InlinePatcherTests.cs | 26 +++++++++++++++++ src/DiffEngine/Inline/InlinePatcher.cs | 33 ++++++++++++++++++---- 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/src/DiffEngine.Tests/InlinePatcherTests.cs b/src/DiffEngine.Tests/InlinePatcherTests.cs index 6c7db593..f9edc3bc 100644 --- a/src/DiffEngine.Tests/InlinePatcherTests.cs +++ b/src/DiffEngine.Tests/InlinePatcherTests.cs @@ -123,6 +123,32 @@ public async Task ASnapshotBeforeAVerbatimStringOpeningOnAnEscapedQuoteIsStillFo const string q3 = "\"\"\""; const string q4 = "\"\"\"\""; + /// + /// 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 + /// matching: the literal is the same expression and compared byte for byte it is not, so the + /// snapshot could not be patched at all. + /// + [Test] + public async Task AnAnchorMatchesAcrossMixedLineEndings() + { + var literal = "\"\"\"\n old\n \"\"\""; + var source = + "class Tests\r\n" + + "{\r\n" + + " async Task Test()\r\n" + + " {\r\n" + + $" await Snapshot({literal});\r\n" + + " }\r\n" + + "}"; + + var status = TryApply(source, 5, InlinePatchMode.Set, literal, "new", out var newSource, out var reason); + + await Assert.That(status).IsEqualTo(PatchStatus.Applied); + await Assert.That(reason).IsEmpty(); + await Assert.That(newSource).Contains("new"); + await Assert.That(newSource).DoesNotContain("old"); + } [Test] public async Task ReplaceRegularLiteral() { diff --git a/src/DiffEngine/Inline/InlinePatcher.cs b/src/DiffEngine/Inline/InlinePatcher.cs index e44cace5..c2f5a46a 100644 --- a/src/DiffEngine/Inline/InlinePatcher.cs +++ b/src/DiffEngine/Inline/InlinePatcher.cs @@ -296,13 +296,34 @@ readonly struct ExpectedArgument(int start, int end, int listStart, bool blocked public bool IsAbsent => Start == End; /// - /// True when the argument is character for character the given expression. + /// True when the argument is the given expression, character for character or once both + /// have had their newlines normalised. + /// + /// The needle arrives normalised to the file's dominant line ending, which is the right + /// thing to write but the wrong thing to search for: a literal whose own lines use the + /// other ending is the same expression and did not match, so the snapshot could not be + /// patched at all. Mixed endings inside one file are ordinary - a merge, an editor that + /// only fixes what it touches, a generator. + /// /// - public bool Matches(string source, string expression) => - !IsAbsent && - !BlockedByName && - End - Start == expression.Length && - string.CompareOrdinal(source, Start, expression, 0, expression.Length) == 0; + public bool Matches(string source, string expression) + { + if (IsAbsent || + BlockedByName) + { + return false; + } + + if (End - Start == expression.Length && + string.CompareOrdinal(source, Start, expression, 0, expression.Length) == 0) + { + return true; + } + + var argument = source.Substring(Start, End - Start); + return SourceLanguage.NormalizeNewlines(argument) == + SourceLanguage.NormalizeNewlines(expression); + } } static bool TryReadArguments(string source, SourceScan scan, int openParen, out ExpectedArgument expected)