From 7c7b35c9c6198017d54d11e8a8ebc00756def790 Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Fri, 21 Aug 2026 20:39:39 +1000 Subject: [PATCH] Do not lex a verbatim string as a raw string TrySkipStringLike measured the quote run before checking whether the literal was verbatim, so a verbatim string opening on an escaped quote came out as a 3-quote raw string. There is no verbatim raw form: after @" a run of quotes is content. The consequence was not a mis-parse of that one literal. A raw string ends at the next run of at least as many quotes, and there is none, so the scan ran to the end of the file and every Snapshot call below the line disappeared. A test file with a Windows path in a verbatim string above its snapshots could not have any of them patched, reporting only "Could not find a Snapshot call near line N". CsStringLiteral.TryScan and FsLanguage both ask this question before measuring, and say why in a comment; the lexer was the one place that did not. --- src/DiffEngine.Tests/InlinePatcherTests.cs | 46 ++++++++++++++++++++++ src/DiffEngine/Inline/CsLanguage.cs | 7 +++- 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/src/DiffEngine.Tests/InlinePatcherTests.cs b/src/DiffEngine.Tests/InlinePatcherTests.cs index ece3b054..6c7db593 100644 --- a/src/DiffEngine.Tests/InlinePatcherTests.cs +++ b/src/DiffEngine.Tests/InlinePatcherTests.cs @@ -74,6 +74,52 @@ public async Task ASnapshotBeforeARawInterpolatedStringIsStillFound() const string rawInterpolated = " var text = $" + q3 + "{Render(" + q4 + "has " + q3 + " inside" + q4 + ")}" + q3 + ";"; + /// + /// A verbatim string that opens on an escaped quote. Measuring the quote run before asking + /// whether the literal is verbatim lexed this as a 3-quote raw string, which then ran to the + /// end of the file and hid every call after it. There is no verbatim raw form, so a run of + /// quotes after @" is content, not a delimiter. + /// + [Test] + public async Task AVerbatimStringOpeningOnAnEscapedQuoteIsSteppedOverWhole() + { + var source = Method($"{verbatimEscapedQuote}\n await Snapshot(\"old\");"); + var status = TryApply(source, 6, InlinePatchMode.Set, "\"old\"", "new", out var newSource, out var reason); + + await Assert.That(status).IsEqualTo(PatchStatus.Applied); + await Assert.That(reason).IsEmpty(); + await Assert.That(newSource).Contains("Snapshot(\"new\")"); + // And the literal it stepped over is untouched + await Assert.That(newSource).Contains(verbatimEscapedQuote); + } + + /// + /// The same shape with the call before it, so the scan has to get past the literal rather + /// than stop short of it. + /// + [Test] + public async Task ASnapshotBeforeAVerbatimStringOpeningOnAnEscapedQuoteIsStillFound() + { + var source = Method($" await Snapshot(\"old\");\n{verbatimEscapedQuote}"); + var status = TryApply(source, 5, InlinePatchMode.Set, "\"old\"", "new", out var newSource, out _); + + await Assert.That(status).IsEqualTo(PatchStatus.Applied); + await Assert.That(newSource).Contains("Snapshot(\"new\")"); + await Assert.That(newSource).Contains(verbatimEscapedQuote); + } + + // A verbatim string whose first content character is an escaped quote: + // + // var path = @"""C:\tools\run.exe"" --flag"; + // + // By concatenation for the same reason rawInterpolated is: the quote runs cannot be written + // in a literal here without a delimiter wider than the thing being described. + const string verbatimEscapedQuote = + " var path = @" + q3 + "C:" + slash + "tools" + slash + "run.exe" + q2 + " --flag" + q1 + ";"; + + const string q1 = "\""; + const string q2 = "\"\""; + const string slash = "\\"; const string q3 = "\"\"\""; const string q4 = "\"\"\"\""; diff --git a/src/DiffEngine/Inline/CsLanguage.cs b/src/DiffEngine/Inline/CsLanguage.cs index a74eb9e5..f8756749 100644 --- a/src/DiffEngine/Inline/CsLanguage.cs +++ b/src/DiffEngine/Inline/CsLanguage.cs @@ -208,7 +208,12 @@ static bool TrySkipStringLike(string source, ref int index) } var quotes = StringLiteral.QuoteRunLength(source, cursor); - if (quotes >= 3) + // Asked before the run is treated as a delimiter, because there is no verbatim raw form: + // a run of quotes after @" is an escaped quote and the start of the content. Measuring + // alone lexed a verbatim string opening on an escaped quote as a 3-quote raw string, + // which then ran to the end of the file and hid every call after it. CsStringLiteral and + // FsLanguage already ask this, and for the same reason + if (quotes >= 3 && !verbatim) { // Raw string: scan to a closing run of >= quotes, stepping over any interpolation // hole whole. Skipping holes as content read the quotes of a literal inside one -