diff --git a/src/DiffEngine.Tests/InlinePatcherTests.cs b/src/DiffEngine.Tests/InlinePatcherTests.cs index a7c107c2..277b6ee1 100644 --- a/src/DiffEngine.Tests/InlinePatcherTests.cs +++ b/src/DiffEngine.Tests/InlinePatcherTests.cs @@ -123,6 +123,33 @@ 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"); + } + /// /// A comment between the argument and the closing paren. A line comment's span includes the /// newline that ends it, so trimming trailing whitespace first ate that newline and the diff --git a/src/DiffEngine/Inline/InlinePatcher.cs b/src/DiffEngine/Inline/InlinePatcher.cs index 9eef3481..449cd743 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)