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)