From 2c61b2347ca0c7fec3aa26b2953462f7a2428483 Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Fri, 21 Aug 2026 20:42:53 +1000 Subject: [PATCH] Read an F# triple-quoted literal that carries no layout TryScanMultiLine rejected any multi-line literal whose content did not strip back to the layout convention. For C# that is right: a raw string not in that shape does not compile, so the content is something this never wrote. F# is the other case. Its triple-quoted literal is plain verbatim text and the layout is only an agreement between the code that renders a snapshot and the code that reads one back, so a hand-written literal with its content starting on the opening line - the idiomatic shape - is perfectly valid and its value is simply what it says. Rejecting those made them impossible to patch, and did so in the worst way: the two halves of the agreement disagreed. TryParse returned "not a string literal" while StripLayout, which is what a test library runs the value through, returned the same text unchanged, so the OriginalValue an F# producer sends as its anchor (FS0202 - no CallerArgumentExpression) could never match what the parser read. The snapshot reported "The expected argument of the call near line N is not a string literal" and stayed that way for the life of the file. Give TryScanMultiLine a layoutRequired flag: C# passes true, F# false and falls back to the verbatim newline-normalised content, which is the answer StripLayout already gives for the same text. ParseRejectsMalformedIndent pinned the old behaviour and becomes ParseKeepsContentWithNoLayoutToStrip, which also asserts the two readers agree. --- src/DiffEngine.Tests/FsStringLiteralTests.cs | 19 ++++++++--- src/DiffEngine.Tests/InlinePatcherFsTests.cs | 28 ++++++++++++++++ src/DiffEngine/Inline/CsStringLiteral.cs | 2 +- src/DiffEngine/Inline/FsStringLiteral.cs | 9 +++--- src/DiffEngine/Inline/StringLiteral.cs | 34 ++++++++++++++++++-- 5 files changed, 80 insertions(+), 12 deletions(-) diff --git a/src/DiffEngine.Tests/FsStringLiteralTests.cs b/src/DiffEngine.Tests/FsStringLiteralTests.cs index e6841543..d48dced0 100644 --- a/src/DiffEngine.Tests/FsStringLiteralTests.cs +++ b/src/DiffEngine.Tests/FsStringLiteralTests.cs @@ -300,12 +300,21 @@ public async Task ParseRejects(string expression) await Assert.That(parsed).IsFalse(); } - // The indent is stripped by ordinal prefix, so a content line less indented than the closing - // delimiter is not something this can read + // A triple-quoted literal that is not written to the layout convention. F# has no raw string + // form, so this is a perfectly good literal and its value is simply what it says - which is + // also what StripLayout returns for the same text, and the two have to agree or an + // OriginalValue anchor can never match. [Test] - public async Task ParseRejectsMalformedIndent() + [Arguments("\"\"\"\n a\n \"\"\"", "\n a\n ")] + // The idiomatic hand-written shape: content starting on the opening line. + [Arguments("\"\"\"{\n \"a\": 1\n}\"\"\"", "{\n \"a\": 1\n}")] + public async Task ParseKeepsContentWithNoLayoutToStrip(string expression, string expected) { - var parsed = FsStringLiteral.TryParse("\"\"\"\n a\n \"\"\"", out _); - await Assert.That(parsed).IsFalse(); + var parsed = FsStringLiteral.TryParse(expression, out var value); + await Assert.That(parsed).IsTrue(); + await Assert.That(value).IsEqualTo(expected); + // Whatever a producer sends as OriginalValue for this literal goes through StripLayout, + // so the two readers have to land in the same place + await Assert.That(FsStringLiteral.StripLayout(value!)).IsEqualTo(expected); } } diff --git a/src/DiffEngine.Tests/InlinePatcherFsTests.cs b/src/DiffEngine.Tests/InlinePatcherFsTests.cs index 5726f10a..86350c0b 100644 --- a/src/DiffEngine.Tests/InlinePatcherFsTests.cs +++ b/src/DiffEngine.Tests/InlinePatcherFsTests.cs @@ -28,6 +28,34 @@ await Assert.That(newSource).IsEqualTo( Test(" Verifier.Verify(15).Snapshot(\"new\").ToTask() |> Async.AwaitTask")); } + /// + /// A hand-written triple-quoted literal, content starting on the opening line. F# has no raw + /// string, so the layout is only a convention and a literal not written to it still holds a + /// perfectly good value. Rejecting those made them unpatchable: the anchor an F# producer + /// sends is OriginalValue (FS0202 means there is no CallerArgumentExpression to send), and + /// what the parser read back had to be able to equal it. + /// + [Test] + public async Task ReplaceHandWrittenTripleQuotedLiteral() + { + var literal = "\"\"\"{\n \"a\": 1\n}\"\"\""; + var source = Test($" Verifier.Verify(15).Snapshot({literal}).ToTask() |> Async.AwaitTask"); + + var status = TryApply( + source, + 5, + InlinePatchMode.Set, + null, + "new", + out var newSource, + out var reason, + originalValue: FsStringLiteral.StripLayout("{\n \"a\": 1\n}")); + + await Assert.That(status).IsEqualTo(PatchStatus.Applied); + await Assert.That(reason).IsEmpty(); + await Assert.That(newSource).IsEqualTo( + Test(" Verifier.Verify(15).Snapshot(\"new\").ToTask() |> Async.AwaitTask")); + } // The same shape C# writes: the content indented under the call, with the first line and the // closing delimiter's indentation there for the reader to take back off [Test] diff --git a/src/DiffEngine/Inline/CsStringLiteral.cs b/src/DiffEngine/Inline/CsStringLiteral.cs index 8d653087..64eb56d6 100644 --- a/src/DiffEngine/Inline/CsStringLiteral.cs +++ b/src/DiffEngine/Inline/CsStringLiteral.cs @@ -106,7 +106,7 @@ static bool TryScanLiteral(string text, int start, out string? value, out int en var quotes = StringLiteral.QuoteRunLength(text, index); if (quotes >= 3) { - return StringLiteral.TryScanMultiLine(text, index, quotes, out value, out end); + return StringLiteral.TryScanMultiLine(text, index, quotes, true, out value, out end); } if (quotes == 2) diff --git a/src/DiffEngine/Inline/FsStringLiteral.cs b/src/DiffEngine/Inline/FsStringLiteral.cs index 1a65e532..d2fc4666 100644 --- a/src/DiffEngine/Inline/FsStringLiteral.cs +++ b/src/DiffEngine/Inline/FsStringLiteral.cs @@ -85,9 +85,10 @@ public static string StripLayout(string value) => /// /// Parses an F# string literal expression back to the snapshot it holds: triple-quoted - /// ("""..."""), with the layout taken off, verbatim (@"...") and regular ("..."), which carry - /// their value as it is. Returns false for interpolated strings, byte strings, concatenations, - /// or any other expression. Newlines in the returned value are normalized to \n. + /// ("""..."""), with the layout taken off when it is written to that convention and + /// verbatim when it is not; verbatim (@"...") and regular ("..."), which carry their value as + /// it is. Returns false for interpolated strings, byte strings, concatenations, or any other + /// expression. Newlines in the returned value are normalized to \n. /// public static bool TryParse(string expression, [NotNullWhen(true)] out string? value) => StringLiteral.TryParse(expression, TryScanLiteral, out value); @@ -131,7 +132,7 @@ static bool TryScanLiteral(string text, int start, out string? value, out int en { // F# reads the closing delimiter as exactly three quotes, so a longer run is content // it cannot hold and never something this wrote - return StringLiteral.TryScanMultiLine(text, index, 3, out value, out end); + return StringLiteral.TryScanMultiLine(text, index, 3, false, out value, out end); } if (quotes == 2) diff --git a/src/DiffEngine/Inline/StringLiteral.cs b/src/DiffEngine/Inline/StringLiteral.cs index cf11fc38..b3fbc4cf 100644 --- a/src/DiffEngine/Inline/StringLiteral.cs +++ b/src/DiffEngine/Inline/StringLiteral.cs @@ -184,8 +184,24 @@ public static int QuoteRunLength(string text, int index) /// /// Scans a multi-line literal opening at with a run of /// , and returns what it holds with the layout taken off. + /// + /// is what separates the two languages. A C# raw string not + /// in the layout shape does not compile, so content that fails the strip is a literal this + /// never wrote and rejecting it is right. F#'s triple-quoted literal is plain verbatim text + /// and the layout is only a convention, so a hand-written one - content starting on the + /// opening line, which is the idiomatic shape - is a perfectly good literal whose value is + /// simply what it says. Rejecting those made them unpatchable: TryParse said "not a string + /// literal" while StripLayout, which is what a test library runs the value through, returned + /// the same text unchanged, so the producer's OriginalValue could never match. + /// /// - public static bool TryScanMultiLine(string text, int start, int quotes, out string? value, out int end) + public static bool TryScanMultiLine( + string text, + int start, + int quotes, + bool layoutRequired, + out string? value, + out int end) { value = null; end = start; @@ -224,7 +240,21 @@ public static bool TryScanMultiLine(string text, int start, int quotes, out stri return true; } - return TryStripLayout(SourceLanguage.NormalizeNewlines(content), out value); + var normalized = SourceLanguage.NormalizeNewlines(content); + if (TryStripLayout(normalized, out value)) + { + return true; + } + + if (layoutRequired) + { + return false; + } + + // Not in the layout shape, so there is no layout to take off and the content is the + // value. The same answer StripLayout gives for the same text + value = normalized; + return true; } ///