From 5ff6fb681b1eb131a20bf217d2c32547ee525f78 Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Sat, 15 Aug 2026 09:56:50 +1000 Subject: [PATCH 1/8] . --- claude.md | 11 + docs/inline.md | 49 +- docs/mdsource/inline.source.md | 49 +- docs/mdsource/viewer.source.md | 2 +- docs/viewer.md | 2 +- .../FsCompilerRoundTripTests.cs | 169 ++++++ src/DiffEngine.Tests/FsStringLiteralTests.cs | 268 +++++++++ src/DiffEngine.Tests/InlineApplierTests.cs | 28 +- src/DiffEngine.Tests/InlinePatcherFsTests.cs | 560 ++++++++++++++++++ src/DiffEngine.Tests/InlinePatcherTests.cs | 160 ++--- .../RequiresDotnetAttribute.cs | 56 ++ .../Inline/{CsScan.cs => CsLanguage.cs} | 191 +----- src/DiffEngine/Inline/CsStringLiteral.cs | 11 +- src/DiffEngine/Inline/FsLanguage.cs | 507 ++++++++++++++++ src/DiffEngine/Inline/FsStringLiteral.cs | 487 +++++++++++++++ src/DiffEngine/Inline/InlineApplier.cs | 9 +- src/DiffEngine/Inline/InlinePatch.cs | 5 +- src/DiffEngine/Inline/InlinePatcher.cs | 183 +++--- src/DiffEngine/Inline/SourceLanguage.cs | 222 +++++++ src/DiffEngine/Inline/SourceScan.cs | 164 +++++ src/DiffEngineViewer/CommandLine.cs | 2 +- src/DiffEngineViewer/QueueEntry.cs | 18 +- 22 files changed, 2791 insertions(+), 362 deletions(-) create mode 100644 src/DiffEngine.Tests/FsCompilerRoundTripTests.cs create mode 100644 src/DiffEngine.Tests/FsStringLiteralTests.cs create mode 100644 src/DiffEngine.Tests/InlinePatcherFsTests.cs create mode 100644 src/DiffEngine.Tests/RequiresDotnetAttribute.cs rename src/DiffEngine/Inline/{CsScan.cs => CsLanguage.cs} (62%) create mode 100644 src/DiffEngine/Inline/FsLanguage.cs create mode 100644 src/DiffEngine/Inline/FsStringLiteral.cs create mode 100644 src/DiffEngine/Inline/SourceLanguage.cs create mode 100644 src/DiffEngine/Inline/SourceScan.cs diff --git a/claude.md b/claude.md index 492624ae..5eb5add5 100644 --- a/claude.md +++ b/claude.md @@ -73,6 +73,17 @@ becomes the review surface. Accepting anywhere runs `InlineApplier` against the `SettleInline`, and any surface that applies a patch itself must settle too, or the queue owner keeps offering a snapshot that is already in the source. +The source may be C# or F#, decided by the file's extension (`SourceLanguage.ForFile`) rather than +stated on the patch. `InlinePatcher` walks the same structure either way — a name, an argument +list, a chain hung off it — and everything per language sits on `SourceLanguage`: the lexing that +fills a `SourceScan`, what tells a declaration from a call, and how a literal is written and read +back. F# is the awkward one, because it has no raw string: triple-quoted content is verbatim, so a +multi-line snapshot is written at the left margin, and its last line then decides the column of +everything after the literal. Where that would land left of the statement, F#'s offside rule stops +the file compiling, so the render falls back to a one-line escaped literal. That rule is asserted +by compiling patched source with `dotnet fsi` (`FsCompilerRoundTripTests`), because a belief about +F#'s lexis is exactly the kind of thing a second copy of the same belief cannot check. + ### Core Components **DiffEngine Library (`src/DiffEngine/`):** diff --git a/docs/inline.md b/docs/inline.md index 933fee21..9e9f6c13 100644 --- a/docs/inline.md +++ b/docs/inline.md @@ -67,7 +67,7 @@ sequenceDiagram Nothing touches disk on the happy path: a running owner receives the patch over the socket, a newly launched viewer receives it on stdin. Staging only happens in the fallback, where the test library writes three files (Verify: `*.received.txt`, `*.expected.txt`, `*.inlinepatch`) so the snapshot can still be reviewed by an IDE plugin, a plain text diff tool, or by hand: ``` -DiffEngineViewer --inline --source --line < the.inlinepatch +DiffEngineViewer --inline --source --line < the.inlinepatch ``` @@ -126,11 +126,56 @@ await Verify(value).Snapshot( **Line endings.** The file's dominant ending, with the content normalised to it, so a patch produced on one platform applies cleanly on another. A file that mixes endings keeps every ending it already had: only the spliced span is written, and the rest of the file — encoding, BOM and all — is preserved byte for byte. +## F# + +A patch says which file it edits, so nothing has to say which language that file is in: `.fs`, `.fsx` and `.fsi` are patched as F#, everything else as C#. The line ending, indentation and encoding rules above are the same either way, and so is everything else on this page — one applier, one queue, one protocol. `SourceLanguage.ForFile` returns the right one, and `FsStringLiteral` is the public peer of `CsStringLiteral`. + +What differs is the literal, because F# has no raw string. A triple-quoted string is verbatim from the character after the opening delimiter to the one before the closing one — no first line dropped, no common indentation stripped — so any indent written into it would be snapshot content. Multi-line content therefore starts on the delimiter's line and continues at the left margin, which is the only way a literal spanning lines reads back as the value it was rendered from. That much is checked by compiling patched source with `dotnet fsi` rather than by asserting what F# is believed to mean: + +```fsharp +// single line content +Verifier.Verify(value).Snapshot("the value").ToTask() + +// multi-line content +Verifier.Verify(value).Snapshot("""line one +line two""").ToTask() +``` + +Writing content at the left margin has a consequence: the content's last line decides the column of the closing delimiter, and so of the closing paren and anything chained after it. F#'s offside rule wants those at or right of the column the statement started in, and a snapshot ending in a newline — or in a short line, at a deeply indented call site — puts them left of it. That is not a formatting complaint; the file stops compiling. So the multi-line form is used only where its last line clears the call site's indentation, and everything else falls back to a regular literal on one source line, newlines escaped: + +```fsharp +Verifier.Verify(value).Snapshot("line one\nline two\n").ToTask() +``` + +There is also no way to widen the delimiter, so content that would run into it — content containing `"""`, or starting or ending with a quote — takes the verbatim form instead (`@"..."`, quotes doubled). Single line content is always a regular literal, escaping what F# escapes (`\` `"` `\a` `\b` `\f` `\t` `\v` `\n` `\r`) and `\uXXXX` for the rest, since F# has no `\0` or `\e`. + +Two syntax differences show up in `Append` and in an argument list. F# does not apply the implicit conversion that lets a `SettingsTask` be awaited, so an F# test ends its chain with `ToTask`; `Snapshot` returns the `SettingsTask`, so an appended call goes in front of that rather than after it. And an argument binds to a parameter with `=`, so an inserted named argument is `expected = "..."`. + +```fsharp +// before +Verifier.Verify(value) + .UseMethodName("customName") + .ToTask() + +// after an Append +Verifier.Verify(value) + .UseMethodName("customName") + .Snapshot("the value") + .ToTask() +``` + +One difference is not syntax at all. The F# compiler does not implement `CallerArgumentExpression` — it warns FS0202 and leaves the parameter at its default — so an F# patch never carries `originalExpression`, and the call is located by line hint and the search outward from it alone. Where a C# patch with no expression treats a differing literal as a conflict, an F# one has to take it as the snapshot that changed: the alternative is that an inline snapshot can be accepted once and never updated. + +Locating the call is otherwise the same scan, taught F#'s lexis: `(* *)` comments nest, a tick is a char literal only where it cannot be part of a name (`value'`) or a type parameter (`'T`), `(*)` is the multiplication operator rather than a comment, and a name is a declaration only where `let` or `member` says so. A call written without parentheses (`Verifier.Verify value`) is not found — the patch reports rather than corrupts, and the fix is to re-run after adding them. + +`FsStringLiteral.Render` takes the call site's indentation, like its C# peer, but means something else by it: not a prefix to write, since the content is verbatim, but the column the result has to clear. A surface rendering its own literal has to pass the indentation of the statement it is splicing into, or it will produce the form that does not compile there. + + ## Applying a patch from another surface The contract for a review surface of its own, which is what the ReSharper / Rider plugin is: read the staged patch with `InlinePatchFile.TryRead`, apply it with `InlineApplier.Apply`, and honour two rules. -* **InlineApplier owns all locking.** A per file cross process mutex (up to a ten second wait) plus an in process gate serialise every writer, so applying beside a concurrently accepting tray or viewer is safe, and callers must not add locking of their own. The file's encoding, BOM and line endings are preserved. +* **InlineApplier owns all locking.** A per file cross process mutex (up to a ten second wait) plus an in process gate serialise every writer, so applying beside a concurrently accepting tray or viewer is safe, and callers must not add locking of their own. The file's encoding, BOM and line endings are preserved, and its extension picks the language. * **Settle what was applied.** The same test run that staged the files may also have queued the patch with the port owner, and that queue outlives both the window and the run. After `Applied` or `AlreadyApplied`, call `DiffRunner.SettleInline(patch.SourceFile, patch.LineHint)` — otherwise the tray keeps offering a snapshot that is already in the source. `Apply` returns `Applied`, `AlreadyApplied` (the literal already matches), `NotFound` (the source changed since the test run — tell the user to re-run rather than retrying), or a failure with a message (locked file, unreadable source), which is retryable. diff --git a/docs/mdsource/inline.source.md b/docs/mdsource/inline.source.md index d2ffe4ca..395b971d 100644 --- a/docs/mdsource/inline.source.md +++ b/docs/mdsource/inline.source.md @@ -60,7 +60,7 @@ sequenceDiagram Nothing touches disk on the happy path: a running owner receives the patch over the socket, a newly launched viewer receives it on stdin. Staging only happens in the fallback, where the test library writes three files (Verify: `*.received.txt`, `*.expected.txt`, `*.inlinepatch`) so the snapshot can still be reviewed by an IDE plugin, a plain text diff tool, or by hand: ``` -DiffEngineViewer --inline --source --line < the.inlinepatch +DiffEngineViewer --inline --source --line < the.inlinepatch ``` @@ -119,11 +119,56 @@ await Verify(value).Snapshot( **Line endings.** The file's dominant ending, with the content normalised to it, so a patch produced on one platform applies cleanly on another. A file that mixes endings keeps every ending it already had: only the spliced span is written, and the rest of the file — encoding, BOM and all — is preserved byte for byte. +## F# + +A patch says which file it edits, so nothing has to say which language that file is in: `.fs`, `.fsx` and `.fsi` are patched as F#, everything else as C#. The line ending, indentation and encoding rules above are the same either way, and so is everything else on this page — one applier, one queue, one protocol. `SourceLanguage.ForFile` returns the right one, and `FsStringLiteral` is the public peer of `CsStringLiteral`. + +What differs is the literal, because F# has no raw string. A triple-quoted string is verbatim from the character after the opening delimiter to the one before the closing one — no first line dropped, no common indentation stripped — so any indent written into it would be snapshot content. Multi-line content therefore starts on the delimiter's line and continues at the left margin, which is the only way a literal spanning lines reads back as the value it was rendered from. That much is checked by compiling patched source with `dotnet fsi` rather than by asserting what F# is believed to mean: + +```fsharp +// single line content +Verifier.Verify(value).Snapshot("the value").ToTask() + +// multi-line content +Verifier.Verify(value).Snapshot("""line one +line two""").ToTask() +``` + +Writing content at the left margin has a consequence: the content's last line decides the column of the closing delimiter, and so of the closing paren and anything chained after it. F#'s offside rule wants those at or right of the column the statement started in, and a snapshot ending in a newline — or in a short line, at a deeply indented call site — puts them left of it. That is not a formatting complaint; the file stops compiling. So the multi-line form is used only where its last line clears the call site's indentation, and everything else falls back to a regular literal on one source line, newlines escaped: + +```fsharp +Verifier.Verify(value).Snapshot("line one\nline two\n").ToTask() +``` + +There is also no way to widen the delimiter, so content that would run into it — content containing `"""`, or starting or ending with a quote — takes the verbatim form instead (`@"..."`, quotes doubled). Single line content is always a regular literal, escaping what F# escapes (`\` `"` `\a` `\b` `\f` `\t` `\v` `\n` `\r`) and `\uXXXX` for the rest, since F# has no `\0` or `\e`. + +Two syntax differences show up in `Append` and in an argument list. F# does not apply the implicit conversion that lets a `SettingsTask` be awaited, so an F# test ends its chain with `ToTask`; `Snapshot` returns the `SettingsTask`, so an appended call goes in front of that rather than after it. And an argument binds to a parameter with `=`, so an inserted named argument is `expected = "..."`. + +```fsharp +// before +Verifier.Verify(value) + .UseMethodName("customName") + .ToTask() + +// after an Append +Verifier.Verify(value) + .UseMethodName("customName") + .Snapshot("the value") + .ToTask() +``` + +One difference is not syntax at all. The F# compiler does not implement `CallerArgumentExpression` — it warns FS0202 and leaves the parameter at its default — so an F# patch never carries `originalExpression`, and the call is located by line hint and the search outward from it alone. Where a C# patch with no expression treats a differing literal as a conflict, an F# one has to take it as the snapshot that changed: the alternative is that an inline snapshot can be accepted once and never updated. + +Locating the call is otherwise the same scan, taught F#'s lexis: `(* *)` comments nest, a tick is a char literal only where it cannot be part of a name (`value'`) or a type parameter (`'T`), `(*)` is the multiplication operator rather than a comment, and a name is a declaration only where `let` or `member` says so. A call written without parentheses (`Verifier.Verify value`) is not found — the patch reports rather than corrupts, and the fix is to re-run after adding them. + +`FsStringLiteral.Render` takes the call site's indentation, like its C# peer, but means something else by it: not a prefix to write, since the content is verbatim, but the column the result has to clear. A surface rendering its own literal has to pass the indentation of the statement it is splicing into, or it will produce the form that does not compile there. + + ## Applying a patch from another surface The contract for a review surface of its own, which is what the ReSharper / Rider plugin is: read the staged patch with `InlinePatchFile.TryRead`, apply it with `InlineApplier.Apply`, and honour two rules. -* **InlineApplier owns all locking.** A per file cross process mutex (up to a ten second wait) plus an in process gate serialise every writer, so applying beside a concurrently accepting tray or viewer is safe, and callers must not add locking of their own. The file's encoding, BOM and line endings are preserved. +* **InlineApplier owns all locking.** A per file cross process mutex (up to a ten second wait) plus an in process gate serialise every writer, so applying beside a concurrently accepting tray or viewer is safe, and callers must not add locking of their own. The file's encoding, BOM and line endings are preserved, and its extension picks the language. * **Settle what was applied.** The same test run that staged the files may also have queued the patch with the port owner, and that queue outlives both the window and the run. After `Applied` or `AlreadyApplied`, call `DiffRunner.SettleInline(patch.SourceFile, patch.LineHint)` — otherwise the tray keeps offering a snapshot that is already in the source. `Apply` returns `Applied`, `AlreadyApplied` (the literal already matches), `NotFound` (the source changed since the test run — tell the user to re-run rather than retrying), or a failure with a message (locked file, unreadable source), which is retryable. diff --git a/docs/mdsource/viewer.source.md b/docs/mdsource/viewer.source.md index 11e9672f..75c87d6c 100644 --- a/docs/mdsource/viewer.source.md +++ b/docs/mdsource/viewer.source.md @@ -41,7 +41,7 @@ DiffEngineViewer Reviewing an inline snapshot, where the patch payload arrives on stdin: ``` -DiffEngineViewer --inline --source --line +DiffEngineViewer --inline --source --line ``` Reviewing a file a passing test no longer produces, which DiffEngine sends when no tray is running: diff --git a/docs/viewer.md b/docs/viewer.md index 6425ca82..6a98419a 100644 --- a/docs/viewer.md +++ b/docs/viewer.md @@ -48,7 +48,7 @@ DiffEngineViewer Reviewing an inline snapshot, where the patch payload arrives on stdin: ``` -DiffEngineViewer --inline --source --line +DiffEngineViewer --inline --source --line ``` Reviewing a file a passing test no longer produces, which DiffEngine sends when no tray is running: diff --git a/src/DiffEngine.Tests/FsCompilerRoundTripTests.cs b/src/DiffEngine.Tests/FsCompilerRoundTripTests.cs new file mode 100644 index 00000000..55eec3ab --- /dev/null +++ b/src/DiffEngine.Tests/FsCompilerRoundTripTests.cs @@ -0,0 +1,169 @@ +/// +/// Patches F# the way a real accept does, then hands the result to the F# compiler and asks what +/// the literal is worth at runtime. +/// +/// Everything else about F# rendering is asserted against what this repo believes F# means - +/// verbatim triple-quoted content, no indent stripping, which escapes exist. That belief is the +/// thing most likely to be wrong, and being wrong about it produces a snapshot that compiles and +/// silently differs, or a file that no longer compiles at all. So it is checked against fsi rather +/// than against another copy of the belief. +/// +/// +public class FsCompilerRoundTripTests +{ + static readonly string[] cases = + [ + "", + " ", + "abc", + "a\nb", + "\nabc", + "abc\n", + "\nabc\n", + "a\n\n\nb", + "line1\n indented\nline3", + "a\n \nb", + "trailing space \nnext", + "has \"quotes\" inside", + "has \"quotes\"\nover lines", + "back\\slash", + "back\\slash\nover lines", + "tab\there", + "tab\there\nover lines", + "bell\a and vertical\v tab", + "esc null\0 del", + "emoji 🎈 and unicode ☂", + "emoji 🎈\nover lines", + "$ {value} {{x}} %d", + "{ \"json\": true }\n{ \"more\": 1 }", + "\"", + "\"\"", + "\"\"\"", + "\"\"\"\n\"\"\"", + "\"starts with a quote\nsecond", + "ends with a quote\nsecond\"", + "has \"\"\" inside\nsecond", + "(* not a comment *)\nsecond", + "// not a comment\nsecond", + "'ticked'\nsecond" + ]; + + [Test] + [RequiresDotnet] + public async Task PatchedSourceCompilesAndReadsBack() + { + var script = BuildScript(); + var path = Path.Combine(Path.GetTempPath(), $"DiffEngineFsRoundTrip_{Guid.NewGuid():N}.fsx"); + File.WriteAllText(path, script, new UTF8Encoding(false)); + try + { + var (exitCode, output) = RunFsi(path); + + // Names the case and prints both values when one differs, so the output is the report + await Assert.That(output).Contains("ALL OK"); + await Assert.That(exitCode).IsEqualTo(0); + } + finally + { + File.Delete(path); + } + } + + static string BuildScript() + { + var builder = new StringBuilder(); + builder.Append( + """ + type Chain(value: string) = + member _.Snapshot(expected: string) = Chain(expected) + member _.ToTask() = value + + let Verify (value: string) = Chain(value) + + let mutable failures = 0 + + let check (name: string) (actual: string) (expectedBase64: string) = + let expected = System.Text.Encoding.UTF8.GetString(System.Convert.FromBase64String expectedBase64) + if actual <> expected then + failures <- failures + 1 + printfn "FAIL %s" name + printfn " actual %A" actual + printfn " expected %A" expected + + + """); + + for (var index = 0; index < cases.Length; index++) + { + var content = cases[index]; + var expected = Convert.ToBase64String(Encoding.UTF8.GetBytes(content)); + + // Set: the literal goes into a Snapshot call that is already there + builder.Append(Patch($"let set{index} () =\n Verify(\"x\").Snapshot().ToTask()\n", 2, InlinePatchMode.Set, content)); + builder.Append($"check \"set{index}\" (set{index} ()) \"{expected}\"\n\n"); + + // Append: there is no Snapshot call yet, so one is written in front of ToTask + builder.Append(Patch($"let append{index} () =\n Verify(\"x\").ToTask()\n", 2, InlinePatchMode.Append, content)); + builder.Append($"check \"append{index}\" (append{index} ()) \"{expected}\"\n\n"); + + // And a call site indented further in, where a multi-line literal's closing delimiter + // would land left of the statement and the layout would not survive it + builder.Append( + Patch( + $"let deep{index} () =\n let inner () =\n Verify(\"x\").Snapshot().ToTask()\n inner ()\n", + 3, + InlinePatchMode.Set, + content)); + builder.Append($"check \"deep{index}\" (deep{index} ()) \"{expected}\"\n\n"); + + // A chain across lines, where the call after the literal is on the line below it + builder.Append( + Patch( + $"let chain{index} () =\n Verify(\"x\")\n .Snapshot()\n .ToTask()\n", + 3, + InlinePatchMode.Set, + content)); + builder.Append($"check \"chain{index}\" (chain{index} ()) \"{expected}\"\n\n"); + } + + builder.Append( + """ + if failures = 0 then printfn "ALL OK" else printfn "%d FAILURES" failures + exit failures + + """); + return builder.ToString(); + } + + static string Patch(string snippet, int lineHint, InlinePatchMode mode, string content) + { + var status = InlinePatcher.TryApply(SourceLanguage.FSharp, snippet, lineHint, mode, null, content, out var patched, out var reason); + if (status != PatchStatus.Applied) + { + throw new($"{mode} patch was not applied: {reason}"); + } + + return patched; + } + + static (int exitCode, string output) RunFsi(string path) + { + var startInfo = new ProcessStartInfo(RequiresDotnetAttribute.DotnetPath!) + { + Arguments = $"fsi --nologo \"{path}\"", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + using var process = Process.Start(startInfo)!; + var output = process.StandardOutput.ReadToEnd() + process.StandardError.ReadToEnd(); + if (!process.WaitForExit(120000)) + { + process.Kill(); + throw new("fsi did not exit within two minutes."); + } + + return (process.ExitCode, output); + } +} diff --git a/src/DiffEngine.Tests/FsStringLiteralTests.cs b/src/DiffEngine.Tests/FsStringLiteralTests.cs new file mode 100644 index 00000000..a88de1e6 --- /dev/null +++ b/src/DiffEngine.Tests/FsStringLiteralTests.cs @@ -0,0 +1,268 @@ +public class FsStringLiteralTests +{ + static readonly string[] renderRoundTripCases = + [ + "", + " ", + "abc", + "abc\"\"\"", + "\"\"\"\n\"\"\"", + "a\nb", + "\nabc", + "abc\n", + "\nabc\n", + "a\n\n\nb", + "\"", + "\"\"", + "\"\"\"", + "\"\"\"\"\"\"", + "\"\"\"starts with quotes", + "ends with quote\"", + "$ {value} {{x}}", + "a\n \nb", + "trailing space \nnext", + "emoji 🎈 and unicode ☂", + "line1\n indented\nline3", + "back\\slash\nsecond", + "tab\there\nsecond" + ]; + + // Content is verbatim, so it starts on the delimiter's line and stays at the left margin + [Test] + public async Task RenderMultiLine() + { + var rendered = FsStringLiteral.RenderMultiLine("a\nb", "\n"); + await Assert.That(rendered).IsEqualTo("\"\"\"a\nb\"\"\""); + } + + [Test] + public async Task RenderMultiLineIndentIsContent() + { + var rendered = FsStringLiteral.RenderMultiLine("a\n indented\nb", "\n"); + await Assert.That(rendered).IsEqualTo("\"\"\"a\n indented\nb\"\"\""); + } + + [Test] + public async Task RenderEmpty() + { + var rendered = FsStringLiteral.RenderMultiLine("", "\n"); + await Assert.That(rendered).IsEqualTo("\"\""); + } + + [Test] + public async Task RenderCrlf() + { + var rendered = FsStringLiteral.RenderMultiLine("a\nb", "\r\n"); + await Assert.That(rendered).IsEqualTo("\"\"\"a\r\nb\"\"\""); + } + + // F# cannot widen a triple-quoted delimiter, so content that runs into one takes the + // verbatim form instead + [Test] + [Arguments("has \"\"\" inside\nsecond", "@\"has \"\"\"\"\"\" inside\nsecond\"")] + [Arguments("\"starts with a quote\nsecond", "@\"\"\"starts with a quote\nsecond\"")] + [Arguments("ends with a quote\nsecond\"", "@\"ends with a quote\nsecond\"\"\"")] + public async Task RenderMultiLineFallsBackToVerbatim(string content, string expected) + { + var rendered = FsStringLiteral.RenderMultiLine(content, "\n"); + await Assert.That(rendered).IsEqualTo(expected); + } + + // A single quote in the middle is no problem for the triple-quoted form + [Test] + public async Task RenderMultiLineKeepsQuotesInTheMiddle() + { + var rendered = FsStringLiteral.RenderMultiLine("a \"quoted\" word\nsecond", "\n"); + await Assert.That(rendered).IsEqualTo("\"\"\"a \"quoted\" word\nsecond\"\"\""); + } + + [Test] + [Arguments("a\r\nb")] + [Arguments("a\rb")] + [Arguments("a\r\nb\rc\nd")] + public async Task RenderNormalizesCarriageReturns(string content) + { + // Content is meant to arrive \n normalized; a stray \r must not reach the literal + var normalized = content.Replace("\r\n", "\n").Replace('\r', '\n'); + foreach (var eol in new[] { "\n", "\r\n" }) + { + var rendered = FsStringLiteral.RenderMultiLine(content, eol); + await Assert.That(rendered).IsEqualTo(FsStringLiteral.RenderMultiLine(normalized, eol)); + + var parsed = FsStringLiteral.TryParse(rendered, out var value); + await Assert.That(parsed).IsTrue(); + await Assert.That(value).IsEqualTo(normalized); + } + } + + [Test] + public async Task RenderRoundTrips() + { + foreach (var content in renderRoundTripCases) + { + foreach (var eol in new[] { "\n", "\r\n" }) + { + var rendered = FsStringLiteral.RenderMultiLine(content, eol); + var parsed = FsStringLiteral.TryParse(rendered, out var value); + await Assert.That(parsed).IsTrue(); + await Assert.That(value).IsEqualTo(content); + } + } + } + + [Test] + [Arguments("abc", "\"abc\"")] + [Arguments("", "\"\"")] + [Arguments(" ", "\" \"")] + [Arguments("has \"quotes\"", "\"has \\\"quotes\\\"\"")] + [Arguments("back\\slash", "\"back\\\\slash\"")] + [Arguments("tab\there", "\"tab\\there\"")] + [Arguments("bell\a", "\"bell\\a\"")] + // F# has no \0 or \e escape, so the \u form carries every other control character + [Arguments("esc\u001b", "\"esc\\u001b\"")] + [Arguments("null\0", "\"null\\u0000\"")] + [Arguments("emoji 🎈 and unicode ☂", "\"emoji 🎈 and unicode ☂\"")] + [Arguments("$ {value} {{x}}", "\"$ {value} {{x}}\"")] + public async Task RenderSingleLineIsRegular(string content, string expected) + { + var rendered = FsStringLiteral.Render(content, " ", "\n"); + await Assert.That(rendered).IsEqualTo(expected); + } + + [Test] + [Arguments("a\nb")] + [Arguments("a\rb")] + [Arguments("a\r\nb")] + [Arguments("\nabc")] + [Arguments("abc\n")] + public async Task RenderMultiLineIsVerbatim(string content) + { + var rendered = FsStringLiteral.Render(content, "", "\n"); + await Assert.That(rendered).IsEqualTo(FsStringLiteral.RenderMultiLine(content, "\n")); + } + + // The closing delimiter lands left of the column the statement started in, which F# reads as + // the end of the statement rather than as part of it. One source line instead + [Test] + [Arguments("abc\n", "\"abc\\n\"")] + [Arguments("a\nb", "\"a\\nb\"")] + [Arguments("has \"quotes\" and a \\\nx", "\"has \\\"quotes\\\" and a \\\\\\nx\"")] + public async Task RenderMultiLineFallsBackWhenItWouldBreakTheLayout(string content, string expected) + { + var rendered = FsStringLiteral.Render(content, " ", "\n"); + await Assert.That(rendered).IsEqualTo(expected); + } + + // The last line reaches the splice column, so the verbatim form stands + [Test] + public async Task RenderMultiLineKeptWhenTheLastLineIsLongEnough() + { + var rendered = FsStringLiteral.Render("first\nlong enough", " ", "\n"); + await Assert.That(rendered).IsEqualTo("\"\"\"first\nlong enough\"\"\""); + } + + [Test] + [Arguments("first\nabcde", " ", true)] + [Arguments("first\nabcd", " ", false)] + [Arguments("first\na", " ", true)] + [Arguments("first\n", " ", false)] + [Arguments("first\n", "", true)] + public async Task ClearsOffsideLine(string content, string indent, bool expected) + { + var rendered = FsStringLiteral.RenderMultiLine(content, "\n"); + await Assert.That(FsStringLiteral.ClearsOffsideLine(rendered, indent)).IsEqualTo(expected); + } + + [Test] + public async Task RenderRoundTripsWhicheverFormItPicks() + { + foreach (var content in renderRoundTripCases) + { + foreach (var eol in new[] { "\n", "\r\n" }) + { + foreach (var indent in new[] { "", " ", " " }) + { + var rendered = FsStringLiteral.Render(content, indent, eol); + var parsed = FsStringLiteral.TryParse(rendered, out var value); + await Assert.That(parsed).IsTrue(); + await Assert.That(value).IsEqualTo(content); + } + } + } + } + + [Test] + [Arguments("\"a\"", "a")] + [Arguments("\"\"", "")] + [Arguments("\"a\\nb\"", "a\nb")] + [Arguments("\"tab\\there\"", "tab\there")] + [Arguments("\"quote\\\"q\"", "quote\"q")] + [Arguments("\"back\\\\slash\"", "back\\slash")] + [Arguments("\"\\u0041\"", "A")] + [Arguments("\"\\x41\"", "A")] + [Arguments("\"\\U0001F600\"", "😀")] + [Arguments("\"\\065\"", "A")] + [Arguments("@\"a\"\"b\"", "a\"b")] + [Arguments("@\"\"", "")] + // Verbatim has no triple-quoted form: the run after @" is an escaped quote + [Arguments("@\"\"\"abc\"\"\"", "\"abc\"")] + [Arguments("\"\"\"a\"b\"\"\"", "a\"b")] + // Ordinary F# strings may span lines + [Arguments("\"a\nb\"", "a\nb")] + public async Task Parse(string expression, string expected) + { + var parsed = FsStringLiteral.TryParse(expression, out var value); + await Assert.That(parsed).IsTrue(); + await Assert.That(value).IsEqualTo(expected); + } + + // A backslash before a line break drops the break and the indentation that follows it + [Test] + public async Task ParseLineContinuation() + { + var parsed = FsStringLiteral.TryParse("\"a\\\n b\"", out var value); + await Assert.That(parsed).IsTrue(); + await Assert.That(value).IsEqualTo("ab"); + } + + [Test] + public async Task ParseMultiLineVerbatim() + { + var parsed = FsStringLiteral.TryParse("@\"a\r\nb\"", out var value); + await Assert.That(parsed).IsTrue(); + await Assert.That(value).IsEqualTo("a\nb"); + } + + // Where C# would drop the first line and strip the closing delimiter's indent, F# keeps + // every character between the delimiters + [Test] + public async Task ParseTripleQuotedIsVerbatim() + { + var expression = "\"\"\"\n a\n\n b\n \"\"\""; + var parsed = FsStringLiteral.TryParse(expression, out var value); + await Assert.That(parsed).IsTrue(); + await Assert.That(value).IsEqualTo("\n a\n\n b\n "); + } + + [Test] + [Arguments("$\"interpolated\"")] + [Arguments("$\"\"\"interpolated\"\"\"")] + [Arguments("nameof(x)")] + [Arguments("\"a\" + \"b\"")] + [Arguments("\"unterminated")] + [Arguments("identifier")] + [Arguments("")] + // A byte string is not a string + [Arguments("\"bytes\"B")] + [Arguments("@\"bytes\"B")] + // A trigraph is three digits or nothing + [Arguments("\"\\0\"")] + [Arguments("\"\\12\"")] + // Not an F# escape + [Arguments("\"\\e\"")] + public async Task ParseRejects(string expression) + { + var parsed = FsStringLiteral.TryParse(expression, out _); + await Assert.That(parsed).IsFalse(); + } +} diff --git a/src/DiffEngine.Tests/InlineApplierTests.cs b/src/DiffEngine.Tests/InlineApplierTests.cs index 4b536c9a..4a37470f 100644 --- a/src/DiffEngine.Tests/InlineApplierTests.cs +++ b/src/DiffEngine.Tests/InlineApplierTests.cs @@ -1,8 +1,8 @@ public class InlineApplierTests { - static string WriteTemp(byte[] bytes) + static string WriteTemp(byte[] bytes, string extension = ".cs") { - var path = Path.Combine(Path.GetTempPath(), $"InlineApplierTests_{Guid.NewGuid():N}.cs"); + var path = Path.Combine(Path.GetTempPath(), $"InlineApplierTests_{Guid.NewGuid():N}{extension}"); File.WriteAllBytes(path, bytes); return path; } @@ -241,6 +241,30 @@ public async Task TryParseToleratesLeadingBom() await Assert.That(result.NewContent).IsEqualTo("new content"); } + // The extension picks the language, so the same patch content is written as the literal that + // file's compiler reads. A C# raw string here would not even parse + [Test] + [Arguments(".fs")] + [Arguments(".fsx")] + [Arguments(".FS")] + public async Task FSharpFileGetsAnFSharpLiteral(string extension) + { + var fsharp = "module Tests\n\nlet MyTest () =\n Verifier.Verify(value).Snapshot(\"old\").ToTask()\n"; + var path = WriteTemp(Utf8(fsharp, bom: false), extension); + try + { + var result = InlineApplier.Apply(Patch(path, 4, "\"old\"", "a\nb")); + + await Assert.That(result.Status).IsEqualTo(InlineApplyStatus.Applied); + await Assert.That(await File.ReadAllTextAsync(path)).IsEqualTo( + "module Tests\n\nlet MyTest () =\n Verifier.Verify(value).Snapshot(\"\"\"a\nb\"\"\").ToTask()\n"); + } + finally + { + File.Delete(path); + } + } + [Test] public async Task MissingFileFails() { diff --git a/src/DiffEngine.Tests/InlinePatcherFsTests.cs b/src/DiffEngine.Tests/InlinePatcherFsTests.cs new file mode 100644 index 00000000..def9552b --- /dev/null +++ b/src/DiffEngine.Tests/InlinePatcherFsTests.cs @@ -0,0 +1,560 @@ +public class InlinePatcherFsTests +{ + // Line 5 is the first line of the body + static string Test(string body) => + $"module Tests\n\n[]\nlet MyTest () =\n{body}\n"; + + static PatchStatus TryApply( + string source, + int lineHint, + InlinePatchMode mode, + string? originalExpression, + string newContent, + out string newSource, + out string failReason) => + InlinePatcher.TryApply(SourceLanguage.FSharp, source, lineHint, mode, originalExpression, newContent, out newSource, out failReason); + + [Test] + public async Task ReplaceRegularLiteral() + { + var source = Test(" Verifier.Verify(15).Snapshot(\"old\").ToTask() |> Async.AwaitTask"); + + var status = TryApply(source, 5, InlinePatchMode.Set, "\"old\"", "new", out var newSource, out _); + + await Assert.That(status).IsEqualTo(PatchStatus.Applied); + await Assert.That(newSource).IsEqualTo( + Test(" Verifier.Verify(15).Snapshot(\"new\").ToTask() |> Async.AwaitTask")); + } + + // The content of a triple-quoted literal is verbatim, so it starts on the delimiter's line + // and every line after it sits at the left margin. An indent would be snapshot content + [Test] + public async Task MultiLineContentIsNotIndented() + { + var source = Test(" Verifier.Verify(15).Snapshot().ToTask() |> Async.AwaitTask"); + + var status = TryApply(source, 5, InlinePatchMode.Set, null, "a\nb", out var newSource, out _); + + await Assert.That(status).IsEqualTo(PatchStatus.Applied); + await Assert.That(newSource).IsEqualTo( + Test(" Verifier.Verify(15).Snapshot(\"\"\"a\nb\"\"\").ToTask() |> Async.AwaitTask")); + } + + // The closing delimiter would land at column 0..2, left of the statement, and F# reads that as + // the end of the statement rather than as part of it. One source line instead + [Test] + public async Task ContentEndingInANewlineTakesTheEscapedForm() + { + var source = Test(" Verifier.Verify(15).Snapshot().ToTask() |> Async.AwaitTask"); + + var status = TryApply(source, 5, InlinePatchMode.Set, null, "a\nb\n", out var newSource, out _); + + await Assert.That(status).IsEqualTo(PatchStatus.Applied); + await Assert.That(newSource).IsEqualTo( + Test(" Verifier.Verify(15).Snapshot(\"a\\nb\\n\").ToTask() |> Async.AwaitTask")); + } + + // Same content, deeper call site: the last line no longer reaches the statement's column + [Test] + public async Task DeepCallSiteTakesTheEscapedForm() + { + var source = Test( + " let inner () =\n" + + " Verifier.Verify(15).Snapshot().ToTask()\n" + + " inner ()"); + + var status = TryApply(source, 6, InlinePatchMode.Set, null, "a\nb", out var newSource, out _); + + await Assert.That(status).IsEqualTo(PatchStatus.Applied); + await Assert.That(newSource).Contains("Snapshot(\"a\\nb\").ToTask()"); + } + + [Test] + public async Task ReplaceTripleQuotedLiteral() + { + var source = Test(" Verifier.Verify(15).Snapshot(\"\"\"old1\nold2\"\"\").ToTask() |> Async.AwaitTask"); + + var status = TryApply(source, 5, InlinePatchMode.Set, "\"\"\"old1\nold2\"\"\"", "new1\nnew2", out var newSource, out _); + + await Assert.That(status).IsEqualTo(PatchStatus.Applied); + await Assert.That(newSource).IsEqualTo( + Test(" Verifier.Verify(15).Snapshot(\"\"\"new1\nnew2\"\"\").ToTask() |> Async.AwaitTask")); + } + + [Test] + public async Task ReplacementUsesFileEol() + { + var source = Test(" Verifier.Verify(15).Snapshot(\"old\").ToTask()").Replace("\n", "\r\n"); + + var status = TryApply(source, 5, InlinePatchMode.Set, "\"old\"", "a\nb", out var newSource, out _); + + await Assert.That(status).IsEqualTo(PatchStatus.Applied); + await Assert.That(newSource).Contains("Snapshot(\"\"\"a\r\nb\"\"\")"); + await Assert.That(newSource).DoesNotContain("a\nb"); + } + + [Test] + public async Task AlreadyAppliedWhenLiteralMatches() + { + var source = Test(" Verifier.Verify(15).Snapshot(\"same\").ToTask()"); + + var status = TryApply(source, 5, InlinePatchMode.Set, "\"same\"", "same", out _, out _); + + await Assert.That(status).IsEqualTo(PatchStatus.AlreadyApplied); + } + + // What is rendered has to read back as what it was rendered from, or the next run patches + // a literal it thinks is different + [Test] + public async Task ReapplyingTheSameContentIsAlreadyApplied() + { + var source = Test(" Verifier.Verify(15).Snapshot().ToTask()"); + + TryApply(source, 5, InlinePatchMode.Set, null, "a\nb", out var applied, out _); + var status = TryApply(applied, 5, InlinePatchMode.Set, null, "a\nb", out _, out _); + + await Assert.That(status).IsEqualTo(PatchStatus.AlreadyApplied); + } + + // F# does not implement CallerArgumentExpression (FS0202), so an F# patch never carries the + // previous expression and a changed snapshot arrives looking exactly like a conflict. Taking + // it as the conflict would mean a snapshot could be accepted once and never updated + [Test] + public async Task ChangedSnapshotIsReplacedWithNoExpressionToAnchorOn() + { + var source = Test(" Verifier.Verify(15).Snapshot(\"old\").ToTask()"); + + var status = TryApply(source, 5, InlinePatchMode.Set, null, "new", out var newSource, out _); + + await Assert.That(status).IsEqualTo(PatchStatus.Applied); + await Assert.That(newSource).IsEqualTo(Test(" Verifier.Verify(15).Snapshot(\"new\").ToTask()")); + } + + [Test] + public async Task ChangedSnapshotPicksTheSiteNearestTheHint() + { + var source = string.Join( + "\n", + "module Tests", + "", + "let TestA () =", + " Verifier.Verify(a).Snapshot(\"a\").ToTask()", + "", + "let TestB () =", + " Verifier.Verify(b).Snapshot(\"b\").ToTask()"); + + var status = TryApply(source, 7, InlinePatchMode.Set, null, "new", out var newSource, out _); + + await Assert.That(status).IsEqualTo(PatchStatus.Applied); + await Assert.That(newSource).Contains("Verify(a).Snapshot(\"a\")"); + await Assert.That(newSource).Contains("Verify(b).Snapshot(\"new\")"); + } + + [Test] + public async Task InsertIntoEmptyArgumentList() + { + var source = Test(" Verifier.Verify(15).Snapshot().ToTask()"); + + var status = TryApply(source, 5, InlinePatchMode.Set, null, "new", out var newSource, out _); + + await Assert.That(status).IsEqualTo(PatchStatus.Applied); + await Assert.That(newSource).Contains("Snapshot(\"new\")"); + } + + // F# binds an argument to a name with =, not : + [Test] + public async Task InsertBeforeAnotherNamedArgument() + { + var source = Test(" Verifier.Verify(15).Snapshot(file = myFile).ToTask()"); + + var status = TryApply(source, 5, InlinePatchMode.Set, null, "new", out var newSource, out _); + + await Assert.That(status).IsEqualTo(PatchStatus.Applied); + await Assert.That(newSource).Contains("Snapshot(expected = \"new\", file = myFile)"); + } + + [Test] + public async Task ReplaceANamedExpectedArgument() + { + var source = Test(" Verifier.Verify(15).Snapshot(expected = \"old\").ToTask()"); + + 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(expected = \"new\")"); + } + + // F# does not apply the conversion that lets a SettingsTask be awaited, so the chain ends in + // ToTask. Snapshot returns the SettingsTask, so it has to go in front of it + [Test] + public async Task AppendGoesInFrontOfToTask() + { + var source = Test(" Verifier.Verify(15).ToTask() |> Async.AwaitTask"); + + var status = TryApply(source, 5, InlinePatchMode.Append, null, "new", out var newSource, out _); + + await Assert.That(status).IsEqualTo(PatchStatus.Applied); + await Assert.That(newSource).IsEqualTo( + Test( + " Verifier.Verify(15)\n" + + " .Snapshot(\"new\").ToTask() |> Async.AwaitTask")); + } + + [Test] + public async Task AppendToAMultiLineChain() + { + var source = Test( + " Verifier\n" + + " .Verify(15)\n" + + " .UseMethodName(\"customName\")\n" + + " .ToTask()\n" + + " |> Async.AwaitTask"); + + var status = TryApply(source, 6, InlinePatchMode.Append, null, "new", out var newSource, out _); + + await Assert.That(status).IsEqualTo(PatchStatus.Applied); + await Assert.That(newSource).IsEqualTo( + Test( + " Verifier\n" + + " .Verify(15)\n" + + " .UseMethodName(\"customName\")\n" + + " .Snapshot(\"new\")\n" + + " .ToTask()\n" + + " |> Async.AwaitTask")); + } + + // Awaited in a task expression instead, so there is no ToTask and the chain end is the + // insertion point + [Test] + public async Task AppendWithNoToTask() + { + var source = Test( + " task {\n" + + " do! Verifier.Verify(15)\n" + + " }"); + + var status = TryApply(source, 6, InlinePatchMode.Append, null, "new", out var newSource, out _); + + await Assert.That(status).IsEqualTo(PatchStatus.Applied); + await Assert.That(newSource).IsEqualTo( + Test( + " task {\n" + + " do! Verifier.Verify(15)\n" + + " .Snapshot(\"new\")\n" + + " }")); + } + + [Test] + public async Task AppendMultiLineContent() + { + var source = Test(" Verifier.Verify(15).ToTask()"); + + var status = TryApply(source, 5, InlinePatchMode.Append, null, "a\nb", out var newSource, out _); + + await Assert.That(status).IsEqualTo(PatchStatus.Applied); + await Assert.That(newSource).IsEqualTo( + Test( + " Verifier.Verify(15)\n" + + " .Snapshot(\"\"\"a\nb\"\"\").ToTask()")); + } + + [Test] + public async Task AppendIsRefusedWhenOneIsAlreadyChained() + { + var source = Test(" Verifier.Verify(15).Snapshot(\"already\").ToTask()"); + + var status = TryApply(source, 5, InlinePatchMode.Append, null, "new", out _, out var reason); + + await Assert.That(status).IsEqualTo(PatchStatus.NotFound); + await Assert.That(reason).Contains("already has a Snapshot call"); + } + + [Test] + public async Task AppendSkipsAVerifyOnAnotherReceiver() + { + var source = Test(" Assert.isEmpty (ContentValidation.Verify(value))"); + + var status = TryApply(source, 5, InlinePatchMode.Append, null, "new", out _, out var reason); + + await Assert.That(status).IsEqualTo(PatchStatus.NotFound); + await Assert.That(reason).Contains("Could not find a Verify call"); + } + + [Test] + public async Task RemoveTakesTheWholeLine() + { + var source = Test( + " Verifier.Verify(15)\n" + + " .Snapshot(\"\"\"old1\nold2\"\"\")\n" + + " .ToTask() |> Async.AwaitTask"); + + var status = TryApply(source, 6, InlinePatchMode.Remove, null, "", out var newSource, out _); + + await Assert.That(status).IsEqualTo(PatchStatus.Applied); + await Assert.That(newSource).IsEqualTo( + Test( + " Verifier.Verify(15)\n" + + " .ToTask() |> Async.AwaitTask")); + } + + [Test] + public async Task RemoveFromASingleLineChain() + { + var source = Test(" Verifier.Verify(15).Snapshot(\"old\").ToTask()"); + + var status = TryApply(source, 5, InlinePatchMode.Remove, null, "", out var newSource, out _); + + await Assert.That(status).IsEqualTo(PatchStatus.Applied); + await Assert.That(newSource).IsEqualTo(Test(" Verifier.Verify(15).ToTask()")); + } + + [Test] + public async Task LineCommentedOutCallIsSkipped() + { + var source = string.Join( + "\n", + "module Tests", + "", + "// Verifier.Verify(x).Snapshot(\"doc example\")", + "let MyTest () =", + " Verifier.Verify(x).Snapshot().ToTask()"); + + var status = TryApply(source, 3, InlinePatchMode.Set, null, "new", out var newSource, out _); + + await Assert.That(status).IsEqualTo(PatchStatus.Applied); + await Assert.That(newSource).Contains("// Verifier.Verify(x).Snapshot(\"doc example\")\n"); + await Assert.That(newSource).Contains(" Verifier.Verify(x).Snapshot(\"new\").ToTask()"); + } + + [Test] + public async Task BlockCommentedOutCallIsSkipped() + { + var source = string.Join( + "\n", + "module Tests", + "", + "(* Verifier.Verify(x).Snapshot(\"doc example\") *)", + "let MyTest () =", + " Verifier.Verify(x).Snapshot().ToTask()"); + + var status = TryApply(source, 3, InlinePatchMode.Set, null, "new", out var newSource, out _); + + await Assert.That(status).IsEqualTo(PatchStatus.Applied); + await Assert.That(newSource).Contains("(* Verifier.Verify(x).Snapshot(\"doc example\") *)\n"); + await Assert.That(newSource).Contains(" Verifier.Verify(x).Snapshot(\"new\").ToTask()"); + } + + // Block comments nest, so the inner close does not end the outer comment + [Test] + public async Task NestedBlockCommentIsOneComment() + { + var source = string.Join( + "\n", + "module Tests", + "", + "(* outer (* inner *) .Snapshot(\"commented\") *)", + "let MyTest () =", + " Verifier.Verify(x).Snapshot().ToTask()"); + + var status = TryApply(source, 3, InlinePatchMode.Set, null, "new", out var newSource, out _); + + await Assert.That(status).IsEqualTo(PatchStatus.Applied); + await Assert.That(newSource).Contains(".Snapshot(\"commented\") *)\n"); + await Assert.That(newSource).Contains(" Verifier.Verify(x).Snapshot(\"new\").ToTask()"); + } + + // (*) is the multiplication operator, not an empty comment that would swallow the file + [Test] + public async Task MultiplyOperatorIsNotAComment() + { + var source = string.Join( + "\n", + "module Tests", + "", + "let multiply = (*)", + "let MyTest () =", + " Verifier.Verify(x).Snapshot().ToTask()"); + + var status = TryApply(source, 5, InlinePatchMode.Set, null, "new", out var newSource, out _); + + await Assert.That(status).IsEqualTo(PatchStatus.Applied); + await Assert.That(newSource).Contains("Snapshot(\"new\")"); + } + + [Test] + public async Task CallInsideAStringIsSkipped() + { + var source = Test( + " let text = \"Verifier.Verify(x).Snapshot(\\\"y\\\")\"\n" + + " Verifier.Verify(text).Snapshot().ToTask()"); + + var status = TryApply(source, 5, InlinePatchMode.Set, null, "new", out var newSource, out _); + + await Assert.That(status).IsEqualTo(PatchStatus.Applied); + await Assert.That(newSource).Contains("let text = \"Verifier.Verify(x).Snapshot(\\\"y\\\")\"\n"); + await Assert.That(newSource).Contains("Verifier.Verify(text).Snapshot(\"new\").ToTask()"); + } + + [Test] + public async Task CallInsideATripleQuotedStringIsSkipped() + { + var source = Test( + " let text = \"\"\"Verifier.Verify(x).Snapshot(\"y\")\"\"\"\n" + + " Verifier.Verify(text).Snapshot().ToTask()"); + + var status = TryApply(source, 5, InlinePatchMode.Set, null, "new", out var newSource, out _); + + await Assert.That(status).IsEqualTo(PatchStatus.Applied); + await Assert.That(newSource).Contains("let text = \"\"\"Verifier.Verify(x).Snapshot(\"y\")\"\"\"\n"); + await Assert.That(newSource).Contains("Verifier.Verify(text).Snapshot(\"new\").ToTask()"); + } + + // A type parameter is a tick with no closing tick, so reading one as a char literal would + // take the rest of the file out of the scan + [Test] + public async Task TypeParameterIsNotACharLiteral() + { + var source = Test( + " let values : 'T list = []\n" + + " Verifier.Verify(values).Snapshot(\"old\").ToTask()"); + + var status = TryApply(source, 6, InlinePatchMode.Set, "\"old\"", "new", out var newSource, out _); + + await Assert.That(status).IsEqualTo(PatchStatus.Applied); + await Assert.That(newSource).Contains("Snapshot(\"new\")"); + } + + [Test] + public async Task TickInAnIdentifierIsNotACharLiteral() + { + var source = Test( + " let value' = 15\n" + + " Verifier.Verify(value').Snapshot(\"old\").ToTask()"); + + var status = TryApply(source, 6, InlinePatchMode.Set, "\"old\"", "new", out var newSource, out _); + + await Assert.That(status).IsEqualTo(PatchStatus.Applied); + await Assert.That(newSource).Contains("Verifier.Verify(value').Snapshot(\"new\")"); + } + + // The quote inside the char literal must not open a string + [Test] + public async Task CharLiteralIsSkipped() + { + var source = Test( + " let quote = '\"'\n" + + " Verifier.Verify(quote).Snapshot(\"old\").ToTask()"); + + var status = TryApply(source, 6, InlinePatchMode.Set, "\"old\"", "new", out var newSource, out _); + + await Assert.That(status).IsEqualTo(PatchStatus.Applied); + await Assert.That(newSource).Contains("let quote = '\"'\n"); + await Assert.That(newSource).Contains("Snapshot(\"new\")"); + } + + [Test] + public async Task LetDeclarationIsNotMistakenForACall() + { + var source = string.Join( + "\n", + "module Tests", + "", + "let Snapshot (expected: string) = expected"); + + var status = TryApply(source, 3, InlinePatchMode.Set, null, "new", out _, out var reason); + + await Assert.That(status).IsEqualTo(PatchStatus.NotFound); + await Assert.That(reason).Contains("Could not find a Snapshot call"); + } + + [Test] + public async Task MemberDeclarationIsNotMistakenForACall() + { + var source = string.Join( + "\n", + "module Tests", + "", + "type Extensions =", + " member this.Snapshot (expected: string) = expected", + " static member Snapshot (expected: string, other: string) = expected"); + + var status = TryApply(source, 4, InlinePatchMode.Set, null, "new", out _, out var reason); + + await Assert.That(status).IsEqualTo(PatchStatus.NotFound); + await Assert.That(reason).Contains("Could not find a Snapshot call"); + } + + [Test] + public async Task GenericSnapshotCall() + { + var source = Test(" Verifier.Verify(x).Snapshot().ToTask()"); + + var status = TryApply(source, 5, InlinePatchMode.Set, null, "new", out var newSource, out _); + + await Assert.That(status).IsEqualTo(PatchStatus.Applied); + await Assert.That(newSource).Contains(".Snapshot(\"new\")"); + } + + // The B is part of the literal token, so the expression search must not match through it + [Test] + public async Task ByteStringIsNotPatchedThroughItsQuote() + { + var source = Test(" Verifier.Verify(x).Snapshot(\"old\"B).ToTask()"); + + var status = TryApply(source, 5, InlinePatchMode.Set, "\"old\"", "new", out _, out var reason); + + await Assert.That(status).IsEqualTo(PatchStatus.NotFound); + await Assert.That(reason).Contains("not a string literal"); + } + + [Test] + public async Task NoCallFound() + { + var source = Test(" Verifier.Verify(15).ToTask()"); + + var status = TryApply(source, 5, InlinePatchMode.Set, null, "new", out _, out var reason); + + await Assert.That(status).IsEqualTo(PatchStatus.NotFound); + await Assert.That(reason).Contains("Could not find a Snapshot call"); + } + + // Two tests in the same file producing the same result: both sites must end up patched, and + // the second apply must not mistake the first for its own + [Test] + public async Task SequentialPatchesOfIdenticalLiterals() + { + var source = string.Join( + "\n", + "module Tests", + "", + "let TestA () =", + " Verifier.Verify(a).Snapshot(\"old\").ToTask()", + "", + "let TestB () =", + " Verifier.Verify(b).Snapshot(\"old\").ToTask()"); + + var first = TryApply(source, 4, InlinePatchMode.Set, "\"old\"", "newA", out var afterFirst, out _); + var second = TryApply(afterFirst, 7, InlinePatchMode.Set, "\"old\"", "newB", out var afterSecond, out _); + + await Assert.That(first).IsEqualTo(PatchStatus.Applied); + await Assert.That(second).IsEqualTo(PatchStatus.Applied); + await Assert.That(afterSecond).Contains("Verify(a).Snapshot(\"newA\")"); + await Assert.That(afterSecond).Contains("Verify(b).Snapshot(\"newB\")"); + } + + [Test] + public async Task TabIndentedFileUsesTabUnit() + { + var source = string.Join( + "\n", + "module Tests", + "", + "let MyTest () =", + "\tVerifier.Verify(15).ToTask()"); + + var status = TryApply(source, 4, InlinePatchMode.Append, null, "new", out var newSource, out _); + + await Assert.That(status).IsEqualTo(PatchStatus.Applied); + await Assert.That(newSource).Contains("\tVerifier.Verify(15)\n\t\t.Snapshot(\"new\").ToTask()"); + } +} diff --git a/src/DiffEngine.Tests/InlinePatcherTests.cs b/src/DiffEngine.Tests/InlinePatcherTests.cs index 8af4f9cc..b1d7881c 100644 --- a/src/DiffEngine.Tests/InlinePatcherTests.cs +++ b/src/DiffEngine.Tests/InlinePatcherTests.cs @@ -9,7 +9,7 @@ static string Method(string body) => public async Task ReplaceRawLiteral() { var source = Method($" await Snapshot({rawOld.Replace("\n", "\n ")});"); - var status = InlinePatcher.TryApply(source, 5, InlinePatchMode.Set, rawOld.Replace("\n", "\n "), "new", out var newSource, out _); + var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Set, rawOld.Replace("\n", "\n "), "new", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).Contains("new"); await Assert.That(newSource).DoesNotContain("old"); @@ -23,7 +23,7 @@ public async Task ReplaceRawLiteral() public async Task ReplaceRegularLiteral() { var source = Method(" await Snapshot(\"old\");"); - var status = InlinePatcher.TryApply(source, 5, InlinePatchMode.Set, "\"old\"", "new", out var newSource, out _); + var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Set, "\"old\"", "new", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).Contains(" await Snapshot(\"new\");"); } @@ -32,7 +32,7 @@ public async Task ReplaceRegularLiteral() public async Task ReplacementUsesFileEol() { var source = Method(" await Snapshot(\"old\");").Replace("\n", "\r\n"); - var status = InlinePatcher.TryApply(source, 5, InlinePatchMode.Set, "\"old\"", "a\nb", out var newSource, out _); + var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Set, "\"old\"", "a\nb", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).DoesNotContain("a\nb"); await Assert.That(newSource).Contains("a\r\n b"); @@ -42,7 +42,7 @@ public async Task ReplacementUsesFileEol() public async Task AlreadyAppliedWhenLiteralMatches() { var source = Method(" await Snapshot(\"same\");"); - var status = InlinePatcher.TryApply(source, 5, InlinePatchMode.Set, "\"same\"", "same", out _, out _); + var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Set, "\"same\"", "same", out _, out _); await Assert.That(status).IsEqualTo(PatchStatus.AlreadyApplied); } @@ -51,7 +51,7 @@ public async Task ShiftedLinesStillFound() { var padding = string.Concat(Enumerable.Repeat(" // padding\n", 30)); var source = Method(padding + " await Snapshot(\"old\");"); - var status = InlinePatcher.TryApply(source, 5, InlinePatchMode.Set, "\"old\"", "new", out var newSource, out _); + var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Set, "\"old\"", "new", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).DoesNotContain("\"old\""); } @@ -63,7 +63,7 @@ public async Task DuplicateLiteralsPicksNearestToHint() "await A().Snapshot(\"dup\");\n" + string.Concat(Enumerable.Repeat("// filler\n", 10)) + "await B().Snapshot(\"dup\");\n"; - var status = InlinePatcher.TryApply(source, 12, InlinePatchMode.Set, "\"dup\"", "new", out var newSource, out _); + var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 12, InlinePatchMode.Set, "\"dup\"", "new", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); // First occurrence untouched, second replaced await Assert.That(newSource).Contains("A().Snapshot(\"dup\")"); @@ -95,7 +95,7 @@ public async Task DuplicateLiteralsPicksNearestToHintFirst() { var source = TwoCallSites("\"dup\"", "\"dup\""); - var status = InlinePatcher.TryApply(source, 4, InlinePatchMode.Set, "\"dup\"", "new", out var newSource, out _); + var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 4, InlinePatchMode.Set, "\"dup\"", "new", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); var (a, b) = Segments(newSource); @@ -120,7 +120,7 @@ public async Task EquidistantDuplicatesPreferAtOrAfterHint() "}"); // Line 5 is equidistant from the sites on lines 4 and 6 - var status = InlinePatcher.TryApply(source, 5, InlinePatchMode.Set, "\"dup\"", "new", out var newSource, out _); + var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Set, "\"dup\"", "new", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); var (a, b) = Segments(newSource); @@ -135,8 +135,8 @@ public async Task SequentialPatchesOfIdenticalLiteralsSameContent() { var source = TwoCallSites("\"old\"", "\"old\""); - var first = InlinePatcher.TryApply(source, 4, InlinePatchMode.Set, "\"old\"", "new", out var afterFirst, out _); - var second = InlinePatcher.TryApply(afterFirst, 7, InlinePatchMode.Set, "\"old\"", "new", out var afterSecond, out var reason); + var first = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 4, InlinePatchMode.Set, "\"old\"", "new", out var afterFirst, out _); + var second = InlinePatcher.TryApply(SourceLanguage.CSharp, afterFirst, 7, InlinePatchMode.Set, "\"old\"", "new", out var afterSecond, out var reason); await Assert.That(first).IsEqualTo(PatchStatus.Applied); await Assert.That(second).IsEqualTo(PatchStatus.Applied); @@ -152,8 +152,8 @@ public async Task SequentialPatchesOfIdenticalLiteralsDifferentContent() { var source = TwoCallSites("\"old\"", "\"old\""); - var first = InlinePatcher.TryApply(source, 4, InlinePatchMode.Set, "\"old\"", "newA", out var afterFirst, out _); - var second = InlinePatcher.TryApply(afterFirst, 7, InlinePatchMode.Set, "\"old\"", "newB", out var afterSecond, out _); + var first = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 4, InlinePatchMode.Set, "\"old\"", "newA", out var afterFirst, out _); + var second = InlinePatcher.TryApply(SourceLanguage.CSharp, afterFirst, 7, InlinePatchMode.Set, "\"old\"", "newB", out var afterSecond, out _); await Assert.That(first).IsEqualTo(PatchStatus.Applied); await Assert.That(second).IsEqualTo(PatchStatus.Applied); @@ -172,9 +172,9 @@ public async Task SecondPatchSurvivesLineShiftFromTheFirst() { var source = TwoCallSites("\"old\"", "\"old\""); - InlinePatcher.TryApply(source, 4, InlinePatchMode.Set, "\"old\"", "line1\nline2\nline3", out var afterFirst, out _); + InlinePatcher.TryApply(SourceLanguage.CSharp, source, 4, InlinePatchMode.Set, "\"old\"", "line1\nline2\nline3", out var afterFirst, out _); var lineShift = afterFirst.Split('\n').Length - source.Split('\n').Length; - var second = InlinePatcher.TryApply(afterFirst, 7, InlinePatchMode.Set, "\"old\"", "newB", out var afterSecond, out _); + var second = InlinePatcher.TryApply(SourceLanguage.CSharp, afterFirst, 7, InlinePatchMode.Set, "\"old\"", "newB", out var afterSecond, out _); await Assert.That(lineShift).IsGreaterThan(0); await Assert.That(second).IsEqualTo(PatchStatus.Applied); @@ -191,7 +191,7 @@ public async Task ReapplyingWithIdenticalLiteralsIsAlreadyApplied() { var source = TwoCallSites("\"new\"", "\"old\""); - var status = InlinePatcher.TryApply(source, 4, InlinePatchMode.Set, "\"gone\"", "new", out _, out _); + var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 4, InlinePatchMode.Set, "\"gone\"", "new", out _, out _); await Assert.That(status).IsEqualTo(PatchStatus.AlreadyApplied); } @@ -202,7 +202,7 @@ public async Task ExpressionGoneAndLiteralMatchesIsAlreadyApplied() // The other TFM already applied: the old expression is gone, // and the current argument renders to the new content. var source = Method(" await Snapshot(\"new\");"); - var status = InlinePatcher.TryApply(source, 5, InlinePatchMode.Set, "\"old-gone\"", "new", out _, out _); + var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Set, "\"old-gone\"", "new", out _, out _); await Assert.That(status).IsEqualTo(PatchStatus.AlreadyApplied); } @@ -210,7 +210,7 @@ public async Task ExpressionGoneAndLiteralMatchesIsAlreadyApplied() public async Task ExpressionGoneAndLiteralDiffersIsNotFound() { var source = Method(" await Snapshot(\"different\");"); - var status = InlinePatcher.TryApply(source, 5, InlinePatchMode.Set, "\"old-gone\"", "new", out _, out var reason); + var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Set, "\"old-gone\"", "new", out _, out var reason); await Assert.That(status).IsEqualTo(PatchStatus.NotFound); await Assert.That(reason).Contains("Re-run the test"); } @@ -219,7 +219,7 @@ public async Task ExpressionGoneAndLiteralDiffersIsNotFound() public async Task InsertIntoEmptyArgumentList() { var source = Method(" await Snapshot();"); - var status = InlinePatcher.TryApply(source, 5, InlinePatchMode.Set, null, "new", out var newSource, out _); + var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Set, null, "new", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).Contains("await Snapshot(\"new\");"); } @@ -228,7 +228,7 @@ public async Task InsertIntoEmptyArgumentList() public async Task InsertReplacesNullArgument() { var source = Method(" await Snapshot(null, file, line);"); - var status = InlinePatcher.TryApply(source, 5, InlinePatchMode.Set, null, "new", out var newSource, out _); + var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Set, null, "new", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).Contains("await Snapshot(\"new\", file, line);"); } @@ -237,7 +237,7 @@ public async Task InsertReplacesNullArgument() public async Task InsertBeforeAnotherNamedArgument() { var source = Method(" await Snapshot(file: myFile);"); - var status = InlinePatcher.TryApply(source, 5, InlinePatchMode.Set, null, "new", out var newSource, out _); + var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Set, null, "new", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).Contains("await Snapshot(expected: \"new\", file: myFile);"); } @@ -246,7 +246,7 @@ public async Task InsertBeforeAnotherNamedArgument() public async Task NullOriginalWithDifferingLiteralIsNotFound() { var source = Method(" await Snapshot(\"different\");"); - var status = InlinePatcher.TryApply(source, 5, InlinePatchMode.Set, null, "new", out _, out var reason); + var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Set, null, "new", out _, out var reason); await Assert.That(status).IsEqualTo(PatchStatus.NotFound); await Assert.That(reason).Contains("different expected argument"); } @@ -255,7 +255,7 @@ public async Task NullOriginalWithDifferingLiteralIsNotFound() public async Task NullOriginalWithEqualLiteralIsAlreadyApplied() { var source = Method(" await Snapshot(\"new\");"); - var status = InlinePatcher.TryApply(source, 5, InlinePatchMode.Set, null, "new", out _, out _); + var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Set, null, "new", out _, out _); await Assert.That(status).IsEqualTo(PatchStatus.AlreadyApplied); } @@ -263,7 +263,7 @@ public async Task NullOriginalWithEqualLiteralIsAlreadyApplied() public async Task NoCallFound() { var source = Method(" await Verify(value);"); - var status = InlinePatcher.TryApply(source, 5, InlinePatchMode.Set, null, "new", out _, out var reason); + var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Set, null, "new", out _, out var reason); await Assert.That(status).IsEqualTo(PatchStatus.NotFound); await Assert.That(reason).Contains("Could not find a Snapshot call"); } @@ -272,7 +272,7 @@ public async Task NoCallFound() public async Task PartialTokenIsNotMatched() { var source = Method(" await MySnapshotHelper(value);"); - var status = InlinePatcher.TryApply(source, 5, InlinePatchMode.Set, null, "new", out _, out _); + var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Set, null, "new", out _, out _); await Assert.That(status).IsEqualTo(PatchStatus.NotFound); } @@ -281,7 +281,7 @@ public async Task AppendToABareVerify() { var source = Method(" await Verify(value);"); - var status = InlinePatcher.TryApply(source, 5, InlinePatchMode.Append, null, "new", out var newSource, out _); + var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Append, null, "new", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).Contains( @@ -295,7 +295,7 @@ public async Task AppendMultiLineContent() { var source = Method(" await Verify(value);"); - var status = InlinePatcher.TryApply(source, 5, InlinePatchMode.Append, null, "a\nb", out var newSource, out _); + var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Append, null, "a\nb", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).Contains( @@ -316,7 +316,7 @@ public async Task AppendGoesAfterAnExistingChain() " .UseDirectory(\"snapshots\")\n" + " .ScrubLinesContaining(\"x\");"); - var status = InlinePatcher.TryApply(source, 5, InlinePatchMode.Append, null, "new", out var newSource, out _); + var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Append, null, "new", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).Contains( @@ -329,7 +329,7 @@ public async Task AppendToAnEntryPointOverload() { var source = Method(" await VerifyXml(value);"); - var status = InlinePatcher.TryApply(source, 5, InlinePatchMode.Append, null, "new", out var newSource, out _); + var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Append, null, "new", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).Contains("await VerifyXml(value)\n .Snapshot(\"new\");"); @@ -346,7 +346,7 @@ public async Task AppendToAMultiLineVerifyCall() " value\n" + " });"); - var status = InlinePatcher.TryApply(source, 5, InlinePatchMode.Append, null, "new", out var newSource, out _); + var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Append, null, "new", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).Contains(" })\n .Snapshot(\"new\");"); @@ -357,7 +357,7 @@ public async Task AppendUsesTheFileEol() { var source = Method(" await Verify(value);").Replace("\n", "\r\n"); - var status = InlinePatcher.TryApply(source, 5, InlinePatchMode.Append, null, "a\nb", out var newSource, out _); + var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Append, null, "a\nb", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await AssertEolConsistent(newSource, crlf); @@ -368,7 +368,7 @@ public async Task AppendIsRefusedWhenOneIsAlreadyChained() { var source = Method(" await Verify(value)\n .Snapshot(\"already\");"); - var status = InlinePatcher.TryApply(source, 5, InlinePatchMode.Append, null, "new", out _, out var reason); + var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Append, null, "new", out _, out var reason); await Assert.That(status).IsEqualTo(PatchStatus.NotFound); await Assert.That(reason).Contains("already has a Snapshot call"); @@ -379,7 +379,7 @@ public async Task AppendWithNoVerifyCall() { var source = Method(" await Something(value);"); - var status = InlinePatcher.TryApply(source, 5, InlinePatchMode.Append, null, "new", out _, out var reason); + var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Append, null, "new", out _, out var reason); await Assert.That(status).IsEqualTo(PatchStatus.NotFound); await Assert.That(reason).Contains("Could not find a Verify call"); @@ -391,7 +391,7 @@ public async Task AppendSkipsAVerifyOnAnotherReceiver() { var source = Method(" Assert.Empty(ContentValidation.Verify(value));"); - var status = InlinePatcher.TryApply(source, 5, InlinePatchMode.Append, null, "new", out _, out var reason); + var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Append, null, "new", out _, out var reason); await Assert.That(status).IsEqualTo(PatchStatus.NotFound); await Assert.That(reason).Contains("Could not find a Verify call"); @@ -402,7 +402,7 @@ public async Task AppendSkipsAVerifyOnAnInstance() { var source = Method(" mock.VerifyAll();"); - var status = InlinePatcher.TryApply(source, 5, InlinePatchMode.Append, null, "new", out _, out var reason); + var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Append, null, "new", out _, out var reason); await Assert.That(status).IsEqualTo(PatchStatus.NotFound); await Assert.That(reason).Contains("Could not find a Verify call"); @@ -414,7 +414,7 @@ public async Task AppendPrefersTheEntryPointOverANestedHelper() { var source = Method(" await Verify(ContentValidation.Verify(value));"); - var status = InlinePatcher.TryApply(source, 5, InlinePatchMode.Append, null, "new", out var newSource, out _); + var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Append, null, "new", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).Contains( @@ -427,7 +427,7 @@ public async Task AppendToAVerifierQualifiedCall() { var source = Method(" await Verifier.Verify(value);"); - var status = InlinePatcher.TryApply(source, 5, InlinePatchMode.Append, null, "new", out var newSource, out _); + var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Append, null, "new", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).Contains("await Verifier.Verify(value)\n .Snapshot(\"new\");"); @@ -438,7 +438,7 @@ public async Task AppendToAThisQualifiedCall() { var source = Method(" await this.Verify(value);"); - var status = InlinePatcher.TryApply(source, 5, InlinePatchMode.Append, null, "new", out var newSource, out _); + var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Append, null, "new", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).Contains("await this.Verify(value)\n .Snapshot(\"new\");"); @@ -453,7 +453,7 @@ public async Task RemoveTakesTheWholeLine() " old\n" + " \"\"\");"); - var status = InlinePatcher.TryApply(source, 6, InlinePatchMode.Remove, null, "", out var newSource, out _); + var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 6, InlinePatchMode.Remove, null, "", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).IsEqualTo(Method(" await Verify(value);")); @@ -467,7 +467,7 @@ public async Task RemoveLeavesTheRestOfTheChain() " .UseDirectory(\"snapshots\")\n" + " .Snapshot(\"old\");"); - var status = InlinePatcher.TryApply(source, 7, InlinePatchMode.Remove, null, "", out var newSource, out _); + var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 7, InlinePatchMode.Remove, null, "", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).IsEqualTo( @@ -481,7 +481,7 @@ public async Task RemoveFromASingleLineChain() { var source = Method(" await Verify(value).Snapshot(\"old\");"); - var status = InlinePatcher.TryApply(source, 5, InlinePatchMode.Remove, null, "", out var newSource, out _); + var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Remove, null, "", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).IsEqualTo(Method(" await Verify(value);")); @@ -492,7 +492,7 @@ public async Task RemoveWithCrlf() { var source = Method(" await Verify(value)\n .Snapshot(\"old\");").Replace("\n", "\r\n"); - var status = InlinePatcher.TryApply(source, 6, InlinePatchMode.Remove, null, "", out var newSource, out _); + var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 6, InlinePatchMode.Remove, null, "", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).IsEqualTo(Method(" await Verify(value);").Replace("\n", "\r\n")); @@ -503,7 +503,7 @@ public async Task RemovePicksTheSiteNearestTheHint() { var source = TwoCallSites("\"a\"", "\"b\""); - var status = InlinePatcher.TryApply(source, 7, InlinePatchMode.Remove, null, "", out var newSource, out _); + var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 7, InlinePatchMode.Remove, null, "", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); var (a, b) = Segments(newSource); @@ -516,7 +516,7 @@ public async Task RemoveWhenTheCallIsNotChained() { var source = Method(" await Snapshot(\"old\");"); - var status = InlinePatcher.TryApply(source, 5, InlinePatchMode.Remove, null, "", out _, out var reason); + var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Remove, null, "", out _, out var reason); await Assert.That(status).IsEqualTo(PatchStatus.NotFound); await Assert.That(reason).Contains("not a chained call"); @@ -527,7 +527,7 @@ public async Task RemoveWithNoSnapshotCall() { var source = Method(" await Verify(value);"); - var status = InlinePatcher.TryApply(source, 5, InlinePatchMode.Remove, null, "", out _, out var reason); + var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Remove, null, "", out _, out var reason); await Assert.That(status).IsEqualTo(PatchStatus.NotFound); await Assert.That(reason).Contains("Could not find a Snapshot call"); @@ -537,7 +537,7 @@ public async Task RemoveWithNoSnapshotCall() public async Task TabIndentedFileUsesTabUnit() { var source = "class Tests\n{\n\tasync Task Test()\n\t{\n\t\tawait Snapshot();\n\t}\n}"; - var status = InlinePatcher.TryApply(source, 5, InlinePatchMode.Set, null, "a\nb", out var newSource, out _); + var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Set, null, "a\nb", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).Contains("Snapshot(\n\t\t\t\"\"\"\n\t\t\ta\n\t\t\tb\n\t\t\t\"\"\");"); } @@ -551,7 +551,7 @@ public async Task AppendToATabIndentedFile() { var source = TabMethod("\t\tawait Verify(value);"); - var status = InlinePatcher.TryApply(source, 5, InlinePatchMode.Append, null, "a\nb", out var newSource, out _); + var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Append, null, "a\nb", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).IsEqualTo( @@ -572,7 +572,7 @@ public async Task AppendToATabIndentedChain() "\t\tawait Verify(value)\n" + "\t\t\t.UseDirectory(\"snapshots\");"); - var status = InlinePatcher.TryApply(source, 5, InlinePatchMode.Append, null, "a\nb", out var newSource, out _); + var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Append, null, "a\nb", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).IsEqualTo( @@ -595,7 +595,7 @@ public async Task RemoveFromATabIndentedChain() "\t\t\t\told\n" + "\t\t\t\t\"\"\");"); - var status = InlinePatcher.TryApply(source, 6, InlinePatchMode.Remove, null, "", out var newSource, out _); + var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 6, InlinePatchMode.Remove, null, "", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).IsEqualTo(TabMethod("\t\tawait Verify(value);")); @@ -624,9 +624,9 @@ public async Task MixedIndentFileUsesTheSiteIndent() { var source = MixedIndentSites(); - var spaces = InlinePatcher.TryApply(source, 5, InlinePatchMode.Set, "\"a\"", "a1\na2", out var afterSpaces, out _); + var spaces = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Set, "\"a\"", "a1\na2", out var afterSpaces, out _); // The first patch turned one line into five, so the second site has moved down four - var tabs = InlinePatcher.TryApply(afterSpaces, 14, InlinePatchMode.Set, "\"b\"", "b1\nb2", out var afterTabs, out _); + var tabs = InlinePatcher.TryApply(SourceLanguage.CSharp, afterSpaces, 14, InlinePatchMode.Set, "\"b\"", "b1\nb2", out var afterTabs, out _); await Assert.That(spaces).IsEqualTo(PatchStatus.Applied); await Assert.That(tabs).IsEqualTo(PatchStatus.Applied); @@ -664,7 +664,7 @@ public async Task LineIndentedWithTabsThenSpaces() { var source = "class Tests\n{\n\tasync Task Test() =>\n\t Verify(value).Snapshot(\"old\");\n}"; - var status = InlinePatcher.TryApply(source, 4, InlinePatchMode.Set, "\"old\"", "a\nb", out var newSource, out _); + var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 4, InlinePatchMode.Set, "\"old\"", "a\nb", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).Contains( @@ -684,7 +684,7 @@ public async Task TwoSpaceFileUsesATwoSpaceUnit() { var source = TwoSpaceMethod(" await Snapshot(\"old\");"); - var status = InlinePatcher.TryApply(source, 5, InlinePatchMode.Set, "\"old\"", "a\nb", out var newSource, out _); + var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Set, "\"old\"", "a\nb", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).IsEqualTo( @@ -701,7 +701,7 @@ public async Task AppendToATwoSpaceFile() { var source = TwoSpaceMethod(" await Verify(value);"); - var status = InlinePatcher.TryApply(source, 5, InlinePatchMode.Append, null, "a\nb", out var newSource, out _); + var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Append, null, "a\nb", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).IsEqualTo( @@ -733,7 +733,7 @@ public async Task LiteralContentDoesNotSetTheUnit() $" Task T() => Snapshot({expression});", "}"); - var status = InlinePatcher.TryApply(source, 3, InlinePatchMode.Set, expression, "x\ny", out var newSource, out _); + var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 3, InlinePatchMode.Set, expression, "x\ny", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).IsEqualTo( @@ -755,7 +755,7 @@ public async Task FileWithNoIndentationFallsBackToFourSpaces() { var source = "class Tests\n{\nasync Task Test() =>\nSnapshot(\"old\");\n}"; - var status = InlinePatcher.TryApply(source, 4, InlinePatchMode.Set, "\"old\"", "a\nb", out var newSource, out _); + var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 4, InlinePatchMode.Set, "\"old\"", "a\nb", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).Contains( @@ -781,7 +781,7 @@ public async Task LiteralWithMismatchedIndentCharactersIsNotPatched() " \"\"\");", "}"); - var status = InlinePatcher.TryApply(source, 4, InlinePatchMode.Set, null, "new", out _, out var reason); + var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 4, InlinePatchMode.Set, null, "new", out _, out var reason); await Assert.That(status).IsEqualTo(PatchStatus.NotFound); await Assert.That(reason).Contains("is not a string literal"); @@ -791,7 +791,7 @@ public async Task LiteralWithMismatchedIndentCharactersIsNotPatched() public async Task HintBeyondEndOfFile() { var source = "await Snapshot();"; - var status = InlinePatcher.TryApply(source, 500, InlinePatchMode.Set, null, "new", out var newSource, out _); + var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 500, InlinePatchMode.Set, null, "new", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).Contains("Snapshot(\"new\");"); } @@ -801,7 +801,7 @@ public async Task LfExpressionFoundInCrlfFile() { var source = Method($" await Snapshot({rawOld.Replace("\n", "\n ")});").Replace("\n", "\r\n"); var expression = rawOld.Replace("\n", "\n "); - var status = InlinePatcher.TryApply(source, 5, InlinePatchMode.Set, expression, "new", out var newSource, out _); + var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Set, expression, "new", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).DoesNotContain("old"); } @@ -811,7 +811,7 @@ public async Task OutsideSpanIsCharacterIdentical() { var body = " await Snapshot(\"old\");"; var source = Method(body); - InlinePatcher.TryApply(source, 5, InlinePatchMode.Set, "\"old\"", "new", out var newSource, out _); + InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Set, "\"old\"", "new", out var newSource, out _); var prefix = source.Substring(0, source.IndexOf("\"old\"", StringComparison.Ordinal)); var suffix = source.Substring(source.IndexOf("\"old\"", StringComparison.Ordinal) + 5); await Assert.That(newSource.StartsWith(prefix)).IsTrue(); @@ -879,7 +879,7 @@ public async Task EolCombinations(string fileEol, string expressionEol, string c var expression = BuildExpression(expressionEol); var content = "new1" + contentEol + "new2"; - var status = InlinePatcher.TryApply(source, 5, InlinePatchMode.Set, expression, content, out var newSource, out var reason); + var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Set, expression, content, out var newSource, out var reason); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(reason).IsEmpty(); @@ -908,7 +908,7 @@ public async Task EolCombinationsForInsert(string fileEol, string contentEol) "}"); var content = "new1" + contentEol + "new2"; - var status = InlinePatcher.TryApply(source, 5, InlinePatchMode.Set, null, content, out var newSource, out _); + var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Set, null, content, out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).Contains("new1"); @@ -922,7 +922,7 @@ public async Task LoneCarriageReturnInContentIsNormalized() var source = BuildMultiLineSource(lf); var expression = BuildExpression(lf); - var status = InlinePatcher.TryApply(source, 5, InlinePatchMode.Set, expression, "new1\rnew2", out var newSource, out _); + var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Set, expression, "new1\rnew2", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).DoesNotContain("\r"); @@ -947,7 +947,7 @@ public async Task MixedEolFileLeavesUntouchedRegionsAlone() var suffix = "\r\n// trailing\n// mixed tail\n"; var source = prefix + body + suffix; - var status = InlinePatcher.TryApply(source, 7, InlinePatchMode.Set, "\"old\"", "new1\nnew2", out var newSource, out _); + var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 7, InlinePatchMode.Set, "\"old\"", "new1\nnew2", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); // Untouched regions keep their original endings byte for byte @@ -960,7 +960,7 @@ public async Task MixedEolFileLeavesUntouchedRegionsAlone() [Test] public async Task SingleLineFileWithNoNewlines() { - var status = InlinePatcher.TryApply("await Snapshot(\"old\");", 1, InlinePatchMode.Set, "\"old\"", "new", out var newSource, out _); + var status = InlinePatcher.TryApply(SourceLanguage.CSharp, "await Snapshot(\"old\");", 1, InlinePatchMode.Set, "\"old\"", "new", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).Contains("new"); @@ -973,7 +973,7 @@ public async Task SameLiteralInTheVerifyArgumentIsNotPatched() { var source = Method(" await Verify(\"same\").Snapshot(\"same\");"); - var status = InlinePatcher.TryApply(source, 5, InlinePatchMode.Set, "\"same\"", "changed", out var newSource, out _); + var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Set, "\"same\"", "changed", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).Contains("await Verify(\"same\").Snapshot(\"changed\");"); @@ -985,7 +985,7 @@ public async Task PrefixedLiteralIsNotPatchedThroughItsQuote() { var source = Method(" await Verify(x).Snapshot(@\"old\");"); - var status = InlinePatcher.TryApply(source, 5, InlinePatchMode.Set, "\"old\"", "new", out _, out var reason); + var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Set, "\"old\"", "new", out _, out var reason); await Assert.That(status).IsEqualTo(PatchStatus.NotFound); await Assert.That(reason).Contains("Re-run the test"); @@ -996,7 +996,7 @@ public async Task SuffixedLiteralIsNotPatchedThroughItsQuote() { var source = Method(" await Verify(x).Snapshot(\"old\"u8);"); - var status = InlinePatcher.TryApply(source, 5, InlinePatchMode.Set, "\"old\"", "new", out _, out var reason); + var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Set, "\"old\"", "new", out _, out var reason); await Assert.That(status).IsEqualTo(PatchStatus.NotFound); await Assert.That(reason).Contains("not a string literal"); @@ -1014,7 +1014,7 @@ public async Task CommentedOutCallIsSkipped() " await Verify(x).Snapshot();", "}"); - var status = InlinePatcher.TryApply(source, 3, InlinePatchMode.Set, null, "new", out var newSource, out _); + var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 3, InlinePatchMode.Set, null, "new", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).Contains(" // await Verify(x).Snapshot(\"doc example\");\n"); @@ -1028,7 +1028,7 @@ public async Task CallInsideAStringIsSkipped() " var text = \"await Snapshot(\\\"x\\\")\";\n" + " await Verify(x).Snapshot();"); - var status = InlinePatcher.TryApply(source, 5, InlinePatchMode.Set, null, "new", out var newSource, out _); + var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Set, null, "new", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).Contains("var text = \"await Snapshot(\\\"x\\\")\";\n"); @@ -1047,7 +1047,7 @@ public async Task SnapshotDeclarationIsNotMistakenForACall() " task;", "}"); - var status = InlinePatcher.TryApply(source, 3, InlinePatchMode.Set, null, "new", out _, out var reason); + var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 3, InlinePatchMode.Set, null, "new", out _, out var reason); await Assert.That(status).IsEqualTo(PatchStatus.NotFound); await Assert.That(reason).Contains("Could not find a Snapshot call"); @@ -1063,7 +1063,7 @@ public async Task AppendSkipsAVerifyPrefixedDeclaration() " Task VerifyThing(string value) => Verify(value);", "}"); - var status = InlinePatcher.TryApply(source, 3, InlinePatchMode.Append, null, "new", out var newSource, out _); + var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 3, InlinePatchMode.Append, null, "new", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).Contains( @@ -1079,7 +1079,7 @@ public async Task AppendGoesAfterACommentInTheChain() " await Verify(value) // note\n" + " .UseDirectory(\"snapshots\");"); - var status = InlinePatcher.TryApply(source, 5, InlinePatchMode.Append, null, "new", out var newSource, out _); + var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Append, null, "new", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).Contains( @@ -1094,7 +1094,7 @@ public async Task LiteralInACommentIsNotPatched() " // was \"old\"\n" + " await Verify(x).Snapshot(\"old\");"); - var status = InlinePatcher.TryApply(source, 5, InlinePatchMode.Set, "\"old\"", "new", out var newSource, out _); + var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Set, "\"old\"", "new", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).Contains("// was \"old\"\n"); @@ -1114,7 +1114,7 @@ public async Task LiteralInAnotherMethodIsNotPatched() " await Verify(x).Snapshot(\"old\");", "}"); - var status = InlinePatcher.TryApply(source, 3, InlinePatchMode.Set, "\"old\"", "new", out var newSource, out _); + var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 3, InlinePatchMode.Set, "\"old\"", "new", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).Contains("void Helper() => Log(\"old\");"); @@ -1138,7 +1138,7 @@ public async Task EmptyOriginalIsNotMatchedInsideARawDelimiter() " Verify(b).Snapshot(\"\");", "}"); - var status = InlinePatcher.TryApply(source, 7, InlinePatchMode.Set, "\"\"", "new", out var newSource, out _); + var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 7, InlinePatchMode.Set, "\"\"", "new", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).Contains(" content\n \"\"\");"); @@ -1150,7 +1150,7 @@ public async Task GenericSnapshotCall() { var source = Method(" await Verify(x).Snapshot();"); - var status = InlinePatcher.TryApply(source, 5, InlinePatchMode.Set, null, "new", out var newSource, out _); + var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Set, null, "new", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).Contains(".Snapshot(\"new\");"); @@ -1161,7 +1161,7 @@ public async Task AppendToAGenericVerify() { var source = Method(" await Verify(value);"); - var status = InlinePatcher.TryApply(source, 5, InlinePatchMode.Append, null, "new", out var newSource, out _); + var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Append, null, "new", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).Contains("await Verify(value)\n .Snapshot(\"new\");"); @@ -1172,7 +1172,7 @@ public async Task CommentInTheArgumentListIsNotTheArgument() { var source = Method(" await Verify(x).Snapshot(/* keep */);"); - var status = InlinePatcher.TryApply(source, 5, InlinePatchMode.Set, null, "new", out var newSource, out _); + var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Set, null, "new", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).Contains("Snapshot(/* keep */\"new\");"); @@ -1183,7 +1183,7 @@ public async Task CommentAfterTheArgumentIsKept() { var source = Method(" await Verify(x).Snapshot(\"old\" /* why */);"); - var status = InlinePatcher.TryApply(source, 5, InlinePatchMode.Set, "\"old\"", "new", out var newSource, out _); + var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Set, "\"old\"", "new", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).Contains("\"new\" /* why */);"); @@ -1198,7 +1198,7 @@ public async Task RemoveLeavesALineCommentAboveIntact() " // note\n" + " .Snapshot(\"old\");"); - var status = InlinePatcher.TryApply(source, 7, InlinePatchMode.Remove, null, "", out var newSource, out _); + var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 7, InlinePatchMode.Remove, null, "", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).IsEqualTo( diff --git a/src/DiffEngine.Tests/RequiresDotnetAttribute.cs b/src/DiffEngine.Tests/RequiresDotnetAttribute.cs new file mode 100644 index 00000000..f95a37b5 --- /dev/null +++ b/src/DiffEngine.Tests/RequiresDotnetAttribute.cs @@ -0,0 +1,56 @@ +/// +/// Skips a test that shells out to the .NET SDK. The SDK is what built these tests, so it is +/// there in every arrangement that matters; the skip exists for the one where the test assembly +/// was carried somewhere the CLI is not. +/// +public sealed class RequiresDotnetAttribute() : + SkipAttribute("The dotnet CLI was not found, so the F# compiler cannot be run.") +{ + public static string? DotnetPath { get; } = Find(); + + public override Task ShouldSkip(TestRegisteredContext context) => + Task.FromResult(DotnetPath is null); + + static string? Find() + { + var name = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "dotnet.exe" : "dotnet"; + var root = Environment.GetEnvironmentVariable("DOTNET_ROOT"); + if (!string.IsNullOrEmpty(root)) + { + var candidate = Path.Combine(root!, name); + if (File.Exists(candidate)) + { + return candidate; + } + } + + var paths = Environment.GetEnvironmentVariable("PATH"); + if (paths == null) + { + return null; + } + + foreach (var directory in paths.Split(Path.PathSeparator)) + { + if (directory.Length == 0) + { + continue; + } + + try + { + var candidate = Path.Combine(directory, name); + if (File.Exists(candidate)) + { + return candidate; + } + } + catch (ArgumentException) + { + // An invalid directory on PATH is not this test's problem + } + } + + return null; + } +} diff --git a/src/DiffEngine/Inline/CsScan.cs b/src/DiffEngine/Inline/CsLanguage.cs similarity index 62% rename from src/DiffEngine/Inline/CsScan.cs rename to src/DiffEngine/Inline/CsLanguage.cs index 90c7d71a..39699a3a 100644 --- a/src/DiffEngine/Inline/CsScan.cs +++ b/src/DiffEngine/Inline/CsLanguage.cs @@ -1,34 +1,24 @@ /// -/// A one pass lexical map of a C# file: where the comments, strings and char literals are, and -/// therefore which offsets are code. -/// -/// The patcher finds its call sites by scanning text, and a text scan that cannot see a comment -/// or a string patches a commented out example, or the middle of another test's snapshot content, -/// as readily as the real call. Lexing once and asking the map is cheaper than lexing per search, -/// and it is one implementation: every search agrees on what a string is because there is only -/// one answer to ask. -/// +/// C#: the lexing that fills a , and the syntax the patcher has to write. /// -sealed class CsScan +sealed class CsLanguage : SourceLanguage { - readonly string source; - readonly bool[] code; + public override string Render(string content, string indent, string eol) => + CsStringLiteral.Render(content, indent, eol); - /// - /// Start of a comment or literal to the offset just past it. - /// - readonly Dictionary skips = new(); + public override bool TryParse(string expression, [NotNullWhen(true)] out string? value) => + CsStringLiteral.TryParse(expression, out value); - /// - /// The same spans keyed the other way round, for a scan working backwards. Ends are unique - /// because the spans cannot overlap. - /// - readonly Dictionary skipEnds = new(); + internal override string NamePrefix(string name) => $"{name}: "; + + internal override char NameSeparator => ':'; - public CsScan(string source) + internal override bool IsIdentifierChar(char ch) => + char.IsLetterOrDigit(ch) || ch == '_'; + + internal override SourceScan Scan(string source) { - this.source = source; - code = new bool[source.Length]; + var scan = new SourceScan(this, source); var index = 0; while (index < source.Length) { @@ -38,7 +28,7 @@ public CsScan(string source) case '/': if (TrySkipComment(source, ref index)) { - AddSkip(start, index); + scan.AddSkip(start, index, comment: true); continue; } @@ -46,7 +36,7 @@ public CsScan(string source) case '\'': if (TrySkipCharLiteral(source, ref index)) { - AddSkip(start, index); + scan.AddSkip(start, index, comment: false); continue; } @@ -67,81 +57,28 @@ public CsScan(string source) index++; } - AddSkip(start, index); + scan.AddSkip(start, index, comment: false); continue; } break; } - code[index] = true; + scan.MarkCode(index); index++; } - } - void AddSkip(int start, int end) - { - skips.Add(start, end); - skipEnds[end] = start; + return scan; } /// - /// True when the offset is outside every comment, string and char literal. - /// - public bool IsCode(int index) => - index >= 0 && - index < code.Length && - code[index]; - - /// - /// When a comment or literal starts at , is the - /// offset just past it. Lets a structural scan step over trivia without lexing it again. - /// - public bool TryGetSkip(int index, out int end) => - skips.TryGetValue(index, out end); - - /// - /// True when a comment ends at , with set to - /// where it began. Only comments: a literal is content, and trimming one off a span would be - /// trimming off the value. + /// A declaration is preceded by its return type, a call by a dot, an operator, or one of the + /// keywords that can introduce an expression. /// - public bool TryGetCommentEndingAt(int end, out int start) => - skipEnds.TryGetValue(end, out start) && - source[start] == '/'; - - /// - /// Advances past whitespace and comments. - /// - public void SkipTrivia(ref int index) + internal override bool IsDeclaration(SourceScan scan, int nameStart) { - while (index < source.Length) - { - if (char.IsWhiteSpace(source[index])) - { - index++; - continue; - } - - if (source[index] == '/' && - skips.TryGetValue(index, out var end)) - { - index = end; - continue; - } - - return; - } - } - - /// - /// True when the identifier at is being declared rather than - /// called. The two are otherwise identical - name, parens, body - so the tell is the token in - /// front: a declaration is preceded by its return type, a call by a dot, an operator, or one - /// of the keywords that can introduce an expression. - /// - public bool IsDeclaration(int nameStart) - { - var index = PreviousSignificant(nameStart); + var source = scan.Source; + var index = scan.PreviousSignificant(nameStart); if (index < 0) { return false; @@ -160,14 +97,7 @@ public bool IsDeclaration(int nameStart) return false; } - var start = index; - while (start > 0 && - IsIdentifierChar(source[start - 1])) - { - start--; - } - - return !callablePredecessors.Contains(source.Substring(start, index - start + 1)); + return !callablePredecessors.Contains(scan.WordEndingAt(index)); } /// @@ -184,77 +114,6 @@ public bool IsDeclaration(int nameStart) "where", "while", "with", "yield" ]; - /// - /// The offset of the last character before that is neither - /// whitespace nor inside a comment, or -1 when there is none. - /// - public int PreviousSignificant(int index) - { - index--; - while (index >= 0 && - (char.IsWhiteSpace(source[index]) || !code[index])) - { - index--; - } - - return index; - } - - /// - /// Advances past a type argument list, so Foo<Bar>(...) is located as readily as - /// Foo(...). Only the characters a type argument list can hold are accepted, and the caller - /// still has to find a '(' after it, so a comparison cannot be mistaken for one. - /// - public static bool TrySkipTypeArguments(string source, ref int index) - { - var cursor = index; - if (cursor >= source.Length || - source[cursor] != '<') - { - return false; - } - - cursor++; - var depth = 1; - while (cursor < source.Length) - { - var ch = source[cursor]; - if (ch == '<') - { - depth++; - cursor++; - continue; - } - - if (ch == '>') - { - depth--; - cursor++; - if (depth == 0) - { - index = cursor; - return true; - } - - continue; - } - - if (IsIdentifierChar(ch) || - ch is ',' or '.' or '?' or '[' or ']' or ':' or ' ' or '\t') - { - cursor++; - continue; - } - - return false; - } - - return false; - } - - public static bool IsIdentifierChar(char ch) => - char.IsLetterOrDigit(ch) || ch == '_'; - static bool TrySkipComment(string source, ref int index) { if (index + 1 >= source.Length) diff --git a/src/DiffEngine/Inline/CsStringLiteral.cs b/src/DiffEngine/Inline/CsStringLiteral.cs index 53e1682b..f911b73b 100644 --- a/src/DiffEngine/Inline/CsStringLiteral.cs +++ b/src/DiffEngine/Inline/CsStringLiteral.cs @@ -94,7 +94,7 @@ public static string RenderRaw(string content, string indent, string eol) { // Content is meant to arrive \n normalized. Be defensive: a stray \r would // otherwise be emitted into the literal as content, corrupting the snapshot - content = NormalizeNewlines(content); + content = SourceLanguage.NormalizeNewlines(content); } var delimiter = new string('"', Math.Max(3, LongestQuoteRun(content) + 1)); @@ -167,15 +167,10 @@ public static bool TryParse(string expression, [NotNullWhen(true)] out string? v return false; } - value = NormalizeNewlines(value!); + value = SourceLanguage.NormalizeNewlines(value!); return true; } - internal static string NormalizeNewlines(string value) => - value - .Replace("\r\n", "\n") - .Replace('\r', '\n'); - /// /// Scans one string literal starting at (which must point at the /// first character of the literal: '"' or '@'). On success is the @@ -287,7 +282,7 @@ static bool TryScanRaw(string text, int start, int quotes, out string? value, ou // * first line (after the opening quotes) must be whitespace only and is dropped // * the last line holds the closing quotes; its leading whitespace is the indent // stripped from every content line, and the line itself is dropped - var normalized = NormalizeNewlines(content); + var normalized = SourceLanguage.NormalizeNewlines(content); var lines = normalized.Split('\n'); var first = lines[0]; if (first.Trim().Length > 0) diff --git a/src/DiffEngine/Inline/FsLanguage.cs b/src/DiffEngine/Inline/FsLanguage.cs new file mode 100644 index 00000000..9cd6f4f8 --- /dev/null +++ b/src/DiffEngine/Inline/FsLanguage.cs @@ -0,0 +1,507 @@ +/// +/// F#: the lexing that fills a , and the syntax the patcher has to write. +/// +/// Three things differ from C# beyond the obvious. Comments are (* *) and they nest. A tick +/// is a char literal in one place and part of a name (value') or a type parameter +/// ('T) in others, so it cannot simply open a literal. And a name is a declaration only +/// when a keyword says so - F# has no return type in front of a name to tell the two apart, so the +/// C# rule inverts here: assume a call, and let let or member say otherwise. +/// +/// +sealed class FsLanguage : SourceLanguage +{ + public override string Render(string content, string indent, string eol) => + FsStringLiteral.Render(content, indent, eol); + + public override bool TryParse(string expression, [NotNullWhen(true)] out string? value) => + FsStringLiteral.TryParse(expression, out value); + + internal override string NamePrefix(string name) => $"{name} = "; + + internal override char NameSeparator => '='; + + /// + /// The content of a multi-line literal is verbatim, so it starts hard against the opening + /// delimiter and there is nothing to be gained by giving that delimiter a line of its own. + /// Moving it down a line would also move the closing delimiter left, which is the one thing + /// exists to stop. + /// + internal override bool LiteralOnOwnLine => false; + + /// + /// F# does not apply the implicit conversion that lets a SettingsTask be awaited, so an F# + /// test ends the chain with ToTask. Snapshot returns the SettingsTask and ToTask does not, so + /// an appended call goes in front of it rather than after it. + /// + internal override string? ChainTerminator => "ToTask"; + + /// + /// The F# compiler does not implement - it + /// warns FS0202 and leaves the parameter at its default - so an F# patch never carries the + /// expression its C# equivalent is anchored to, and is located by line hint alone. + /// + internal override bool SuppliesArgumentExpressions => false; + + internal override bool IsIdentifierChar(char ch) => + char.IsLetterOrDigit(ch) || ch == '_' || ch == '\''; + + internal override bool IsTypeArgumentChar(char ch) => + base.IsTypeArgumentChar(ch) || + // Tuple types (Foo) and statically resolved type parameters (^T) + ch is '*' or '^'; + + internal override SourceScan Scan(string source) + { + var scan = new SourceScan(this, source); + var index = 0; + while (index < source.Length) + { + var start = index; + switch (source[index]) + { + case '/': + if (TrySkipLineComment(source, ref index)) + { + scan.AddSkip(start, index, comment: true); + continue; + } + + break; + case '(': + if (TrySkipBlockComment(source, ref index)) + { + scan.AddSkip(start, index, comment: true); + continue; + } + + break; + case '\'': + // Only where the tick cannot be part of the name in front of it, and only + // where a closing tick follows within a literal's length. Everything else is + // a type parameter, which is code + if (!IsIdentifierChar(index > 0 ? source[index - 1] : ' ') && + TrySkipCharLiteral(source, ref index)) + { + scan.AddSkip(start, index, comment: false); + continue; + } + + break; + case '"': + case '@': + case '$': + if (TrySkipStringLike(source, ref index)) + { + // The B of a byte string is part of the literal token, so a search for "x" + // cannot match "x"B and splice over only the quoted part + while (index < source.Length && + (char.IsLetterOrDigit(source[index]) || source[index] == '_')) + { + index++; + } + + scan.AddSkip(start, index, comment: false); + continue; + } + + break; + } + + scan.MarkCode(index); + index++; + } + + return scan; + } + + internal override bool IsDeclaration(SourceScan scan, int nameStart) + { + var source = scan.Source; + var index = scan.PreviousSignificant(nameStart); + if (index < 0) + { + return false; + } + + if (source[index] == '.') + { + // member this.Snapshot, which otherwise reads exactly like the receiver of a call. + // Step back over the self identifier and judge by what introduced it + var receiver = scan.PreviousSignificant(index); + if (receiver < 0 || + !IsIdentifierChar(source[receiver])) + { + return false; + } + + index = scan.PreviousSignificant(scan.WordStart(receiver)); + if (index < 0) + { + return false; + } + } + + if (!IsIdentifierChar(source[index])) + { + return false; + } + + return declarationKeywords.Contains(scan.WordEndingAt(index)); + } + + /// + /// The keywords that introduce a binding. A name preceded by one of them is being declared; + /// anything else in front of a name - an operator, a bracket, or a keyword that introduces an + /// expression - leaves it a call. + /// + static readonly HashSet declarationKeywords = + [ + with(StringComparer.Ordinal), + "abstract", "and", "default", "inline", "internal", "let", "member", "mutable", + "override", "private", "public", "rec", "static", "use", "val" + ]; + + static bool TrySkipLineComment(string source, ref int index) + { + if (index + 1 >= source.Length || + source[index + 1] != '/') + { + return false; + } + + var end = source.IndexOf('\n', index); + index = end < 0 ? source.Length : end + 1; + return true; + } + + /// + /// Block comments nest, so the scan counts them rather than stopping at the first close. + /// + static bool TrySkipBlockComment(string source, ref int index) + { + if (!StartsBlockComment(source, index)) + { + return false; + } + + var cursor = index + 2; + var depth = 1; + while (cursor < source.Length) + { + if (StartsBlockComment(source, cursor)) + { + depth++; + cursor += 2; + continue; + } + + if (source[cursor] == '*' && + cursor + 1 < source.Length && + source[cursor + 1] == ')') + { + depth--; + cursor += 2; + if (depth == 0) + { + index = cursor; + return true; + } + + continue; + } + + cursor++; + } + + // Unterminated: the rest of the file is comment, which is what the compiler sees too + index = source.Length; + return true; + } + + static bool StartsBlockComment(string source, int index) => + index + 1 < source.Length && + source[index] == '(' && + source[index + 1] == '*' && + // (*) is the multiplication operator as a function, not an empty comment + !(index + 2 < source.Length && source[index + 2] == ')'); + + /// + /// Strict, because the alternative reading of a tick is a type parameter and swallowing to the + /// next one would take a span of code out of the map. Only a literal that closes where a + /// literal has to close is one. + /// + static bool TrySkipCharLiteral(string source, ref int index) + { + var cursor = index + 1; + if (cursor >= source.Length) + { + return false; + } + + var ch = source[cursor]; + if (ch == '\\') + { + cursor++; + if (cursor >= source.Length) + { + return false; + } + + var escape = source[cursor]; + cursor++; + switch (escape) + { + case 'u': + if (!TrySkipHex(source, ref cursor, 4)) + { + return false; + } + + break; + case 'U': + if (!TrySkipHex(source, ref cursor, 8)) + { + return false; + } + + break; + case 'x': + if (!TrySkipHex(source, ref cursor, 2)) + { + return false; + } + + break; + default: + if (char.IsDigit(escape) && + !TrySkipDigits(source, ref cursor, 2)) + { + return false; + } + + break; + } + } + else if (ch is '\'' or '\n' or '\r') + { + return false; + } + else + { + cursor++; + } + + if (cursor >= source.Length || + source[cursor] != '\'') + { + return false; + } + + index = cursor + 1; + return true; + } + + static bool TrySkipHex(string source, ref int index, int count) + { + for (var read = 0; read < count; read++) + { + if (index >= source.Length || + !Uri.IsHexDigit(source[index])) + { + return false; + } + + index++; + } + + return true; + } + + static bool TrySkipDigits(string source, ref int index, int count) + { + for (var read = 0; read < count; read++) + { + if (index >= source.Length || + !char.IsDigit(source[index])) + { + return false; + } + + index++; + } + + return true; + } + + // index at '$', '@' or '"'. Returns false when the characters do not start a string literal + // (eg the list append operator '@'); the caller then advances by one. + static bool TrySkipStringLike(string source, ref int index) + { + var cursor = index; + var interpolated = false; + var verbatim = false; + while (cursor < source.Length) + { + var ch = source[cursor]; + if (ch == '$') + { + interpolated = true; + cursor++; + continue; + } + + if (ch == '@') + { + verbatim = true; + cursor++; + continue; + } + + break; + } + + if (cursor >= source.Length || source[cursor] != '"') + { + return false; + } + + var quotes = QuoteRun(source, cursor); + // There is no verbatim triple-quoted form, so a run of quotes after @" is an escaped quote + // and the rest of the string, not a delimiter + if (quotes >= 3 && !verbatim) + { + // Triple quoted: verbatim, so there are no escapes to consider and no delimiter to + // widen. It ends at the next run of three quotes, interpolation holes included + var search = cursor + 3; + while (search < source.Length) + { + if (source[search] == '"' && + QuoteRun(source, search) >= 3) + { + index = search + 3; + return true; + } + + search++; + } + + index = source.Length; + return true; + } + + if (quotes == 2 && !verbatim) + { + // Empty string "" or interpolated empty string $"" + index = cursor + 2; + return true; + } + + cursor++; + while (cursor < source.Length) + { + var ch = source[cursor]; + if (ch == '"') + { + if (verbatim && + cursor + 1 < source.Length && + source[cursor + 1] == '"') + { + cursor += 2; + continue; + } + + index = cursor + 1; + return true; + } + + if (!verbatim && ch == '\\') + { + // Escape or line continuation: either way the next character is content + cursor += 2; + continue; + } + + if (interpolated && ch == '{') + { + if (cursor + 1 < source.Length && source[cursor + 1] == '{') + { + cursor += 2; + continue; + } + + if (!TrySkipHole(source, ref cursor)) + { + index = source.Length; + return true; + } + + continue; + } + + if (interpolated && ch == '}' && + cursor + 1 < source.Length && source[cursor + 1] == '}') + { + cursor += 2; + continue; + } + + // An ordinary F# string may span lines, so a newline is content rather than the end + cursor++; + } + + index = source.Length; + return true; + } + + // cursor at '{' of an interpolation hole; skips past the matching '}' + static bool TrySkipHole(string source, ref int cursor) + { + var depth = 1; + cursor++; + while (cursor < source.Length) + { + switch (source[cursor]) + { + case '"': + case '@': + case '$': + if (!TrySkipStringLike(source, ref cursor)) + { + cursor++; + } + + continue; + case '{': + depth++; + cursor++; + continue; + case '}': + depth--; + cursor++; + if (depth == 0) + { + return true; + } + + continue; + default: + cursor++; + continue; + } + } + + return false; + } + + static int QuoteRun(string source, int index) + { + var count = 0; + while (index + count < source.Length && + source[index + count] == '"') + { + count++; + } + + return count; + } +} diff --git a/src/DiffEngine/Inline/FsStringLiteral.cs b/src/DiffEngine/Inline/FsStringLiteral.cs new file mode 100644 index 00000000..4a87ccf1 --- /dev/null +++ b/src/DiffEngine/Inline/FsStringLiteral.cs @@ -0,0 +1,487 @@ +namespace DiffEngine; + +/// +/// Renders snapshot text as an F# string literal, and parses F# string literal expressions back to +/// their runtime values. +/// +/// The peer of , and its indent means something else entirely. F# has +/// no raw string: a triple-quoted one is verbatim from the character after the opening delimiter +/// to the one before the closing delimiter, with no first line dropped and no common indentation +/// stripped, so an indent written into it would be snapshot content. Multi-line content is +/// therefore written hard against the left margin, and the indent is a floor the result has to +/// clear rather than a prefix to add. +/// +/// +public static class FsStringLiteral +{ + /// + /// Renders (\n newlines) as an F# string literal expression: a + /// verbatim multi-line one where the layout allows it, and a regular literal - one source + /// line, newlines escaped - everywhere else. + /// + /// Snapshot text with \n newlines. + /// + /// The indentation the literal is being spliced at. Not a prefix: the column that whatever + /// follows the literal has to reach. See . + /// + /// The target file's line ending ("\r\n" or "\n"). + public static string Render(string content, string indent, string eol) + { + if (content.IndexOf('\n') == -1 && + content.IndexOf('\r') == -1) + { + return RenderRegular(content); + } + + var multiLine = RenderMultiLine(content, eol); + if (ClearsOffsideLine(multiLine, indent)) + { + return multiLine; + } + + // No layout to break: one source line, whatever it costs in escapes + return RenderRegular(SourceLanguage.NormalizeNewlines(content)); + } + + /// + /// Whether a multi-line literal can be spliced at without breaking + /// F#'s offside rule. + /// + /// The content is verbatim, so its last line decides where the closing delimiter sits, and + /// therefore where the closing paren and any chained call after it sit. F# requires those to + /// be at or right of the column the statement started in, and a snapshot whose last line is + /// short - anything ending in a newline, for a start - puts them left of it. That is not a + /// formatting complaint: the file no longer compiles. + /// + /// + /// is the splice site's indentation, which is at or right of the + /// statement's own, so measuring against it is the conservative side of the rule. Measured in + /// characters because F# rejects tabs in source outright (FS1161), which makes a column count + /// and a character count the same number. + /// + /// + public static bool ClearsOffsideLine(string rendered, string indent) => + rendered.Length - (rendered.LastIndexOf('\n') + 1) >= indent.Length; + + /// + /// Renders content as a regular literal on one source line, escaping what the form cannot hold + /// verbatim - newlines included, which is what makes this the form that cannot break the + /// layout around it. + /// + static string RenderRegular(string content) + { + var builder = new StringBuilder(content.Length + 2); + builder.Append('"'); + foreach (var ch in content) + { + switch (ch) + { + case '\\': + builder.Append("\\\\"); + continue; + case '"': + builder.Append("\\\""); + continue; + case '\n': + builder.Append("\\n"); + continue; + case '\r': + builder.Append("\\r"); + continue; + case '\a': + builder.Append("\\a"); + continue; + case '\b': + builder.Append("\\b"); + continue; + case '\f': + builder.Append("\\f"); + continue; + case '\t': + builder.Append("\\t"); + continue; + case '\v': + builder.Append("\\v"); + continue; + } + + // Everything else a literal cannot carry as itself. F# has no \0 or \e escape, so the + // \u form covers every remaining control character rather than only the exotic ones + if (ch < ' ' || ch == '\u007f') + { + builder.Append("\\u"); + builder.Append(((int) ch).ToString("x4")); + continue; + } + + builder.Append(ch); + } + + builder.Append('"'); + return builder.ToString(); + } + + /// + /// Renders (\n newlines) as a multi-line literal. Triple-quoted + /// where it can be, since that form escapes nothing at all and so carries a snapshot as it + /// reads; verbatim where the content would collide with the delimiter. + /// + /// The content lines are written as they are, with no indentation added: F# takes them + /// verbatim, so an indent would be snapshot content rather than layout. + /// + /// + /// Snapshot text with \n newlines. + /// The target file's line ending ("\r\n" or "\n"). + public static string RenderMultiLine(string content, string eol) + { + if (content.Length == 0) + { + return "\"\""; + } + + if (content.IndexOf('\r') != -1) + { + // Content is meant to arrive \n normalized. Be defensive: a stray \r would + // otherwise be emitted into the literal as content, corrupting the snapshot + content = SourceLanguage.NormalizeNewlines(content); + } + + var body = content.Replace("\n", eol); + if (CanTripleQuote(content)) + { + return $"\"\"\"{body}\"\"\""; + } + + return $"@\"{body.Replace("\"", "\"\"")}\""; + } + + /// + /// A triple-quoted literal has no way to escape its own delimiter and no way to widen it, so + /// content that runs into the delimiter has to take the verbatim form instead. A quote at + /// either end counts: it would sit against the delimiter and be read as part of it. + /// + static bool CanTripleQuote(string content) => + content[0] != '"' && + content[content.Length - 1] != '"' && + content.IndexOf("\"\"\"", StringComparison.Ordinal) == -1; + + /// + /// Parses an F# string literal expression back to its runtime value. + /// Supports triple-quoted ("""..."""), verbatim (@"...") and regular ("...") literals. + /// 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) + { + value = null; + var text = expression.Trim(); + if (text.Length == 0) + { + return false; + } + + if (!TryScanLiteral(text, 0, out value, out var end)) + { + return false; + } + + // The scan must consume the whole expression (rejects "a" + "b", "abc"B etc.) + if (end != text.Length) + { + value = null; + return false; + } + + value = SourceLanguage.NormalizeNewlines(value!); + return true; + } + + /// + /// Scans one string literal starting at (which must point at the + /// first character of the literal: '"' or '@'). On success is the + /// index one past the closing quote. The value is NOT newline normalized. + /// + static bool TryScanLiteral(string text, int start, out string? value, out int end) + { + value = null; + end = start; + if (start >= text.Length) + { + return false; + } + + var index = start; + var verbatim = false; + if (text[index] == '@') + { + verbatim = true; + index++; + } + + if (index >= text.Length || text[index] != '"') + { + // Interpolated ($) and everything else is unsupported. + return false; + } + + if (verbatim) + { + // There is no verbatim triple-quoted form, so a run of quotes after @" is an escaped + // quote and the rest of the string, not a delimiter + return TryScanVerbatim(text, index + 1, out value, out end); + } + + var quotes = QuoteRunLength(text, index); + if (quotes >= 3) + { + return TryScanTripleQuoted(text, index, out value, out end); + } + + if (quotes == 2) + { + // Empty regular string "" + value = ""; + end = index + 2; + return true; + } + + return TryScanRegular(text, index + 1, out value, out end); + } + + static int QuoteRunLength(string text, int index) + { + var count = 0; + while (index + count < text.Length && + text[index + count] == '"') + { + count++; + } + + return count; + } + + /// + /// Content is whatever sits between the delimiters, exactly. Unlike a C# raw string there is + /// no first line to drop and no indent to strip. + /// + static bool TryScanTripleQuoted(string text, int start, out string? value, out int end) + { + value = null; + end = start; + var contentStart = start + 3; + var index = contentStart; + while (index < text.Length) + { + if (text[index] == '"' && + QuoteRunLength(text, index) >= 3) + { + value = text.Substring(contentStart, index - contentStart); + end = index + 3; + return true; + } + + index++; + } + + return false; + } + + static bool TryScanVerbatim(string text, int start, out string? value, out int end) + { + value = null; + end = start; + var builder = new StringBuilder(); + var index = start; + while (index < text.Length) + { + var ch = text[index]; + if (ch == '"') + { + if (index + 1 < text.Length && + text[index + 1] == '"') + { + builder.Append('"'); + index += 2; + continue; + } + + value = builder.ToString(); + end = index + 1; + return true; + } + + builder.Append(ch); + index++; + } + + return false; + } + + static bool TryScanRegular(string text, int start, out string? value, out int end) + { + value = null; + end = start; + var builder = new StringBuilder(); + var index = start; + while (index < text.Length) + { + var ch = text[index]; + if (ch == '"') + { + value = builder.ToString(); + end = index + 1; + return true; + } + + if (ch != '\\') + { + // An ordinary F# string may span lines, so a newline here is content + builder.Append(ch); + index++; + continue; + } + + index++; + if (index >= text.Length) + { + return false; + } + + var escape = text[index]; + index++; + switch (escape) + { + case '\\': + builder.Append('\\'); + break; + case '"': + builder.Append('"'); + break; + case '\'': + builder.Append('\''); + break; + case 'a': + builder.Append('\a'); + break; + case 'b': + builder.Append('\b'); + break; + case 'f': + builder.Append('\f'); + break; + case 'n': + builder.Append('\n'); + break; + case 'r': + builder.Append('\r'); + break; + case 't': + builder.Append('\t'); + break; + case 'v': + builder.Append('\v'); + break; + case 'u': + if (!TryReadHex(text, ref index, 4, out var utf16)) + { + return false; + } + + builder.Append((char) utf16); + break; + case 'x': + if (!TryReadHex(text, ref index, 2, out var byteValue)) + { + return false; + } + + builder.Append((char) byteValue); + break; + case 'U': + if (!TryReadHex(text, ref index, 8, out var codePoint)) + { + return false; + } + + if (codePoint > 0x10FFFF) + { + return false; + } + + builder.Append(char.ConvertFromUtf32((int) codePoint)); + break; + case '\r': + case '\n': + // Line continuation: the newline and the indentation that follows it are + // layout, not content + if (escape == '\r' && + index < text.Length && + text[index] == '\n') + { + index++; + } + + while (index < text.Length && + (text[index] == ' ' || text[index] == '\t')) + { + index++; + } + + break; + default: + // Trigraph: \DDD, three decimal digits + if (!char.IsDigit(escape)) + { + return false; + } + + if (!TryReadTrigraph(text, ref index, escape, out var trigraph)) + { + return false; + } + + builder.Append(trigraph); + break; + } + } + + return false; + } + + static bool TryReadHex(string text, ref int index, int count, out uint result) + { + result = 0; + var read = 0; + while (read < count && + index < text.Length && + Uri.IsHexDigit(text[index])) + { + result = (result << 4) + (uint) Uri.FromHex(text[index]); + index++; + read++; + } + + return read == count; + } + + static bool TryReadTrigraph(string text, ref int index, char first, out char result) + { + result = '\0'; + if (index + 1 >= text.Length || + !char.IsDigit(text[index]) || + !char.IsDigit(text[index + 1])) + { + return false; + } + + var value = (first - '0') * 100 + (text[index] - '0') * 10 + (text[index + 1] - '0'); + index += 2; + if (value > 255) + { + return false; + } + + result = (char) value; + return true; + } +} diff --git a/src/DiffEngine/Inline/InlineApplier.cs b/src/DiffEngine/Inline/InlineApplier.cs index 57920193..27392db7 100644 --- a/src/DiffEngine/Inline/InlineApplier.cs +++ b/src/DiffEngine/Inline/InlineApplier.cs @@ -1,9 +1,13 @@ namespace DiffEngine; /// -/// Applies an to a C# source file, preserving the file's +/// Applies an to a source file, preserving the file's /// encoding, BOM and line endings. Owns all locking (cross process and in process); /// callers must not add their own. +/// +/// The language is read off the file's extension (see ), so a +/// patch says which file it edits and nothing has to say which language that file is in. +/// /// public static class InlineApplier { @@ -36,7 +40,7 @@ public static InlineApplyResult Apply(InlinePatch patch) return InlineApplyResult.Failed($"Source file does not exist: {fullPath}"); } - var newContent = CsStringLiteral.NormalizeNewlines(patch.NewContent); + var newContent = SourceLanguage.NormalizeNewlines(patch.NewContent); var normalizedPath = fullPath.ToLowerInvariant(); lock (gates.GetOrAdd(normalizedPath, static _ => new())) { @@ -100,6 +104,7 @@ static InlineApplyResult LockedApply(string fullPath, InlinePatch patch, string } var status = InlinePatcher.TryApply( + SourceLanguage.ForFile(fullPath), source, patch.LineHint, patch.Mode, diff --git a/src/DiffEngine/Inline/InlinePatch.cs b/src/DiffEngine/Inline/InlinePatch.cs index 368765bc..cbcc143b 100644 --- a/src/DiffEngine/Inline/InlinePatch.cs +++ b/src/DiffEngine/Inline/InlinePatch.cs @@ -1,7 +1,7 @@ namespace DiffEngine; /// -/// Describes a pending inline-snapshot edit to a C# source file. +/// Describes a pending inline-snapshot edit to a source file. /// /// Settable properties so it round-trips through , but no /// parameterless constructor: a patch with no source file or no content is not a patch, and @@ -25,7 +25,8 @@ public InlinePatch( } /// - /// Full path to the .cs file. + /// Full path to the source file. Its extension decides the language the literal is written in + /// (). /// public string SourceFile { get; set; } diff --git a/src/DiffEngine/Inline/InlinePatcher.cs b/src/DiffEngine/Inline/InlinePatcher.cs index bb5be7e2..4179b6da 100644 --- a/src/DiffEngine/Inline/InlinePatcher.cs +++ b/src/DiffEngine/Inline/InlinePatcher.cs @@ -6,8 +6,14 @@ enum PatchStatus } /// -/// Pure string in / string out engine that locates an inline snapshot call site in C# source -/// and splices in a new raw string literal. No file IO. +/// Pure string in / string out engine that locates an inline snapshot call site in source and +/// splices in a new string literal. No file IO. +/// +/// The structure it walks - a name, an argument list, a chain of calls hung off it - is the same +/// in every language it patches, so only what a literal looks like, what a comment looks like, and +/// what tells a declaration from a call is per language. All of that lives on +/// , reached through the scan. +/// /// static class InlinePatcher { @@ -35,6 +41,7 @@ static class InlinePatcher const string verifierType = "Verifier"; public static PatchStatus TryApply( + SourceLanguage language, string source, int lineHint, InlinePatchMode mode, @@ -47,7 +54,7 @@ public static PatchStatus TryApply( failReason = ""; var eol = DetectEol(source); var lineStarts = BuildLineStarts(source); - var scan = new CsScan(source); + var scan = language.Scan(source); if (mode == InlinePatchMode.Remove) { @@ -79,13 +86,13 @@ public static PatchStatus TryApply( continue; } - if (CsStringLiteral.TryParse(needle, out var oldValue) && + if (language.TryParse(needle, out var oldValue) && oldValue == newContent) { return PatchStatus.AlreadyApplied; } - var rendered = RenderArgument(source, lineStarts, expected.Start, newContent, eol, fileUnit); + var rendered = RenderArgument(language, source, lineStarts, expected.Start, newContent, eol, fileUnit); newSource = Splice(source, expected.Start, expected.End, rendered); return PatchStatus.Applied; } @@ -99,7 +106,7 @@ public static PatchStatus TryApply( static PatchStatus InsertOrCheck( string source, - CsScan scan, + SourceScan scan, List lineStarts, int lineHint, string newContent, @@ -130,7 +137,7 @@ static PatchStatus InsertOrCheck( return PatchStatus.NotFound; } - var emptyRendered = RenderArgument(source, lineStarts, expected.Start, newContent, eol, fileUnit); + var emptyRendered = RenderArgument(scan.Language, source, lineStarts, expected.Start, newContent, eol, fileUnit); newSource = Splice(source, expected.Start, expected.Start, emptyRendered); return PatchStatus.Applied; } @@ -146,8 +153,11 @@ static PatchStatus InsertOrCheck( } var namedIndent = IndentForSpan(source, lineStarts, expected.ListStart, fileUnit); - var namedRendered = CsStringLiteral.Render(newContent, namedIndent, eol); - newSource = Splice(source, expected.ListStart, expected.ListStart, $"{parameterName}: {namedRendered}, "); + var namedRendered = scan.Language.Render( + newContent, + scan.Language.IndentFor(LeadingWhitespace(source, lineStarts, expected.ListStart), namedIndent), + eol); + newSource = Splice(source, expected.ListStart, expected.ListStart, $"{scan.Language.NamePrefix(parameterName)}{namedRendered}, "); return PatchStatus.Applied; } @@ -160,18 +170,29 @@ static PatchStatus InsertOrCheck( return PatchStatus.NotFound; } - var rendered = RenderArgument(source, lineStarts, expected.Start, newContent, eol, fileUnit); + var rendered = RenderArgument(scan.Language, source, lineStarts, expected.Start, newContent, eol, fileUnit); newSource = Splice(source, expected.Start, expected.End, rendered); return PatchStatus.Applied; } - if (CsStringLiteral.TryParse(argText, out var currentValue)) + if (scan.Language.TryParse(argText, out var currentValue)) { if (currentValue == newContent) { return PatchStatus.AlreadyApplied; } + if (!alreadyOnly && + !scan.Language.SuppliesArgumentExpressions) + { + // A differing literal is a snapshot that changed, and this is the only shape a + // changed one arrives in from a language with no expression to anchor on. Refusing + // it there would mean an inline snapshot could be accepted once and never updated + var rendered = RenderArgument(scan.Language, source, lineStarts, expected.Start, newContent, eol, fileUnit); + newSource = Splice(source, expected.Start, expected.End, rendered); + return PatchStatus.Applied; + } + failReason = alreadyOnly ? $"The previous expected expression was not found near line {lineHint}, and the current expected argument has different content. The source may have changed since the test run. Re-run the test." : $"The {methodName} call near line {lineHint} already has a different expected argument."; @@ -225,7 +246,7 @@ public bool Matches(string source, string expression) => string.CompareOrdinal(source, Start, expression, 0, expression.Length) == 0; } - static bool TryReadArguments(string source, CsScan scan, int openParen, out ExpectedArgument expected) + static bool TryReadArguments(string source, SourceScan scan, int openParen, out ExpectedArgument expected) { expected = default; if (!TryScanArguments(source, scan, openParen, out var closeParen, out var topCommas)) @@ -239,7 +260,7 @@ static bool TryReadArguments(string source, CsScan scan, int openParen, out Expe TrimSpan(source, scan, ref start, ref end); var listStart = start; var blockedByName = start != end && - TryStripArgumentName(source, ref start, out var argumentName) && + scan.Language.TryStripArgumentName(source, ref start, out var argumentName) && argumentName != parameterName; expected = new(start, end, listStart, blockedByName); return true; @@ -248,11 +269,13 @@ static bool TryReadArguments(string source, CsScan scan, int openParen, out Expe /// /// Appends a Snapshot call to the verify invocation, for a snapshot that has never been /// accepted. Snapshot terminates the chain, so the insertion point is the end of any calls - /// already chained onto the invocation rather than the invocation's own closing paren. + /// already chained onto the invocation rather than the invocation's own closing paren - except + /// where the language ends its chain with something Snapshot has to precede, which + /// answers. /// static PatchStatus TryAppend( string source, - CsScan scan, + SourceScan scan, List lineStarts, int lineHint, string newContent, @@ -288,8 +311,8 @@ static PatchStatus TryAppend( ? statementIndent + unit : LeadingWhitespace(source, lineStarts, insertAt - 1); var contentIndent = callIndent + unit; - var rendered = CsStringLiteral.Render(newContent, contentIndent, eol); - var argument = OnOwnLine(rendered, contentIndent, eol); + var rendered = scan.Language.Render(newContent, scan.Language.IndentFor(statementIndent, contentIndent), eol); + var argument = OnOwnLine(scan.Language, rendered, contentIndent, eol); newSource = Splice(source, insertAt, insertAt, $"{eol}{callIndent}.{methodName}({argument})"); return PatchStatus.Applied; } @@ -300,7 +323,7 @@ static PatchStatus TryAppend( /// static PatchStatus TryRemove( string source, - CsScan scan, + SourceScan scan, List lineStarts, int lineHint, ref string newSource, @@ -362,12 +385,18 @@ static PatchStatus TryRemove( } /// - /// Walks the calls chained onto an invocation and returns the end of the chain. + /// Walks the calls chained onto an invocation and returns where a call should be appended: + /// the end of the chain, or the point in front of the language's + /// when the chain ends in one. /// is set when one of them is a call to . /// - static int WalkChain(string source, CsScan scan, int index, string name, out bool found) + static int WalkChain(string source, SourceScan scan, int index, string name, out bool found) { found = false; + var terminator = scan.Language.ChainTerminator; + // Where the chain was before the terminating call, which is where an appended one goes: + // in front of the terminator, and behind the whitespace and line break that introduced it + var beforeTerminator = -1; while (true) { var cursor = index; @@ -375,7 +404,7 @@ static int WalkChain(string source, CsScan scan, int index, string name, out boo if (cursor >= source.Length || source[cursor] != '.') { - return index; + break; } cursor++; @@ -383,7 +412,7 @@ static int WalkChain(string source, CsScan scan, int index, string name, out boo var nameStart = cursor; while (cursor < source.Length && - CsScan.IsIdentifierChar(source[cursor])) + scan.IsIdentifierChar(source[cursor])) { cursor++; } @@ -392,19 +421,31 @@ static int WalkChain(string source, CsScan scan, int index, string name, out boo !TrySkipToParen(source, scan, cursor, out var paren) || !TryScanArguments(source, scan, paren, out var closeParen, out _)) { - return index; + break; } - if (string.CompareOrdinal(source, nameStart, name, 0, name.Length) == 0 && - cursor - nameStart == name.Length) + if (IsCall(source, nameStart, cursor, name)) { found = true; } + if (terminator != null && + beforeTerminator < 0 && + IsCall(source, nameStart, cursor, terminator)) + { + beforeTerminator = index; + } + index = closeParen + 1; } + + return beforeTerminator < 0 ? index : beforeTerminator; } + static bool IsCall(string source, int nameStart, int nameEnd, string name) => + nameEnd - nameStart == name.Length && + string.CompareOrdinal(source, nameStart, name, 0, name.Length) == 0; + static string LeadingWhitespace(string source, List lineStarts, int offset) { var lineStart = lineStarts[LineOf(lineStarts, offset) - 1]; @@ -418,12 +459,12 @@ static string LeadingWhitespace(string source, List lineStarts, int offset) return source.Substring(lineStart, index - lineStart); } - static bool TryFindCall(string source, CsScan scan, List lineStarts, int lineHint, out int openParen) => + static bool TryFindCall(string source, SourceScan scan, List lineStarts, int lineHint, out int openParen) => TryFindCall(source, scan, lineStarts, lineHint, methodName, false, out _, out openParen); static bool TryFindCall( string source, - CsScan scan, + SourceScan scan, List lineStarts, int lineHint, string name, @@ -451,7 +492,7 @@ static bool TryFindCall( /// static IEnumerable<(int nameStart, int openParen)> FindCalls( string source, - CsScan scan, + SourceScan scan, List lineStarts, int lineHint, string name, @@ -486,13 +527,13 @@ static bool TryFindCall( if (byPrefix) { while (identifierEnd < source.Length && - CsScan.IsIdentifierChar(source[identifierEnd])) + scan.IsIdentifierChar(source[identifierEnd])) { identifierEnd++; } } else if (identifierEnd < source.Length && - CsScan.IsIdentifierChar(source[identifierEnd])) + scan.IsIdentifierChar(source[identifierEnd])) { index += name.Length; continue; @@ -501,7 +542,7 @@ static bool TryFindCall( // In code, a whole token, an invocation rather than a declaration, and // followed by an argument list. A commented out example passes none of these if (scan.IsCode(index) && - StartsToken(source, index) && + StartsToken(source, scan, index) && !scan.IsDeclaration(index) && !(byPrefix && IsForeignReceiver(source, scan, index)) && TrySkipToParen(source, scan, identifierEnd, out var paren)) @@ -515,9 +556,9 @@ static bool TryFindCall( } } - static bool StartsToken(string source, int index) => + static bool StartsToken(string source, SourceScan scan, int index) => index == 0 || - !CsScan.IsIdentifierChar(source[index - 1]); + !scan.IsIdentifierChar(source[index - 1]); /// /// True when the name is reached through a member access on anything other than the verify @@ -530,7 +571,7 @@ static bool StartsToken(string source, int index) => /// it, in a test that may not even be the one the patch came from. /// /// - static bool IsForeignReceiver(string source, CsScan scan, int nameStart) + static bool IsForeignReceiver(string source, SourceScan scan, int nameStart) { var dot = scan.PreviousSignificant(nameStart); if (dot < 0 || @@ -548,7 +589,7 @@ static bool IsForeignReceiver(string source, CsScan scan, int nameStart) } if (end < 0 || - !CsScan.IsIdentifierChar(source[end])) + !scan.IsIdentifierChar(source[end])) { // Not a plain receiver, so a literal, an indexer or a call result return true; @@ -556,7 +597,7 @@ static bool IsForeignReceiver(string source, CsScan scan, int nameStart) var start = end; while (start > 0 && - CsScan.IsIdentifierChar(source[start - 1])) + scan.IsIdentifierChar(source[start - 1])) { start--; } @@ -566,13 +607,13 @@ static bool IsForeignReceiver(string source, CsScan scan, int nameStart) receiver != "this"; } - static bool TrySkipToParen(string source, CsScan scan, int index, out int paren) + static bool TrySkipToParen(string source, SourceScan scan, int index, out int paren) { paren = -1; scan.SkipTrivia(ref index); if (index < source.Length && source[index] == '<' && - CsScan.TrySkipTypeArguments(source, ref index)) + scan.Language.TrySkipTypeArguments(source, ref index)) { scan.SkipTrivia(ref index); } @@ -589,7 +630,7 @@ static bool TrySkipToParen(string source, CsScan scan, int index, out int paren) // Scans a balanced argument list starting at the open paren. // Records top level comma positions. Comments and literals are stepped over whole. - static bool TryScanArguments(string source, CsScan scan, int openParen, out int closeParen, out List topCommas) + static bool TryScanArguments(string source, SourceScan scan, int openParen, out int closeParen, out List topCommas) { closeParen = -1; topCommas = []; @@ -648,50 +689,12 @@ static bool TryScanArguments(string source, CsScan scan, int openParen, out int return false; } - static bool TryStripArgumentName(string source, ref int start, out string name) - { - name = ""; - var index = start; - if (index >= source.Length || !char.IsLetter(source[index]) && source[index] != '_') - { - return false; - } - - while (index < source.Length && CsScan.IsIdentifierChar(source[index])) - { - index++; - } - - var nameEnd = index; - while (index < source.Length && char.IsWhiteSpace(source[index])) - { - index++; - } - - if (index >= source.Length || - source[index] != ':' || - index + 1 < source.Length && source[index + 1] == ':') - { - return false; - } - - name = source.Substring(start, nameEnd - start); - index++; - while (index < source.Length && char.IsWhiteSpace(source[index])) - { - index++; - } - - start = index; - return true; - } - /// /// Narrows a span to the expression in it: whitespace and comments are not part of the /// argument, and leaving a comment in makes the argument read as something other than the /// literal it is. /// - static void TrimSpan(string source, CsScan scan, ref int start, ref int end) + static void TrimSpan(string source, SourceScan scan, ref int start, ref int end) { while (start < end) { @@ -701,8 +704,7 @@ static void TrimSpan(string source, CsScan scan, ref int start, ref int end) continue; } - if (source[start] == '/' && - scan.TryGetSkip(start, out var afterComment) && + if (scan.TryGetCommentSkip(start, out var afterComment) && afterComment <= end) { start = afterComment; @@ -735,25 +737,28 @@ static void TrimSpan(string source, CsScan scan, ref int start, ref int end) /// Renders the literal for a splice at , indented to suit where it /// lands. /// - static string RenderArgument(string source, List lineStarts, int spanStart, string newContent, string eol, string fileUnit) + static string RenderArgument(SourceLanguage language, string source, List lineStarts, int spanStart, string newContent, string eol, string fileUnit) { var indent = IndentForSpan(source, lineStarts, spanStart, fileUnit); - var rendered = CsStringLiteral.Render(newContent, indent, eol); + var rendered = language.Render(newContent, language.IndentFor(LeadingWhitespace(source, lineStarts, spanStart), indent), eol); if (StartsLine(source, lineStarts, spanStart)) { return rendered; } - return OnOwnLine(rendered, indent, eol); + return OnOwnLine(language, rendered, indent, eol); } /// /// Puts a raw literal on its own line rather than trailing the open paren, so its opening /// delimiter sits with its content and its closing one. A regular literal stays where it is, - /// since it has nothing to line up with. + /// since it has nothing to line up with, and so does every literal in a language whose + /// multi-line form carries its first line of content on the delimiter's line. /// - static string OnOwnLine(string rendered, string indent, string eol) => - rendered.IndexOf('\n') == -1 ? rendered : $"{eol}{indent}{rendered}"; + static string OnOwnLine(SourceLanguage language, string rendered, string indent, string eol) => + !language.LiteralOnOwnLine || rendered.IndexOf('\n') == -1 + ? rendered + : $"{eol}{indent}{rendered}"; /// /// True when only whitespace precedes the offset on its line. @@ -827,7 +832,7 @@ static string DetectEol(string source) /// Returns "" when the file is too small to show a step, which leaves the choice to /// . /// - static string DetectIndentUnit(string source, CsScan scan, List lineStarts) + static string DetectIndentUnit(string source, SourceScan scan, List lineStarts) { Dictionary counts = new(StringComparer.Ordinal); var previous = ""; @@ -966,6 +971,10 @@ static int LineOf(List lineStarts, int offset) return low + 1; } + /// + /// The indentation a literal taking a line of its own would sit at: one level in from the + /// span's line, or the span's own column when it already starts a line. + /// static string IndentForSpan(string source, List lineStarts, int spanStart, string fileUnit) { var line = LineOf(lineStarts, spanStart); diff --git a/src/DiffEngine/Inline/SourceLanguage.cs b/src/DiffEngine/Inline/SourceLanguage.cs new file mode 100644 index 00000000..05d49780 --- /dev/null +++ b/src/DiffEngine/Inline/SourceLanguage.cs @@ -0,0 +1,222 @@ +namespace DiffEngine; + +/// +/// The language of the source file a patch is applied to: how a snapshot is written as a string +/// literal, how one is read back, and what a scan has to step over to find a call. +/// +/// Chosen by file extension rather than carried on the patch, because the file already says: a +/// patch names the source file it edits, and a producer that had to state the language as well +/// could state one the file is not. +/// +/// +public abstract class SourceLanguage +{ + public static SourceLanguage CSharp { get; } = new CsLanguage(); + + public static SourceLanguage FSharp { get; } = new FsLanguage(); + + /// + /// The language of , by extension. Anything that is not F# is treated + /// as C#: C# is the only other language a producer targets today, and an unknown extension on + /// a file full of C# is far more likely than a language with no support here at all. + /// + public static SourceLanguage ForFile(string path) + { + var extension = Path.GetExtension(path); + if (string.Equals(extension, ".fs", StringComparison.OrdinalIgnoreCase) || + string.Equals(extension, ".fsx", StringComparison.OrdinalIgnoreCase) || + string.Equals(extension, ".fsi", StringComparison.OrdinalIgnoreCase)) + { + return FSharp; + } + + return CSharp; + } + + /// + /// Renders (\n newlines) as a string literal expression in this + /// language. + /// + /// Snapshot text with \n newlines. + /// + /// The indentation of the line the literal will start on. A prefix for the content lines where + /// the multi-line form indents them (C#), and a floor the result has to clear where it cannot + /// (F#, whose content is verbatim - see ). + /// + /// The target file's line ending ("\r\n" or "\n"). + public abstract string Render(string content, string indent, string eol); + + /// + /// Parses a string literal expression back to its runtime value. Returns false for + /// interpolated strings, concatenations, or any other expression. Newlines in the returned + /// value are normalized to \n. + /// + public abstract bool TryParse(string expression, [NotNullWhen(true)] out string? value); + + /// + /// Lexes into the map every search then reads. + /// + internal abstract SourceScan Scan(string source); + + internal abstract bool IsIdentifierChar(char ch); + + /// + /// True when the identifier at is being declared rather than + /// called. The two are otherwise identical - name, parens, body - so the tell is what comes + /// in front of the name, and that is where the languages differ most. + /// + internal abstract bool IsDeclaration(SourceScan scan, int nameStart); + + /// + /// How an argument is bound to a parameter by name, up to and including the separator: + /// expected: in C#, expected = in F#. + /// + internal abstract string NamePrefix(string name); + + /// + /// The character that follows an argument name. + /// + internal abstract char NameSeparator { get; } + + /// + /// Whether a multi-line literal goes on the line below the open paren. True where the literal's + /// opening delimiter has content to line up with, and false where the content is verbatim and + /// so has to start hard against the delimiter. + /// + internal virtual bool LiteralOnOwnLine => true; + + /// + /// Which indentation is given, out of the two the call site has: the line + /// a literal of its own would sit on, or the statement the literal ends up inside. + /// + /// A language that indents its literal is told where to indent it to. One that cannot is told + /// what its result has to clear, which is the statement's indentation rather than the line's, + /// because that is the column F#'s layout rule is measured from. + /// + /// + internal string IndentFor(string statementIndent, string ownLineIndent) => + LiteralOnOwnLine ? ownLineIndent : statementIndent; + + /// + /// A chained call that a Snapshot call has to be appended in front of rather than after, or + /// null when the end of the chain is always the insertion point. + /// + internal virtual string? ChainTerminator => null; + + /// + /// Whether a patch from this language carries the source text of the expected argument, which + /// is to say whether the compiler honours . + /// + /// That expression is what a patch is anchored to: the call whose argument is still the text + /// the test run saw is the call to rewrite, whatever moved around it. Without one, all a patch + /// has is a line hint and the search outward from it, and a literal that differs has to be + /// taken as the snapshot that changed rather than as a conflict - the alternative being that + /// an inline snapshot can be accepted once and never updated. + /// + /// + internal virtual bool SuppliesArgumentExpressions => true; + + /// + /// Advances past a type argument list, so Foo<Bar>(...) is located as readily as + /// Foo(...). Only the characters a type argument list can hold are accepted, and the caller + /// still has to find a '(' after it, so a comparison cannot be mistaken for one. + /// + internal bool TrySkipTypeArguments(string source, ref int index) + { + var cursor = index; + if (cursor >= source.Length || + source[cursor] != '<') + { + return false; + } + + cursor++; + var depth = 1; + while (cursor < source.Length) + { + var ch = source[cursor]; + if (ch == '<') + { + depth++; + cursor++; + continue; + } + + if (ch == '>') + { + depth--; + cursor++; + if (depth == 0) + { + index = cursor; + return true; + } + + continue; + } + + if (IsIdentifierChar(ch) || + IsTypeArgumentChar(ch)) + { + cursor++; + continue; + } + + return false; + } + + return false; + } + + internal virtual bool IsTypeArgumentChar(char ch) => + ch is ',' or '.' or '?' or '[' or ']' or ':' or ' ' or '\t'; + + /// + /// Reads an name = or name: prefix off the front of an argument, leaving + /// on the expression itself. + /// + internal bool TryStripArgumentName(string source, ref int start, out string name) + { + name = ""; + var index = start; + if (index >= source.Length || !char.IsLetter(source[index]) && source[index] != '_') + { + return false; + } + + while (index < source.Length && IsIdentifierChar(source[index])) + { + index++; + } + + var nameEnd = index; + while (index < source.Length && char.IsWhiteSpace(source[index])) + { + index++; + } + + var separator = NameSeparator; + if (index >= source.Length || + source[index] != separator || + // A doubled separator is an operator (:: in C#, == in F#), not a name + index + 1 < source.Length && source[index + 1] == separator) + { + return false; + } + + name = source.Substring(start, nameEnd - start); + index++; + while (index < source.Length && char.IsWhiteSpace(source[index])) + { + index++; + } + + start = index; + return true; + } + + internal static string NormalizeNewlines(string value) => + value + .Replace("\r\n", "\n") + .Replace('\r', '\n'); +} diff --git a/src/DiffEngine/Inline/SourceScan.cs b/src/DiffEngine/Inline/SourceScan.cs new file mode 100644 index 00000000..d59ce74f --- /dev/null +++ b/src/DiffEngine/Inline/SourceScan.cs @@ -0,0 +1,164 @@ +/// +/// A one pass lexical map of a source file: where the comments, strings and char literals are, and +/// therefore which offsets are code. +/// +/// The patcher finds its call sites by scanning text, and a text scan that cannot see a comment +/// or a string patches a commented out example, or the middle of another test's snapshot content, +/// as readily as the real call. Lexing once and asking the map is cheaper than lexing per search, +/// and it is one implementation: every search agrees on what a string is because there is only +/// one answer to ask. +/// +/// +/// The map is language neutral - an offset is code or it is not - so only the lexing that fills it +/// is per language, and that lives on . The language is carried here +/// because nothing that reads the map can do without it: whatever is looking at an offset is about +/// to ask what an identifier character is, or how a literal is written. +/// +/// +sealed class SourceScan(SourceLanguage language, string source) +{ + readonly bool[] code = new bool[source.Length]; + + /// + /// Start of a comment or literal to the offset just past it. + /// + readonly Dictionary skips = new(); + + /// + /// The same spans keyed the other way round, for a scan working backwards. Ends are unique + /// because the spans cannot overlap. + /// + readonly Dictionary skipEnds = new(); + + /// + /// Which of the spans are comments. A literal is content, so the two cannot be treated alike + /// where trivia is being stepped over or trimmed off. + /// + readonly HashSet comments = []; + + public SourceLanguage Language { get; } = language; + + public string Source { get; } = source; + + /// + /// Records a comment or literal spanning to . + /// Called by the lexer on as it fills the map. + /// + public void AddSkip(int start, int end, bool comment) + { + skips.Add(start, end); + skipEnds[end] = start; + if (comment) + { + comments.Add(start); + } + } + + public void MarkCode(int index) => code[index] = true; + + /// + /// True when the offset is outside every comment, string and char literal. + /// + public bool IsCode(int index) => + index >= 0 && + index < code.Length && + code[index]; + + /// + /// When a comment or literal starts at , is the + /// offset just past it. Lets a structural scan step over trivia without lexing it again. + /// + public bool TryGetSkip(int index, out int end) => + skips.TryGetValue(index, out end); + + /// + /// As , but only for comments. + /// + public bool TryGetCommentSkip(int index, out int end) => + skips.TryGetValue(index, out end) && + comments.Contains(index); + + /// + /// True when a comment ends at , with set to + /// where it began. Only comments: a literal is content, and trimming one off a span would be + /// trimming off the value. + /// + public bool TryGetCommentEndingAt(int end, out int start) => + skipEnds.TryGetValue(end, out start) && + comments.Contains(start); + + /// + /// Advances past whitespace and comments. + /// + public void SkipTrivia(ref int index) + { + while (index < Source.Length) + { + if (char.IsWhiteSpace(Source[index])) + { + index++; + continue; + } + + if (TryGetCommentSkip(index, out var end)) + { + index = end; + continue; + } + + return; + } + } + + /// + /// The offset of the last character before that is neither + /// whitespace nor inside a comment, or -1 when there is none. + /// + public int PreviousSignificant(int index) + { + index--; + while (index >= 0 && + (char.IsWhiteSpace(Source[index]) || !code[index])) + { + index--; + } + + return index; + } + + /// + /// True when the identifier at is being declared rather than + /// called. + /// + public bool IsDeclaration(int nameStart) => + Language.IsDeclaration(this, nameStart); + + public bool IsIdentifierChar(char ch) => + Language.IsIdentifierChar(ch); + + /// + /// The start of the identifier ending at , which must be an identifier + /// character. + /// + public int WordStart(int end) + { + var start = end; + while (start > 0 && + IsIdentifierChar(Source[start - 1])) + { + start--; + } + + return start; + } + + /// + /// The whole identifier ending at , which must be an identifier + /// character. + /// + public string WordEndingAt(int end) + { + var start = WordStart(end); + return Source.Substring(start, end - start + 1); + } +} diff --git a/src/DiffEngineViewer/CommandLine.cs b/src/DiffEngineViewer/CommandLine.cs index 1ce092ae..67d9e695 100644 --- a/src/DiffEngineViewer/CommandLine.cs +++ b/src/DiffEngineViewer/CommandLine.cs @@ -2,7 +2,7 @@ static class CommandLine { public const string Usage = """ DiffEngineViewer - DiffEngineViewer --inline --source --line + DiffEngineViewer --inline --source --line DiffEngineViewer --delete DiffEngineViewer --attach diff --git a/src/DiffEngineViewer/QueueEntry.cs b/src/DiffEngineViewer/QueueEntry.cs index dca7f2f0..fce0b082 100644 --- a/src/DiffEngineViewer/QueueEntry.cs +++ b/src/DiffEngineViewer/QueueEntry.cs @@ -91,7 +91,7 @@ public static QueueEntry ForInline(PendingInline pending, int selectedVariant = // content is under the cursor; an unlabeled patch keeps the plain header. LeftHeader: variant.Label is null ? "received" : $"received ({variant.Label})", RightHeader: rightHeader, - LeftText: CsStringLiteral.NormalizeNewlines(patch.NewContent), + LeftText: SourceLanguage.NormalizeNewlines(patch.NewContent), RightText: rightText, Kind: QueueEntryKind.Inline, Patch: patch, @@ -113,8 +113,8 @@ public static QueueEntry ForFiles(string leftFile, string rightFile, FileSide le Name: $"{Path.GetFileName(leftFile)} <> {Path.GetFileName(rightFile)}", LeftHeader: Path.GetFileName(leftFile), RightHeader: Path.GetFileName(rightFile), - LeftText: CsStringLiteral.NormalizeNewlines(left.Text), - RightText: CsStringLiteral.NormalizeNewlines(right.Text), + LeftText: SourceLanguage.NormalizeNewlines(left.Text), + RightText: SourceLanguage.NormalizeNewlines(right.Text), Kind: QueueEntryKind.File, Patch: null, LeftFile: leftFile, @@ -145,8 +145,8 @@ public static QueueEntry ForMove( RightHeader: Path.GetFileName(target), // Left is what the test produced, right is what is committed — the same sides an // inline entry uses for received and expected. - LeftText: CsStringLiteral.NormalizeNewlines(tempSide.Text), - RightText: CsStringLiteral.NormalizeNewlines(targetSide.Text), + LeftText: SourceLanguage.NormalizeNewlines(tempSide.Text), + RightText: SourceLanguage.NormalizeNewlines(targetSide.Text), Kind: QueueEntryKind.Move, Patch: null, LeftFile: temp, @@ -177,7 +177,7 @@ public static QueueEntry ForDelete( LeftHeader: "(deleted)", RightHeader: Path.GetFileName(file), LeftText: "", - RightText: CsStringLiteral.NormalizeNewlines(current.Text), + RightText: SourceLanguage.NormalizeNewlines(current.Text), Kind: QueueEntryKind.Delete, Patch: null, LeftFile: file, @@ -201,7 +201,9 @@ public static QueueEntry ForDelete( return ("expected (new snapshot)", "", null); } - if (CsStringLiteral.TryParse(patch.OriginalExpression, out var value)) + // Read as the language of the file it came out of: an F# literal is not a C# one, and a + // parse that guessed would show a snapshot's source text where it has a value to show + if (SourceLanguage.ForFile(patch.SourceFile).TryParse(patch.OriginalExpression, out var value)) { return ("expected", value, null); } @@ -210,7 +212,7 @@ public static QueueEntry ForDelete( // the change is still reviewable, and say so rather than pretending it is a parsed value. return ( "expected (literal not parsed)", - CsStringLiteral.NormalizeNewlines(patch.OriginalExpression), + SourceLanguage.NormalizeNewlines(patch.OriginalExpression), "Existing expected argument is not a plain string literal. Showing its source text."); } } From 768ff07bc362adda448bb935a383279f9c8bda5b Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Sat, 15 Aug 2026 10:21:45 +1000 Subject: [PATCH 2/8] . --- claude.md | 11 + docs/inline.md | 11 +- docs/mdsource/inline.source.md | 11 +- .../FsCompilerRoundTripTests.cs | 2 +- src/DiffEngine.Tests/InlineApplierTests.cs | 53 ++++ src/DiffEngine.Tests/InlinePatcherFsTests.cs | 107 +++++++- src/DiffEngine.Tests/InlinePatcherTests.cs | 249 ++++++++++++------ src/DiffEngine/Inline/InlineApplier.cs | 2 + src/DiffEngine/Inline/InlinePatch.cs | 30 +++ src/DiffEngine/Inline/InlinePatchFile.cs | 33 ++- src/DiffEngine/Inline/InlinePatcher.cs | 242 +++++++++++++---- src/DiffEngine/Inline/SourceLanguage.cs | 11 +- .../DebugReportTests.Owned.verified.txt | 2 + src/DiffEngineTray/DebugReport.cs | 2 + 14 files changed, 615 insertions(+), 151 deletions(-) diff --git a/claude.md b/claude.md index 5eb5add5..f0e043a3 100644 --- a/claude.md +++ b/claude.md @@ -84,6 +84,17 @@ the file compiling, so the render falls back to a one-line escaped literal. That by compiling patched source with `dotnet fsi` (`FsCompilerRoundTripTests`), because a belief about F#'s lexis is exactly the kind of thing a second copy of the same belief cannot check. +A patch is anchored to the call by `OriginalExpression`, the argument's source text from +`CallerArgumentExpression`, so a file that moved since the run still patches the right call. F# +does not implement that attribute (FS0202), so a producer sends `OriginalValue` — the argument's +value — and the patcher matches on what a literal parses to instead of on what it says. Same +anchor, one parse apart. With neither, the hint is all there is and a differing literal is taken +as the snapshot that changed, or a snapshot could be accepted once and never updated. +`MemberName` (`CallerMemberName`, which F# does implement) narrows on top of either: a call above +that member's declaration is not in it, so an identical snapshot in the test next door is not a +candidate at all, while the recorded line is still tried first so two snapshots in one member stay +apart. + ### Core Components **DiffEngine Library (`src/DiffEngine/`):** diff --git a/docs/inline.md b/docs/inline.md index 9e9f6c13..1328d7e3 100644 --- a/docs/inline.md +++ b/docs/inline.md @@ -78,6 +78,7 @@ For the producing side — a test library with a failing inline snapshot: * `DiffRunner.AddInlineAsync(patch)` queues a patch with whatever owns the port, launching the bundled viewer when nothing does. Returns `Queued`, `Disabled` (build servers, continuous testing and AI CLIs included), or `NoViewerFound` — the caller's cue to stage files and fall back to a text diff. * `DiffRunner.SettleInline(sourceFile, line)` drops the pending entry for a call site, for when a previously failing test passes. Unknown entries and an absent owner are no-ops, so call it freely. The settle carries the running framework, so a multi-targeted run only settles its own variant of a conflicted entry. * `AddInlineAsync` stamps `patch.Framework` with the running process's target framework ("net9.0", "net48") unless the caller already set it, which is what lets the owner tell a re-run from another framework disagreeing. Callers may also set `patch.TestName`, which the viewer uses to group and label the queue; without it, items are labeled by call site. +* Set `patch.OriginalExpression` from `CallerArgumentExpression` where the language supplies one, and `patch.OriginalValue` — the previous expected argument's value — where it does not. One of the two is what stops a patch rewriting the wrong call site when the file has moved since the run. `patch.MemberName` from `CallerMemberName` narrows it further, and is supported everywhere including F#. * Setting `DiffEngine_InlineViewer` to `false` reports `NoViewerFound` without probing, which is how a user opts into reviewing in their IDE instead of a window. @@ -94,10 +95,18 @@ originalExpression: {base64} newContent: {base64} testName: {base64} framework: net9.0 +originalValue: {base64} +memberName: {base64} ``` `lineHint` is a hint: locating the call is content anchored, so a file that shifted since the test run still patches, and one whose call site changed reports rather than corrupts. `mode` is `Set` (replace or insert the expected argument), `Append` (add a Snapshot call where none exists yet), or `Remove` (delete the call, used when migrating a snapshot back to a file). `testName` and `framework` are optional provenance — who produced the patch and under which target framework — parsed tolerantly: absent means unknown, and unknown trailing lines are ignored. +The anchor is `originalExpression`, the source text of the argument the test run saw. A producer whose language does not implement `CallerArgumentExpression` sends `originalValue` instead — the argument's *value* — and the call whose literal parses to it is the one rewritten. Either identifies the call; the expression is used where both arrived, being what the source actually says. With neither, all a patch has is the hint, and a literal that differs is taken as the snapshot that changed rather than as a conflict — otherwise an inline snapshot could be accepted once and never updated. + +`memberName` is `CallerMemberName`, and it narrows rather than identifies, since a member holds any number of snapshots. A call above that member's declaration cannot be inside it, so the search will not reach one however well it matches, and the outward walk starts from the declaration rather than from a line that may since have become another test's. What it protects against is two tests in one file with identical snapshots and a hint that has drifted between them; the recorded line is still tried first, which is what keeps two snapshots in the *same* member apart. A member the file no longer declares — a renamed test — is ignored, leaving the hint as it was. + +All three are optional, and everything past the six fixed lines is order agnostic, so they were added without moving the version: a reader that predates one skips the line. + ## How the literal is written @@ -164,7 +173,7 @@ Verifier.Verify(value) .ToTask() ``` -One difference is not syntax at all. The F# compiler does not implement `CallerArgumentExpression` — it warns FS0202 and leaves the parameter at its default — so an F# patch never carries `originalExpression`, and the call is located by line hint and the search outward from it alone. Where a C# patch with no expression treats a differing literal as a conflict, an F# one has to take it as the snapshot that changed: the alternative is that an inline snapshot can be accepted once and never updated. +One difference is not syntax at all. The F# compiler does not implement `CallerArgumentExpression` — it warns FS0202 and leaves the parameter at its default — so an F# patch never carries `originalExpression`. `CallerFilePath` and `CallerLineNumber` work, so the call site is known; what is missing is the anchor that says which call the patch came from when the file has moved under it. An F# producer should send `originalValue` instead, which anchors on what the argument means rather than on what it says, and is what F# does supply — along with `memberName`, since `CallerMemberName` is supported and holds up inside `task` and `async` expressions. Without either anchor, the patch falls back to the line hint alone and rewrites the literal it finds there. Locating the call is otherwise the same scan, taught F#'s lexis: `(* *)` comments nest, a tick is a char literal only where it cannot be part of a name (`value'`) or a type parameter (`'T`), `(*)` is the multiplication operator rather than a comment, and a name is a declaration only where `let` or `member` says so. A call written without parentheses (`Verifier.Verify value`) is not found — the patch reports rather than corrupts, and the fix is to re-run after adding them. diff --git a/docs/mdsource/inline.source.md b/docs/mdsource/inline.source.md index 395b971d..61824a5c 100644 --- a/docs/mdsource/inline.source.md +++ b/docs/mdsource/inline.source.md @@ -71,6 +71,7 @@ For the producing side — a test library with a failing inline snapshot: * `DiffRunner.AddInlineAsync(patch)` queues a patch with whatever owns the port, launching the bundled viewer when nothing does. Returns `Queued`, `Disabled` (build servers, continuous testing and AI CLIs included), or `NoViewerFound` — the caller's cue to stage files and fall back to a text diff. * `DiffRunner.SettleInline(sourceFile, line)` drops the pending entry for a call site, for when a previously failing test passes. Unknown entries and an absent owner are no-ops, so call it freely. The settle carries the running framework, so a multi-targeted run only settles its own variant of a conflicted entry. * `AddInlineAsync` stamps `patch.Framework` with the running process's target framework ("net9.0", "net48") unless the caller already set it, which is what lets the owner tell a re-run from another framework disagreeing. Callers may also set `patch.TestName`, which the viewer uses to group and label the queue; without it, items are labeled by call site. +* Set `patch.OriginalExpression` from `CallerArgumentExpression` where the language supplies one, and `patch.OriginalValue` — the previous expected argument's value — where it does not. One of the two is what stops a patch rewriting the wrong call site when the file has moved since the run. `patch.MemberName` from `CallerMemberName` narrows it further, and is supported everywhere including F#. * Setting `DiffEngine_InlineViewer` to `false` reports `NoViewerFound` without probing, which is how a user opts into reviewing in their IDE instead of a window. @@ -87,10 +88,18 @@ originalExpression: {base64} newContent: {base64} testName: {base64} framework: net9.0 +originalValue: {base64} +memberName: {base64} ``` `lineHint` is a hint: locating the call is content anchored, so a file that shifted since the test run still patches, and one whose call site changed reports rather than corrupts. `mode` is `Set` (replace or insert the expected argument), `Append` (add a Snapshot call where none exists yet), or `Remove` (delete the call, used when migrating a snapshot back to a file). `testName` and `framework` are optional provenance — who produced the patch and under which target framework — parsed tolerantly: absent means unknown, and unknown trailing lines are ignored. +The anchor is `originalExpression`, the source text of the argument the test run saw. A producer whose language does not implement `CallerArgumentExpression` sends `originalValue` instead — the argument's *value* — and the call whose literal parses to it is the one rewritten. Either identifies the call; the expression is used where both arrived, being what the source actually says. With neither, all a patch has is the hint, and a literal that differs is taken as the snapshot that changed rather than as a conflict — otherwise an inline snapshot could be accepted once and never updated. + +`memberName` is `CallerMemberName`, and it narrows rather than identifies, since a member holds any number of snapshots. A call above that member's declaration cannot be inside it, so the search will not reach one however well it matches, and the outward walk starts from the declaration rather than from a line that may since have become another test's. What it protects against is two tests in one file with identical snapshots and a hint that has drifted between them; the recorded line is still tried first, which is what keeps two snapshots in the *same* member apart. A member the file no longer declares — a renamed test — is ignored, leaving the hint as it was. + +All three are optional, and everything past the six fixed lines is order agnostic, so they were added without moving the version: a reader that predates one skips the line. + ## How the literal is written @@ -157,7 +166,7 @@ Verifier.Verify(value) .ToTask() ``` -One difference is not syntax at all. The F# compiler does not implement `CallerArgumentExpression` — it warns FS0202 and leaves the parameter at its default — so an F# patch never carries `originalExpression`, and the call is located by line hint and the search outward from it alone. Where a C# patch with no expression treats a differing literal as a conflict, an F# one has to take it as the snapshot that changed: the alternative is that an inline snapshot can be accepted once and never updated. +One difference is not syntax at all. The F# compiler does not implement `CallerArgumentExpression` — it warns FS0202 and leaves the parameter at its default — so an F# patch never carries `originalExpression`. `CallerFilePath` and `CallerLineNumber` work, so the call site is known; what is missing is the anchor that says which call the patch came from when the file has moved under it. An F# producer should send `originalValue` instead, which anchors on what the argument means rather than on what it says, and is what F# does supply — along with `memberName`, since `CallerMemberName` is supported and holds up inside `task` and `async` expressions. Without either anchor, the patch falls back to the line hint alone and rewrites the literal it finds there. Locating the call is otherwise the same scan, taught F#'s lexis: `(* *)` comments nest, a tick is a char literal only where it cannot be part of a name (`value'`) or a type parameter (`'T`), `(*)` is the multiplication operator rather than a comment, and a name is a declaration only where `let` or `member` says so. A call written without parentheses (`Verifier.Verify value`) is not found — the patch reports rather than corrupts, and the fix is to re-run after adding them. diff --git a/src/DiffEngine.Tests/FsCompilerRoundTripTests.cs b/src/DiffEngine.Tests/FsCompilerRoundTripTests.cs index 55eec3ab..6c96d080 100644 --- a/src/DiffEngine.Tests/FsCompilerRoundTripTests.cs +++ b/src/DiffEngine.Tests/FsCompilerRoundTripTests.cs @@ -137,7 +137,7 @@ exit failures static string Patch(string snippet, int lineHint, InlinePatchMode mode, string content) { - var status = InlinePatcher.TryApply(SourceLanguage.FSharp, snippet, lineHint, mode, null, content, out var patched, out var reason); + var status = InlinePatcher.TryApply(SourceLanguage.FSharp, snippet, lineHint, mode, null, null, null, content, out var patched, out var reason); if (status != PatchStatus.Applied) { throw new($"{mode} patch was not applied: {reason}"); diff --git a/src/DiffEngine.Tests/InlineApplierTests.cs b/src/DiffEngine.Tests/InlineApplierTests.cs index 4a37470f..d392c576 100644 --- a/src/DiffEngine.Tests/InlineApplierTests.cs +++ b/src/DiffEngine.Tests/InlineApplierTests.cs @@ -446,6 +446,59 @@ public async Task RoundTripNullExpression() } } + [Test] + public async Task RoundTripOriginalValue() + { + var patch = new InlinePatch("Tests.fs", 4, null, "new") + { + TestName = null, + OriginalValue = "old line1\nold line2" + }; + + var read = InlinePatchFile.TryParse(InlinePatchFile.Build(patch), out var result); + + await Assert.That(read).IsTrue(); + await Assert.That(result!.OriginalValue).IsEqualTo(patch.OriginalValue); + } + + [Test] + public async Task RoundTripMemberName() + { + var patch = new InlinePatch("Tests.fs", 4, null, "new") + { + TestName = null, + MemberName = "MyTest" + }; + + var read = InlinePatchFile.TryParse(InlinePatchFile.Build(patch), out var result); + + await Assert.That(read).IsTrue(); + await Assert.That(result!.MemberName).IsEqualTo("MyTest"); + } + + [Test] + public async Task RoundTripNullOriginalValue() + { + var read = InlinePatchFile.TryParse(InlinePatchFile.Build(new("Tests.cs", 1, null, "content") { TestName = null }), out var result); + + await Assert.That(read).IsTrue(); + await Assert.That(result!.OriginalValue).IsNull(); + } + + // The field sits past the six fixed lines, so a payload written before it existed still parses + [Test] + public async Task PayloadWithoutOriginalValue() + { + var read = InlinePatchFile.TryParse( + "version: 2\nsourceFile: x\nlineHint: 1\nmode: Set\noriginalExpression:\nnewContent: YQ==\ntestName:\nframework: net9.0\n", + out var result); + + await Assert.That(read).IsTrue(); + await Assert.That(result!.OriginalValue).IsNull(); + await Assert.That(result.MemberName).IsNull(); + await Assert.That(result.Framework).IsEqualTo("net9.0"); + } + [Test] [Arguments(InlinePatchMode.Set)] [Arguments(InlinePatchMode.Append)] diff --git a/src/DiffEngine.Tests/InlinePatcherFsTests.cs b/src/DiffEngine.Tests/InlinePatcherFsTests.cs index def9552b..86b6aa92 100644 --- a/src/DiffEngine.Tests/InlinePatcherFsTests.cs +++ b/src/DiffEngine.Tests/InlinePatcherFsTests.cs @@ -11,8 +11,10 @@ static PatchStatus TryApply( string? originalExpression, string newContent, out string newSource, - out string failReason) => - InlinePatcher.TryApply(SourceLanguage.FSharp, source, lineHint, mode, originalExpression, newContent, out newSource, out failReason); + out string failReason, + string? originalValue = null, + string? memberName = null) => + InlinePatcher.TryApply(SourceLanguage.FSharp, source, lineHint, mode, originalExpression, originalValue, memberName, newContent, out newSource, out failReason); [Test] public async Task ReplaceRegularLiteral() @@ -116,22 +118,23 @@ public async Task ReapplyingTheSameContentIsAlreadyApplied() await Assert.That(status).IsEqualTo(PatchStatus.AlreadyApplied); } - // F# does not implement CallerArgumentExpression (FS0202), so an F# patch never carries the - // previous expression and a changed snapshot arrives looking exactly like a conflict. Taking - // it as the conflict would mean a snapshot could be accepted once and never updated + // F# does not implement CallerArgumentExpression (FS0202), so an F# patch is anchored by the + // previous value instead: the call whose literal still means what the test run saw [Test] - public async Task ChangedSnapshotIsReplacedWithNoExpressionToAnchorOn() + public async Task ValueAnchorFindsTheCall() { var source = Test(" Verifier.Verify(15).Snapshot(\"old\").ToTask()"); - var status = TryApply(source, 5, InlinePatchMode.Set, null, "new", out var newSource, out _); + var status = TryApply(source, 5, InlinePatchMode.Set, null, "new", out var newSource, out _, originalValue: "old"); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).IsEqualTo(Test(" Verifier.Verify(15).Snapshot(\"new\").ToTask()")); } + // The hint is stale by two lines and lands on another test's snapshot. The value is what says + // which call this patch came from, so the wrong one is left alone [Test] - public async Task ChangedSnapshotPicksTheSiteNearestTheHint() + public async Task ValueAnchorBeatsAStaleHint() { var source = string.Join( "\n", @@ -143,13 +146,99 @@ public async Task ChangedSnapshotPicksTheSiteNearestTheHint() "let TestB () =", " Verifier.Verify(b).Snapshot(\"b\").ToTask()"); - var status = TryApply(source, 7, InlinePatchMode.Set, null, "new", out var newSource, out _); + var status = TryApply(source, 4, InlinePatchMode.Set, null, "new", out var newSource, out _, originalValue: "b"); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).Contains("Verify(a).Snapshot(\"a\")"); await Assert.That(newSource).Contains("Verify(b).Snapshot(\"new\")"); } + // The literal that value described is gone, so the patch is stale. Reporting is the whole + // point of having an anchor: the call at the hint is not known to be the right one + [Test] + public async Task ValueAnchorThatMatchesNothingIsNotFound() + { + var source = Test(" Verifier.Verify(15).Snapshot(\"something else\").ToTask()"); + + var status = TryApply(source, 5, InlinePatchMode.Set, null, "new", out _, out var reason, originalValue: "old"); + + await Assert.That(status).IsEqualTo(PatchStatus.NotFound); + await Assert.That(reason).Contains("Re-run the test"); + } + + // Another process accepted it between the run and this apply + [Test] + public async Task ValueAnchorGoneAndContentAlreadyThereIsAlreadyApplied() + { + var source = Test(" Verifier.Verify(15).Snapshot(\"new\").ToTask()"); + + var status = TryApply(source, 5, InlinePatchMode.Set, null, "new", out _, out _, originalValue: "old"); + + await Assert.That(status).IsEqualTo(PatchStatus.AlreadyApplied); + } + + [Test] + public async Task ValueAnchorAcrossAMultiLineLiteral() + { + var source = Test(" Verifier.Verify(15).Snapshot(\"\"\"old1\nold2\"\"\").ToTask()"); + + var status = TryApply(source, 5, InlinePatchMode.Set, null, "new", out var newSource, out _, originalValue: "old1\nold2"); + + await Assert.That(status).IsEqualTo(PatchStatus.Applied); + await Assert.That(newSource).Contains("Snapshot(\"new\")"); + } + + static string TwoTests(string literalA, string literalB) => + string.Join( + "\n", + "module Tests", + "", + "let TestA () =", + $" Verifier.Verify(a).Snapshot({literalA}).ToTask()", + "", + "let TestB () =", + $" Verifier.Verify(b).Snapshot({literalB}).ToTask()"); + + // A call above TestB's declaration is not inside TestB, whatever the hint says, so the + // identical snapshot in the test above is not even a candidate + [Test] + public async Task MemberNameBoundsTheSearch() + { + var source = TwoTests("\"dup\"", "\"dup\""); + + var status = TryApply(source, 4, InlinePatchMode.Set, null, "new", out var newSource, out _, originalValue: "dup", memberName: "TestB"); + + await Assert.That(status).IsEqualTo(PatchStatus.Applied); + await Assert.That(newSource).Contains("Verify(a).Snapshot(\"dup\")"); + await Assert.That(newSource).Contains("Verify(b).Snapshot(\"new\")"); + } + + // With neither anchor the member is all that keeps an overwrite in the right test + [Test] + public async Task MemberNameScopesAnAnchorlessOverwrite() + { + var source = TwoTests("\"a\"", "\"b\""); + + var status = TryApply(source, 4, InlinePatchMode.Set, null, "new", out var newSource, out _, memberName: "TestB"); + + await Assert.That(status).IsEqualTo(PatchStatus.Applied); + await Assert.That(newSource).Contains("Verify(a).Snapshot(\"a\")"); + await Assert.That(newSource).Contains("Verify(b).Snapshot(\"new\")"); + } + + // With neither anchor - a producer that predates the value field - the literal at the hint is + // all there is, and taking it as the changed snapshot is better than never updating one + [Test] + public async Task ChangedSnapshotIsReplacedWithNothingToAnchorOn() + { + var source = Test(" Verifier.Verify(15).Snapshot(\"old\").ToTask()"); + + var status = TryApply(source, 5, InlinePatchMode.Set, null, "new", out var newSource, out _); + + await Assert.That(status).IsEqualTo(PatchStatus.Applied); + await Assert.That(newSource).IsEqualTo(Test(" Verifier.Verify(15).Snapshot(\"new\").ToTask()")); + } + [Test] public async Task InsertIntoEmptyArgumentList() { diff --git a/src/DiffEngine.Tests/InlinePatcherTests.cs b/src/DiffEngine.Tests/InlinePatcherTests.cs index b1d7881c..3075c91b 100644 --- a/src/DiffEngine.Tests/InlinePatcherTests.cs +++ b/src/DiffEngine.Tests/InlinePatcherTests.cs @@ -1,5 +1,17 @@ public class InlinePatcherTests { + static PatchStatus TryApply( + string source, + int lineHint, + InlinePatchMode mode, + string? originalExpression, + string newContent, + out string newSource, + out string failReason, + string? originalValue = null, + string? memberName = null) => + InlinePatcher.TryApply(SourceLanguage.CSharp, source, lineHint, mode, originalExpression, originalValue, memberName, newContent, out newSource, out failReason); + const string rawOld = "\"\"\"\n old\n \"\"\""; static string Method(string body) => @@ -9,7 +21,7 @@ static string Method(string body) => public async Task ReplaceRawLiteral() { var source = Method($" await Snapshot({rawOld.Replace("\n", "\n ")});"); - var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Set, rawOld.Replace("\n", "\n "), "new", out var newSource, out _); + var status = TryApply(source, 5, InlinePatchMode.Set, rawOld.Replace("\n", "\n "), "new", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).Contains("new"); await Assert.That(newSource).DoesNotContain("old"); @@ -23,7 +35,7 @@ public async Task ReplaceRawLiteral() public async Task ReplaceRegularLiteral() { var source = Method(" await Snapshot(\"old\");"); - var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Set, "\"old\"", "new", out var newSource, out _); + 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(" await Snapshot(\"new\");"); } @@ -32,7 +44,7 @@ public async Task ReplaceRegularLiteral() public async Task ReplacementUsesFileEol() { var source = Method(" await Snapshot(\"old\");").Replace("\n", "\r\n"); - var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Set, "\"old\"", "a\nb", out var newSource, out _); + var status = TryApply(source, 5, InlinePatchMode.Set, "\"old\"", "a\nb", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).DoesNotContain("a\nb"); await Assert.That(newSource).Contains("a\r\n b"); @@ -42,7 +54,7 @@ public async Task ReplacementUsesFileEol() public async Task AlreadyAppliedWhenLiteralMatches() { var source = Method(" await Snapshot(\"same\");"); - var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Set, "\"same\"", "same", out _, out _); + var status = TryApply(source, 5, InlinePatchMode.Set, "\"same\"", "same", out _, out _); await Assert.That(status).IsEqualTo(PatchStatus.AlreadyApplied); } @@ -51,7 +63,7 @@ public async Task ShiftedLinesStillFound() { var padding = string.Concat(Enumerable.Repeat(" // padding\n", 30)); var source = Method(padding + " await Snapshot(\"old\");"); - var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Set, "\"old\"", "new", out var newSource, out _); + var status = TryApply(source, 5, InlinePatchMode.Set, "\"old\"", "new", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).DoesNotContain("\"old\""); } @@ -63,7 +75,7 @@ public async Task DuplicateLiteralsPicksNearestToHint() "await A().Snapshot(\"dup\");\n" + string.Concat(Enumerable.Repeat("// filler\n", 10)) + "await B().Snapshot(\"dup\");\n"; - var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 12, InlinePatchMode.Set, "\"dup\"", "new", out var newSource, out _); + var status = TryApply(source, 12, InlinePatchMode.Set, "\"dup\"", "new", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); // First occurrence untouched, second replaced await Assert.That(newSource).Contains("A().Snapshot(\"dup\")"); @@ -95,7 +107,7 @@ public async Task DuplicateLiteralsPicksNearestToHintFirst() { var source = TwoCallSites("\"dup\"", "\"dup\""); - var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 4, InlinePatchMode.Set, "\"dup\"", "new", out var newSource, out _); + var status = TryApply(source, 4, InlinePatchMode.Set, "\"dup\"", "new", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); var (a, b) = Segments(newSource); @@ -120,7 +132,7 @@ public async Task EquidistantDuplicatesPreferAtOrAfterHint() "}"); // Line 5 is equidistant from the sites on lines 4 and 6 - var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Set, "\"dup\"", "new", out var newSource, out _); + var status = TryApply(source, 5, InlinePatchMode.Set, "\"dup\"", "new", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); var (a, b) = Segments(newSource); @@ -135,8 +147,8 @@ public async Task SequentialPatchesOfIdenticalLiteralsSameContent() { var source = TwoCallSites("\"old\"", "\"old\""); - var first = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 4, InlinePatchMode.Set, "\"old\"", "new", out var afterFirst, out _); - var second = InlinePatcher.TryApply(SourceLanguage.CSharp, afterFirst, 7, InlinePatchMode.Set, "\"old\"", "new", out var afterSecond, out var reason); + var first = TryApply(source, 4, InlinePatchMode.Set, "\"old\"", "new", out var afterFirst, out _); + var second = TryApply(afterFirst, 7, InlinePatchMode.Set, "\"old\"", "new", out var afterSecond, out var reason); await Assert.That(first).IsEqualTo(PatchStatus.Applied); await Assert.That(second).IsEqualTo(PatchStatus.Applied); @@ -152,8 +164,8 @@ public async Task SequentialPatchesOfIdenticalLiteralsDifferentContent() { var source = TwoCallSites("\"old\"", "\"old\""); - var first = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 4, InlinePatchMode.Set, "\"old\"", "newA", out var afterFirst, out _); - var second = InlinePatcher.TryApply(SourceLanguage.CSharp, afterFirst, 7, InlinePatchMode.Set, "\"old\"", "newB", out var afterSecond, out _); + var first = TryApply(source, 4, InlinePatchMode.Set, "\"old\"", "newA", out var afterFirst, out _); + var second = TryApply(afterFirst, 7, InlinePatchMode.Set, "\"old\"", "newB", out var afterSecond, out _); await Assert.That(first).IsEqualTo(PatchStatus.Applied); await Assert.That(second).IsEqualTo(PatchStatus.Applied); @@ -172,9 +184,9 @@ public async Task SecondPatchSurvivesLineShiftFromTheFirst() { var source = TwoCallSites("\"old\"", "\"old\""); - InlinePatcher.TryApply(SourceLanguage.CSharp, source, 4, InlinePatchMode.Set, "\"old\"", "line1\nline2\nline3", out var afterFirst, out _); + TryApply(source, 4, InlinePatchMode.Set, "\"old\"", "line1\nline2\nline3", out var afterFirst, out _); var lineShift = afterFirst.Split('\n').Length - source.Split('\n').Length; - var second = InlinePatcher.TryApply(SourceLanguage.CSharp, afterFirst, 7, InlinePatchMode.Set, "\"old\"", "newB", out var afterSecond, out _); + var second = TryApply(afterFirst, 7, InlinePatchMode.Set, "\"old\"", "newB", out var afterSecond, out _); await Assert.That(lineShift).IsGreaterThan(0); await Assert.That(second).IsEqualTo(PatchStatus.Applied); @@ -191,7 +203,7 @@ public async Task ReapplyingWithIdenticalLiteralsIsAlreadyApplied() { var source = TwoCallSites("\"new\"", "\"old\""); - var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 4, InlinePatchMode.Set, "\"gone\"", "new", out _, out _); + var status = TryApply(source, 4, InlinePatchMode.Set, "\"gone\"", "new", out _, out _); await Assert.That(status).IsEqualTo(PatchStatus.AlreadyApplied); } @@ -202,7 +214,7 @@ public async Task ExpressionGoneAndLiteralMatchesIsAlreadyApplied() // The other TFM already applied: the old expression is gone, // and the current argument renders to the new content. var source = Method(" await Snapshot(\"new\");"); - var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Set, "\"old-gone\"", "new", out _, out _); + var status = TryApply(source, 5, InlinePatchMode.Set, "\"old-gone\"", "new", out _, out _); await Assert.That(status).IsEqualTo(PatchStatus.AlreadyApplied); } @@ -210,7 +222,7 @@ public async Task ExpressionGoneAndLiteralMatchesIsAlreadyApplied() public async Task ExpressionGoneAndLiteralDiffersIsNotFound() { var source = Method(" await Snapshot(\"different\");"); - var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Set, "\"old-gone\"", "new", out _, out var reason); + var status = TryApply(source, 5, InlinePatchMode.Set, "\"old-gone\"", "new", out _, out var reason); await Assert.That(status).IsEqualTo(PatchStatus.NotFound); await Assert.That(reason).Contains("Re-run the test"); } @@ -219,7 +231,7 @@ public async Task ExpressionGoneAndLiteralDiffersIsNotFound() public async Task InsertIntoEmptyArgumentList() { var source = Method(" await Snapshot();"); - var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Set, null, "new", out var newSource, out _); + var status = TryApply(source, 5, InlinePatchMode.Set, null, "new", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).Contains("await Snapshot(\"new\");"); } @@ -228,7 +240,7 @@ public async Task InsertIntoEmptyArgumentList() public async Task InsertReplacesNullArgument() { var source = Method(" await Snapshot(null, file, line);"); - var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Set, null, "new", out var newSource, out _); + var status = TryApply(source, 5, InlinePatchMode.Set, null, "new", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).Contains("await Snapshot(\"new\", file, line);"); } @@ -237,7 +249,7 @@ public async Task InsertReplacesNullArgument() public async Task InsertBeforeAnotherNamedArgument() { var source = Method(" await Snapshot(file: myFile);"); - var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Set, null, "new", out var newSource, out _); + var status = TryApply(source, 5, InlinePatchMode.Set, null, "new", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).Contains("await Snapshot(expected: \"new\", file: myFile);"); } @@ -246,7 +258,7 @@ public async Task InsertBeforeAnotherNamedArgument() public async Task NullOriginalWithDifferingLiteralIsNotFound() { var source = Method(" await Snapshot(\"different\");"); - var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Set, null, "new", out _, out var reason); + var status = TryApply(source, 5, InlinePatchMode.Set, null, "new", out _, out var reason); await Assert.That(status).IsEqualTo(PatchStatus.NotFound); await Assert.That(reason).Contains("different expected argument"); } @@ -255,15 +267,92 @@ public async Task NullOriginalWithDifferingLiteralIsNotFound() public async Task NullOriginalWithEqualLiteralIsAlreadyApplied() { var source = Method(" await Snapshot(\"new\");"); - var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Set, null, "new", out _, out _); + var status = TryApply(source, 5, InlinePatchMode.Set, null, "new", out _, out _); await Assert.That(status).IsEqualTo(PatchStatus.AlreadyApplied); } + // The value anchor is not F# specific: a C# producer that sends one gets the same locating, + // and the expression wins where both arrived, being what the source actually says + [Test] + public async Task ValueAnchorLocatesTheCall() + { + var source = Method(" await Snapshot(\"old\");"); + + var status = TryApply(source, 5, InlinePatchMode.Set, null, "new", out var newSource, out _, originalValue: "old"); + + await Assert.That(status).IsEqualTo(PatchStatus.Applied); + await Assert.That(newSource).Contains("await Snapshot(\"new\");"); + } + + // Identical snapshots in two tests, and a hint that has drifted onto the wrong one. The + // expression matches both, so the member is what says which test the patch came from + [Test] + public async Task MemberNameBeatsAStaleHint() + { + var source = TwoCallSites("\"dup\"", "\"dup\""); + + var status = TryApply(source, 4, InlinePatchMode.Set, "\"dup\"", "new", out var newSource, out _, memberName: "B"); + + await Assert.That(status).IsEqualTo(PatchStatus.Applied); + var (a, b) = Segments(newSource); + await Assert.That(a).Contains("\"dup\""); + await Assert.That(b).Contains("new"); + } + + // The recorded line is tried before the member, so two snapshots in one method stay apart + [Test] + public async Task RecordedLineWinsOverTheMemberDeclaration() + { + var source = string.Join( + "\n", + "class Tests", + "{", + " async Task Test()", + " {", + " await Verify(a).Snapshot(\"dup\");", + " await Verify(b).Snapshot(\"dup\");", + " }", + "}"); + + var status = TryApply(source, 6, InlinePatchMode.Set, "\"dup\"", "new", out var newSource, out _, memberName: "Test"); + + await Assert.That(status).IsEqualTo(PatchStatus.Applied); + await Assert.That(newSource).Contains("Verify(a).Snapshot(\"dup\")"); + await Assert.That(newSource).Contains("Verify(b).Snapshot(\"new\")"); + } + + // The test was renamed since the run, so there is no declaration to search from + [Test] + public async Task UnknownMemberNameFallsBackToTheHint() + { + var source = TwoCallSites("\"dup\"", "\"dup\""); + + var status = TryApply(source, 4, InlinePatchMode.Set, "\"dup\"", "new", out var newSource, out _, memberName: "GoneAway"); + + await Assert.That(status).IsEqualTo(PatchStatus.Applied); + var (a, b) = Segments(newSource); + await Assert.That(a).Contains("new"); + await Assert.That(b).Contains("\"dup\""); + } + + [Test] + public async Task ExpressionWinsOverValue() + { + var source = TwoCallSites("\"a\"", "\"b\""); + + var status = TryApply(source, 4, InlinePatchMode.Set, "\"b\"", "new", out var newSource, out _, originalValue: "a"); + + await Assert.That(status).IsEqualTo(PatchStatus.Applied); + var (a, b) = Segments(newSource); + await Assert.That(a).Contains("\"a\""); + await Assert.That(b).Contains("new"); + } + [Test] public async Task NoCallFound() { var source = Method(" await Verify(value);"); - var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Set, null, "new", out _, out var reason); + var status = TryApply(source, 5, InlinePatchMode.Set, null, "new", out _, out var reason); await Assert.That(status).IsEqualTo(PatchStatus.NotFound); await Assert.That(reason).Contains("Could not find a Snapshot call"); } @@ -272,7 +361,7 @@ public async Task NoCallFound() public async Task PartialTokenIsNotMatched() { var source = Method(" await MySnapshotHelper(value);"); - var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Set, null, "new", out _, out _); + var status = TryApply(source, 5, InlinePatchMode.Set, null, "new", out _, out _); await Assert.That(status).IsEqualTo(PatchStatus.NotFound); } @@ -281,7 +370,7 @@ public async Task AppendToABareVerify() { var source = Method(" await Verify(value);"); - var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Append, null, "new", out var newSource, out _); + var status = TryApply(source, 5, InlinePatchMode.Append, null, "new", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).Contains( @@ -295,7 +384,7 @@ public async Task AppendMultiLineContent() { var source = Method(" await Verify(value);"); - var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Append, null, "a\nb", out var newSource, out _); + var status = TryApply(source, 5, InlinePatchMode.Append, null, "a\nb", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).Contains( @@ -316,7 +405,7 @@ public async Task AppendGoesAfterAnExistingChain() " .UseDirectory(\"snapshots\")\n" + " .ScrubLinesContaining(\"x\");"); - var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Append, null, "new", out var newSource, out _); + var status = TryApply(source, 5, InlinePatchMode.Append, null, "new", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).Contains( @@ -329,7 +418,7 @@ public async Task AppendToAnEntryPointOverload() { var source = Method(" await VerifyXml(value);"); - var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Append, null, "new", out var newSource, out _); + var status = TryApply(source, 5, InlinePatchMode.Append, null, "new", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).Contains("await VerifyXml(value)\n .Snapshot(\"new\");"); @@ -346,7 +435,7 @@ public async Task AppendToAMultiLineVerifyCall() " value\n" + " });"); - var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Append, null, "new", out var newSource, out _); + var status = TryApply(source, 5, InlinePatchMode.Append, null, "new", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).Contains(" })\n .Snapshot(\"new\");"); @@ -357,7 +446,7 @@ public async Task AppendUsesTheFileEol() { var source = Method(" await Verify(value);").Replace("\n", "\r\n"); - var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Append, null, "a\nb", out var newSource, out _); + var status = TryApply(source, 5, InlinePatchMode.Append, null, "a\nb", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await AssertEolConsistent(newSource, crlf); @@ -368,7 +457,7 @@ public async Task AppendIsRefusedWhenOneIsAlreadyChained() { var source = Method(" await Verify(value)\n .Snapshot(\"already\");"); - var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Append, null, "new", out _, out var reason); + var status = TryApply(source, 5, InlinePatchMode.Append, null, "new", out _, out var reason); await Assert.That(status).IsEqualTo(PatchStatus.NotFound); await Assert.That(reason).Contains("already has a Snapshot call"); @@ -379,7 +468,7 @@ public async Task AppendWithNoVerifyCall() { var source = Method(" await Something(value);"); - var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Append, null, "new", out _, out var reason); + var status = TryApply(source, 5, InlinePatchMode.Append, null, "new", out _, out var reason); await Assert.That(status).IsEqualTo(PatchStatus.NotFound); await Assert.That(reason).Contains("Could not find a Verify call"); @@ -391,7 +480,7 @@ public async Task AppendSkipsAVerifyOnAnotherReceiver() { var source = Method(" Assert.Empty(ContentValidation.Verify(value));"); - var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Append, null, "new", out _, out var reason); + var status = TryApply(source, 5, InlinePatchMode.Append, null, "new", out _, out var reason); await Assert.That(status).IsEqualTo(PatchStatus.NotFound); await Assert.That(reason).Contains("Could not find a Verify call"); @@ -402,7 +491,7 @@ public async Task AppendSkipsAVerifyOnAnInstance() { var source = Method(" mock.VerifyAll();"); - var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Append, null, "new", out _, out var reason); + var status = TryApply(source, 5, InlinePatchMode.Append, null, "new", out _, out var reason); await Assert.That(status).IsEqualTo(PatchStatus.NotFound); await Assert.That(reason).Contains("Could not find a Verify call"); @@ -414,7 +503,7 @@ public async Task AppendPrefersTheEntryPointOverANestedHelper() { var source = Method(" await Verify(ContentValidation.Verify(value));"); - var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Append, null, "new", out var newSource, out _); + var status = TryApply(source, 5, InlinePatchMode.Append, null, "new", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).Contains( @@ -427,7 +516,7 @@ public async Task AppendToAVerifierQualifiedCall() { var source = Method(" await Verifier.Verify(value);"); - var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Append, null, "new", out var newSource, out _); + var status = TryApply(source, 5, InlinePatchMode.Append, null, "new", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).Contains("await Verifier.Verify(value)\n .Snapshot(\"new\");"); @@ -438,7 +527,7 @@ public async Task AppendToAThisQualifiedCall() { var source = Method(" await this.Verify(value);"); - var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Append, null, "new", out var newSource, out _); + var status = TryApply(source, 5, InlinePatchMode.Append, null, "new", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).Contains("await this.Verify(value)\n .Snapshot(\"new\");"); @@ -453,7 +542,7 @@ public async Task RemoveTakesTheWholeLine() " old\n" + " \"\"\");"); - var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 6, InlinePatchMode.Remove, null, "", out var newSource, out _); + var status = TryApply(source, 6, InlinePatchMode.Remove, null, "", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).IsEqualTo(Method(" await Verify(value);")); @@ -467,7 +556,7 @@ public async Task RemoveLeavesTheRestOfTheChain() " .UseDirectory(\"snapshots\")\n" + " .Snapshot(\"old\");"); - var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 7, InlinePatchMode.Remove, null, "", out var newSource, out _); + var status = TryApply(source, 7, InlinePatchMode.Remove, null, "", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).IsEqualTo( @@ -481,7 +570,7 @@ public async Task RemoveFromASingleLineChain() { var source = Method(" await Verify(value).Snapshot(\"old\");"); - var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Remove, null, "", out var newSource, out _); + var status = TryApply(source, 5, InlinePatchMode.Remove, null, "", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).IsEqualTo(Method(" await Verify(value);")); @@ -492,7 +581,7 @@ public async Task RemoveWithCrlf() { var source = Method(" await Verify(value)\n .Snapshot(\"old\");").Replace("\n", "\r\n"); - var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 6, InlinePatchMode.Remove, null, "", out var newSource, out _); + var status = TryApply(source, 6, InlinePatchMode.Remove, null, "", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).IsEqualTo(Method(" await Verify(value);").Replace("\n", "\r\n")); @@ -503,7 +592,7 @@ public async Task RemovePicksTheSiteNearestTheHint() { var source = TwoCallSites("\"a\"", "\"b\""); - var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 7, InlinePatchMode.Remove, null, "", out var newSource, out _); + var status = TryApply(source, 7, InlinePatchMode.Remove, null, "", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); var (a, b) = Segments(newSource); @@ -516,7 +605,7 @@ public async Task RemoveWhenTheCallIsNotChained() { var source = Method(" await Snapshot(\"old\");"); - var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Remove, null, "", out _, out var reason); + var status = TryApply(source, 5, InlinePatchMode.Remove, null, "", out _, out var reason); await Assert.That(status).IsEqualTo(PatchStatus.NotFound); await Assert.That(reason).Contains("not a chained call"); @@ -527,7 +616,7 @@ public async Task RemoveWithNoSnapshotCall() { var source = Method(" await Verify(value);"); - var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Remove, null, "", out _, out var reason); + var status = TryApply(source, 5, InlinePatchMode.Remove, null, "", out _, out var reason); await Assert.That(status).IsEqualTo(PatchStatus.NotFound); await Assert.That(reason).Contains("Could not find a Snapshot call"); @@ -537,7 +626,7 @@ public async Task RemoveWithNoSnapshotCall() public async Task TabIndentedFileUsesTabUnit() { var source = "class Tests\n{\n\tasync Task Test()\n\t{\n\t\tawait Snapshot();\n\t}\n}"; - var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Set, null, "a\nb", out var newSource, out _); + var status = TryApply(source, 5, InlinePatchMode.Set, null, "a\nb", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).Contains("Snapshot(\n\t\t\t\"\"\"\n\t\t\ta\n\t\t\tb\n\t\t\t\"\"\");"); } @@ -551,7 +640,7 @@ public async Task AppendToATabIndentedFile() { var source = TabMethod("\t\tawait Verify(value);"); - var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Append, null, "a\nb", out var newSource, out _); + var status = TryApply(source, 5, InlinePatchMode.Append, null, "a\nb", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).IsEqualTo( @@ -572,7 +661,7 @@ public async Task AppendToATabIndentedChain() "\t\tawait Verify(value)\n" + "\t\t\t.UseDirectory(\"snapshots\");"); - var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Append, null, "a\nb", out var newSource, out _); + var status = TryApply(source, 5, InlinePatchMode.Append, null, "a\nb", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).IsEqualTo( @@ -595,7 +684,7 @@ public async Task RemoveFromATabIndentedChain() "\t\t\t\told\n" + "\t\t\t\t\"\"\");"); - var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 6, InlinePatchMode.Remove, null, "", out var newSource, out _); + var status = TryApply(source, 6, InlinePatchMode.Remove, null, "", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).IsEqualTo(TabMethod("\t\tawait Verify(value);")); @@ -624,9 +713,9 @@ public async Task MixedIndentFileUsesTheSiteIndent() { var source = MixedIndentSites(); - var spaces = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Set, "\"a\"", "a1\na2", out var afterSpaces, out _); + var spaces = TryApply(source, 5, InlinePatchMode.Set, "\"a\"", "a1\na2", out var afterSpaces, out _); // The first patch turned one line into five, so the second site has moved down four - var tabs = InlinePatcher.TryApply(SourceLanguage.CSharp, afterSpaces, 14, InlinePatchMode.Set, "\"b\"", "b1\nb2", out var afterTabs, out _); + var tabs = TryApply(afterSpaces, 14, InlinePatchMode.Set, "\"b\"", "b1\nb2", out var afterTabs, out _); await Assert.That(spaces).IsEqualTo(PatchStatus.Applied); await Assert.That(tabs).IsEqualTo(PatchStatus.Applied); @@ -664,7 +753,7 @@ public async Task LineIndentedWithTabsThenSpaces() { var source = "class Tests\n{\n\tasync Task Test() =>\n\t Verify(value).Snapshot(\"old\");\n}"; - var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 4, InlinePatchMode.Set, "\"old\"", "a\nb", out var newSource, out _); + var status = TryApply(source, 4, InlinePatchMode.Set, "\"old\"", "a\nb", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).Contains( @@ -684,7 +773,7 @@ public async Task TwoSpaceFileUsesATwoSpaceUnit() { var source = TwoSpaceMethod(" await Snapshot(\"old\");"); - var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Set, "\"old\"", "a\nb", out var newSource, out _); + var status = TryApply(source, 5, InlinePatchMode.Set, "\"old\"", "a\nb", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).IsEqualTo( @@ -701,7 +790,7 @@ public async Task AppendToATwoSpaceFile() { var source = TwoSpaceMethod(" await Verify(value);"); - var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Append, null, "a\nb", out var newSource, out _); + var status = TryApply(source, 5, InlinePatchMode.Append, null, "a\nb", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).IsEqualTo( @@ -733,7 +822,7 @@ public async Task LiteralContentDoesNotSetTheUnit() $" Task T() => Snapshot({expression});", "}"); - var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 3, InlinePatchMode.Set, expression, "x\ny", out var newSource, out _); + var status = TryApply(source, 3, InlinePatchMode.Set, expression, "x\ny", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).IsEqualTo( @@ -755,7 +844,7 @@ public async Task FileWithNoIndentationFallsBackToFourSpaces() { var source = "class Tests\n{\nasync Task Test() =>\nSnapshot(\"old\");\n}"; - var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 4, InlinePatchMode.Set, "\"old\"", "a\nb", out var newSource, out _); + var status = TryApply(source, 4, InlinePatchMode.Set, "\"old\"", "a\nb", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).Contains( @@ -781,7 +870,7 @@ public async Task LiteralWithMismatchedIndentCharactersIsNotPatched() " \"\"\");", "}"); - var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 4, InlinePatchMode.Set, null, "new", out _, out var reason); + var status = TryApply(source, 4, InlinePatchMode.Set, null, "new", out _, out var reason); await Assert.That(status).IsEqualTo(PatchStatus.NotFound); await Assert.That(reason).Contains("is not a string literal"); @@ -791,7 +880,7 @@ public async Task LiteralWithMismatchedIndentCharactersIsNotPatched() public async Task HintBeyondEndOfFile() { var source = "await Snapshot();"; - var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 500, InlinePatchMode.Set, null, "new", out var newSource, out _); + var status = TryApply(source, 500, InlinePatchMode.Set, null, "new", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).Contains("Snapshot(\"new\");"); } @@ -801,7 +890,7 @@ public async Task LfExpressionFoundInCrlfFile() { var source = Method($" await Snapshot({rawOld.Replace("\n", "\n ")});").Replace("\n", "\r\n"); var expression = rawOld.Replace("\n", "\n "); - var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Set, expression, "new", out var newSource, out _); + var status = TryApply(source, 5, InlinePatchMode.Set, expression, "new", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).DoesNotContain("old"); } @@ -811,7 +900,7 @@ public async Task OutsideSpanIsCharacterIdentical() { var body = " await Snapshot(\"old\");"; var source = Method(body); - InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Set, "\"old\"", "new", out var newSource, out _); + TryApply(source, 5, InlinePatchMode.Set, "\"old\"", "new", out var newSource, out _); var prefix = source.Substring(0, source.IndexOf("\"old\"", StringComparison.Ordinal)); var suffix = source.Substring(source.IndexOf("\"old\"", StringComparison.Ordinal) + 5); await Assert.That(newSource.StartsWith(prefix)).IsTrue(); @@ -879,7 +968,7 @@ public async Task EolCombinations(string fileEol, string expressionEol, string c var expression = BuildExpression(expressionEol); var content = "new1" + contentEol + "new2"; - var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Set, expression, content, out var newSource, out var reason); + var status = TryApply(source, 5, InlinePatchMode.Set, expression, content, out var newSource, out var reason); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(reason).IsEmpty(); @@ -908,7 +997,7 @@ public async Task EolCombinationsForInsert(string fileEol, string contentEol) "}"); var content = "new1" + contentEol + "new2"; - var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Set, null, content, out var newSource, out _); + var status = TryApply(source, 5, InlinePatchMode.Set, null, content, out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).Contains("new1"); @@ -922,7 +1011,7 @@ public async Task LoneCarriageReturnInContentIsNormalized() var source = BuildMultiLineSource(lf); var expression = BuildExpression(lf); - var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Set, expression, "new1\rnew2", out var newSource, out _); + var status = TryApply(source, 5, InlinePatchMode.Set, expression, "new1\rnew2", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).DoesNotContain("\r"); @@ -947,7 +1036,7 @@ public async Task MixedEolFileLeavesUntouchedRegionsAlone() var suffix = "\r\n// trailing\n// mixed tail\n"; var source = prefix + body + suffix; - var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 7, InlinePatchMode.Set, "\"old\"", "new1\nnew2", out var newSource, out _); + var status = TryApply(source, 7, InlinePatchMode.Set, "\"old\"", "new1\nnew2", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); // Untouched regions keep their original endings byte for byte @@ -960,7 +1049,7 @@ public async Task MixedEolFileLeavesUntouchedRegionsAlone() [Test] public async Task SingleLineFileWithNoNewlines() { - var status = InlinePatcher.TryApply(SourceLanguage.CSharp, "await Snapshot(\"old\");", 1, InlinePatchMode.Set, "\"old\"", "new", out var newSource, out _); + var status = TryApply("await Snapshot(\"old\");", 1, InlinePatchMode.Set, "\"old\"", "new", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).Contains("new"); @@ -973,7 +1062,7 @@ public async Task SameLiteralInTheVerifyArgumentIsNotPatched() { var source = Method(" await Verify(\"same\").Snapshot(\"same\");"); - var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Set, "\"same\"", "changed", out var newSource, out _); + var status = TryApply(source, 5, InlinePatchMode.Set, "\"same\"", "changed", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).Contains("await Verify(\"same\").Snapshot(\"changed\");"); @@ -985,7 +1074,7 @@ public async Task PrefixedLiteralIsNotPatchedThroughItsQuote() { var source = Method(" await Verify(x).Snapshot(@\"old\");"); - var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Set, "\"old\"", "new", out _, out var reason); + var status = TryApply(source, 5, InlinePatchMode.Set, "\"old\"", "new", out _, out var reason); await Assert.That(status).IsEqualTo(PatchStatus.NotFound); await Assert.That(reason).Contains("Re-run the test"); @@ -996,7 +1085,7 @@ public async Task SuffixedLiteralIsNotPatchedThroughItsQuote() { var source = Method(" await Verify(x).Snapshot(\"old\"u8);"); - var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Set, "\"old\"", "new", out _, out var reason); + var status = TryApply(source, 5, InlinePatchMode.Set, "\"old\"", "new", out _, out var reason); await Assert.That(status).IsEqualTo(PatchStatus.NotFound); await Assert.That(reason).Contains("not a string literal"); @@ -1014,7 +1103,7 @@ public async Task CommentedOutCallIsSkipped() " await Verify(x).Snapshot();", "}"); - var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 3, InlinePatchMode.Set, null, "new", out var newSource, out _); + var status = TryApply(source, 3, InlinePatchMode.Set, null, "new", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).Contains(" // await Verify(x).Snapshot(\"doc example\");\n"); @@ -1028,7 +1117,7 @@ public async Task CallInsideAStringIsSkipped() " var text = \"await Snapshot(\\\"x\\\")\";\n" + " await Verify(x).Snapshot();"); - var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Set, null, "new", out var newSource, out _); + var status = TryApply(source, 5, InlinePatchMode.Set, null, "new", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).Contains("var text = \"await Snapshot(\\\"x\\\")\";\n"); @@ -1047,7 +1136,7 @@ public async Task SnapshotDeclarationIsNotMistakenForACall() " task;", "}"); - var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 3, InlinePatchMode.Set, null, "new", out _, out var reason); + var status = TryApply(source, 3, InlinePatchMode.Set, null, "new", out _, out var reason); await Assert.That(status).IsEqualTo(PatchStatus.NotFound); await Assert.That(reason).Contains("Could not find a Snapshot call"); @@ -1063,7 +1152,7 @@ public async Task AppendSkipsAVerifyPrefixedDeclaration() " Task VerifyThing(string value) => Verify(value);", "}"); - var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 3, InlinePatchMode.Append, null, "new", out var newSource, out _); + var status = TryApply(source, 3, InlinePatchMode.Append, null, "new", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).Contains( @@ -1079,7 +1168,7 @@ public async Task AppendGoesAfterACommentInTheChain() " await Verify(value) // note\n" + " .UseDirectory(\"snapshots\");"); - var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Append, null, "new", out var newSource, out _); + var status = TryApply(source, 5, InlinePatchMode.Append, null, "new", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).Contains( @@ -1094,7 +1183,7 @@ public async Task LiteralInACommentIsNotPatched() " // was \"old\"\n" + " await Verify(x).Snapshot(\"old\");"); - var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Set, "\"old\"", "new", out var newSource, out _); + 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("// was \"old\"\n"); @@ -1114,7 +1203,7 @@ public async Task LiteralInAnotherMethodIsNotPatched() " await Verify(x).Snapshot(\"old\");", "}"); - var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 3, InlinePatchMode.Set, "\"old\"", "new", out var newSource, out _); + var status = TryApply(source, 3, InlinePatchMode.Set, "\"old\"", "new", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).Contains("void Helper() => Log(\"old\");"); @@ -1138,7 +1227,7 @@ public async Task EmptyOriginalIsNotMatchedInsideARawDelimiter() " Verify(b).Snapshot(\"\");", "}"); - var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 7, InlinePatchMode.Set, "\"\"", "new", out var newSource, out _); + var status = TryApply(source, 7, InlinePatchMode.Set, "\"\"", "new", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).Contains(" content\n \"\"\");"); @@ -1150,7 +1239,7 @@ public async Task GenericSnapshotCall() { var source = Method(" await Verify(x).Snapshot();"); - var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Set, null, "new", out var newSource, out _); + var status = TryApply(source, 5, InlinePatchMode.Set, null, "new", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).Contains(".Snapshot(\"new\");"); @@ -1161,7 +1250,7 @@ public async Task AppendToAGenericVerify() { var source = Method(" await Verify(value);"); - var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Append, null, "new", out var newSource, out _); + var status = TryApply(source, 5, InlinePatchMode.Append, null, "new", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).Contains("await Verify(value)\n .Snapshot(\"new\");"); @@ -1172,7 +1261,7 @@ public async Task CommentInTheArgumentListIsNotTheArgument() { var source = Method(" await Verify(x).Snapshot(/* keep */);"); - var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Set, null, "new", out var newSource, out _); + var status = TryApply(source, 5, InlinePatchMode.Set, null, "new", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).Contains("Snapshot(/* keep */\"new\");"); @@ -1183,7 +1272,7 @@ public async Task CommentAfterTheArgumentIsKept() { var source = Method(" await Verify(x).Snapshot(\"old\" /* why */);"); - var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 5, InlinePatchMode.Set, "\"old\"", "new", out var newSource, out _); + 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("\"new\" /* why */);"); @@ -1198,7 +1287,7 @@ public async Task RemoveLeavesALineCommentAboveIntact() " // note\n" + " .Snapshot(\"old\");"); - var status = InlinePatcher.TryApply(SourceLanguage.CSharp, source, 7, InlinePatchMode.Remove, null, "", out var newSource, out _); + var status = TryApply(source, 7, InlinePatchMode.Remove, null, "", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).IsEqualTo( diff --git a/src/DiffEngine/Inline/InlineApplier.cs b/src/DiffEngine/Inline/InlineApplier.cs index 27392db7..9b2dd380 100644 --- a/src/DiffEngine/Inline/InlineApplier.cs +++ b/src/DiffEngine/Inline/InlineApplier.cs @@ -109,6 +109,8 @@ static InlineApplyResult LockedApply(string fullPath, InlinePatch patch, string patch.LineHint, patch.Mode, patch.OriginalExpression, + patch.OriginalValue, + patch.MemberName, newContent, out var newSource, out var failReason); diff --git a/src/DiffEngine/Inline/InlinePatch.cs b/src/DiffEngine/Inline/InlinePatch.cs index cbcc143b..c1eff1ef 100644 --- a/src/DiffEngine/Inline/InlinePatch.cs +++ b/src/DiffEngine/Inline/InlinePatch.cs @@ -24,6 +24,34 @@ public InlinePatch( Mode = mode; } + /// + /// The runtime value of the previous expected argument, for a producer whose language does not + /// supply . Null when there was no previous argument. + /// + /// Both are anchors for the same purpose - identify the call whose expected argument is still + /// what the test run saw, so a file that shifted still patches and one whose call site changed + /// reports rather than corrupts. The expression is used where it exists, being what the source + /// actually says; the value is a parse away from it, and is what F# leaves as the only option, + /// since its compiler does not implement . + /// + /// + public string? OriginalValue { get; set; } + + /// + /// The member the verify call sits in, from . Null when + /// the producer does not supply one. + /// + /// Not an identity - a member holds any number of snapshots - but a locality. Where the + /// recorded line no longer lands on a call, the search moves to this member's declaration + /// rather than fanning out from a line that has since become someone else's, which is what + /// keeps a stale hint from finding an identical snapshot in the test next door. Unlike + /// this is the name in the source, not a display name: a test renamed + /// through UseMethodName, or named by a framework that takes a string, still declares itself + /// here as whatever the compiler saw. + /// + /// + public string? MemberName { get; set; } + /// /// Full path to the source file. Its extension decides the language the literal is written in /// (). @@ -85,6 +113,8 @@ public bool Matches(InlinePatch other) => SourceFile == other.SourceFile && LineHint == other.LineHint && OriginalExpression == other.OriginalExpression && + OriginalValue == other.OriginalValue && + MemberName == other.MemberName && NewContent == other.NewContent && Mode == other.Mode; } diff --git a/src/DiffEngine/Inline/InlinePatchFile.cs b/src/DiffEngine/Inline/InlinePatchFile.cs index f2c8c24c..b9f2bc30 100644 --- a/src/DiffEngine/Inline/InlinePatchFile.cs +++ b/src/DiffEngine/Inline/InlinePatchFile.cs @@ -28,7 +28,16 @@ public static string Build(InlinePatch patch) var testName = patch.TestName is null ? "" : Convert.ToBase64String(Encoding.UTF8.GetBytes(patch.TestName)); - return $"version: 2\nsourceFile: {patch.SourceFile}\nlineHint: {patch.LineHint}\nmode: {patch.Mode}\noriginalExpression: {expression}\nnewContent: {content}\ntestName: {testName}\nframework: {patch.Framework}\n"; + // Past the six fixed lines, so a reader that predates them skips them rather than + // rejecting the payload. That tolerance is why the version does not move for an added + // field. Member names are identifiers, but base64 like the rest of the added fields + var value = patch.OriginalValue is null + ? "" + : Convert.ToBase64String(Encoding.UTF8.GetBytes(patch.OriginalValue)); + var memberName = patch.MemberName is null + ? "" + : Convert.ToBase64String(Encoding.UTF8.GetBytes(patch.MemberName)); + return $"version: 2\nsourceFile: {patch.SourceFile}\nlineHint: {patch.LineHint}\nmode: {patch.Mode}\noriginalExpression: {expression}\nnewContent: {content}\ntestName: {testName}\nframework: {patch.Framework}\noriginalValue: {value}\nmemberName: {memberName}\n"; } public static bool TryRead(string path, [NotNullWhen(true)] out InlinePatch? patch) @@ -80,6 +89,8 @@ public static bool TryParse(string text, [NotNullWhen(true)] out InlinePatch? pa string content; string? testName = null; string? framework = null; + string? originalValue = null; + string? memberName = null; try { expression = expressionBase64.Length == 0 @@ -102,6 +113,22 @@ public static bool TryParse(string text, [NotNullWhen(true)] out InlinePatch? pa if (TryValue(lines[index], "framework", out var frameworkValue)) { framework = frameworkValue.Length == 0 ? null : frameworkValue; + continue; + } + + if (TryValue(lines[index], "originalValue", out var valueBase64)) + { + originalValue = valueBase64.Length == 0 + ? null + : Encoding.UTF8.GetString(Convert.FromBase64String(valueBase64)); + continue; + } + + if (TryValue(lines[index], "memberName", out var memberNameBase64)) + { + memberName = memberNameBase64.Length == 0 + ? null + : Encoding.UTF8.GetString(Convert.FromBase64String(memberNameBase64)); } } } @@ -113,7 +140,9 @@ public static bool TryParse(string text, [NotNullWhen(true)] out InlinePatch? pa patch = new(sourceFile, lineHint, expression, content, mode) { TestName = testName, - Framework = framework + Framework = framework, + OriginalValue = originalValue, + MemberName = memberName }; return true; } diff --git a/src/DiffEngine/Inline/InlinePatcher.cs b/src/DiffEngine/Inline/InlinePatcher.cs index 4179b6da..4cc074c2 100644 --- a/src/DiffEngine/Inline/InlinePatcher.cs +++ b/src/DiffEngine/Inline/InlinePatcher.cs @@ -46,6 +46,8 @@ public static PatchStatus TryApply( int lineHint, InlinePatchMode mode, string? originalExpression, + string? originalValue, + string? memberName, string newContent, out string newSource, out string failReason) @@ -55,17 +57,18 @@ public static PatchStatus TryApply( var eol = DetectEol(source); var lineStarts = BuildLineStarts(source); var scan = language.Scan(source); + var memberLine = MemberLine(source, scan, lineStarts, lineHint, memberName); if (mode == InlinePatchMode.Remove) { - return TryRemove(source, scan, lineStarts, lineHint, ref newSource, ref failReason); + return TryRemove(source, scan, lineStarts, lineHint, memberLine, ref newSource, ref failReason); } var fileUnit = DetectIndentUnit(source, scan, lineStarts); if (mode == InlinePatchMode.Append) { - return TryAppend(source, scan, lineStarts, lineHint, newContent, eol, fileUnit, ref newSource, ref failReason); + return TryAppend(source, scan, lineStarts, lineHint, memberLine, newContent, eol, fileUnit, ref newSource, ref failReason); } if (!string.IsNullOrEmpty(originalExpression)) @@ -78,7 +81,7 @@ public static PatchStatus TryApply( // still unaccepted. // ReSharper disable once RedundantSuppressNullableWarningExpression var needle = NormalizeTo(originalExpression!, eol); - foreach (var (_, openParen) in FindCalls(source, scan, lineStarts, lineHint, methodName, false)) + foreach (var (_, openParen) in FindCalls(source, scan, lineStarts, lineHint, memberLine, methodName, false)) { if (!TryReadArguments(source, scan, openParen, out var expected) || !expected.Matches(source, needle)) @@ -98,10 +101,46 @@ public static PatchStatus TryApply( } // Expression gone: another process may have applied the same patch already - return InsertOrCheck(source, scan, lineStarts, lineHint, newContent, eol, fileUnit, alreadyOnly: true, ref newSource, ref failReason); + return InsertOrCheck(source, scan, lineStarts, lineHint, memberLine, newContent, eol, fileUnit, alreadyOnly: true, ref newSource, ref failReason); } - return InsertOrCheck(source, scan, lineStarts, lineHint, newContent, eol, fileUnit, alreadyOnly: false, ref newSource, ref failReason); + if (originalValue != null) + { + // Located by content again, but by what the argument means rather than by what it + // says. The same anchor for a producer whose language withholds the expression, and + // the same outcome when nothing matches: report, rather than rewrite whichever call + // the hint happens to land on. + var previous = SourceLanguage.NormalizeNewlines(originalValue); + foreach (var (_, openParen) in FindCalls(source, scan, lineStarts, lineHint, memberLine, methodName, false)) + { + if (!TryReadArguments(source, scan, openParen, out var expected) || + expected.IsAbsent || + expected.BlockedByName) + { + continue; + } + + var argument = source.Substring(expected.Start, expected.End - expected.Start); + if (!language.TryParse(argument, out var value) || + value != previous) + { + continue; + } + + if (previous == newContent) + { + return PatchStatus.AlreadyApplied; + } + + var rendered = RenderArgument(language, source, lineStarts, expected.Start, newContent, eol, fileUnit); + newSource = Splice(source, expected.Start, expected.End, rendered); + return PatchStatus.Applied; + } + + return InsertOrCheck(source, scan, lineStarts, lineHint, memberLine, newContent, eol, fileUnit, alreadyOnly: true, ref newSource, ref failReason); + } + + return InsertOrCheck(source, scan, lineStarts, lineHint, memberLine, newContent, eol, fileUnit, alreadyOnly: false, ref newSource, ref failReason); } static PatchStatus InsertOrCheck( @@ -109,6 +148,7 @@ static PatchStatus InsertOrCheck( SourceScan scan, List lineStarts, int lineHint, + int? memberLine, string newContent, string eol, string fileUnit, @@ -116,7 +156,7 @@ static PatchStatus InsertOrCheck( ref string newSource, ref string failReason) { - if (!TryFindCall(source, scan, lineStarts, lineHint, out var openParen)) + if (!TryFindCall(source, scan, lineStarts, lineHint, memberLine, out var openParen)) { failReason = $"Could not find a {methodName} call near line {lineHint}. The source may have changed since the test run. Re-run the test."; return PatchStatus.NotFound; @@ -278,13 +318,14 @@ static PatchStatus TryAppend( SourceScan scan, List lineStarts, int lineHint, + int? memberLine, string newContent, string eol, string fileUnit, ref string newSource, ref string failReason) { - if (!TryFindCall(source, scan, lineStarts, lineHint, verifyPrefix, true, out var nameStart, out var openParen)) + if (!TryFindCall(source, scan, lineStarts, lineHint, memberLine, verifyPrefix, true, out var nameStart, out var openParen)) { failReason = $"Could not find a {verifyPrefix} call near line {lineHint}. The source may have changed since the test run. Re-run the test."; return PatchStatus.NotFound; @@ -326,10 +367,11 @@ static PatchStatus TryRemove( SourceScan scan, List lineStarts, int lineHint, + int? memberLine, ref string newSource, ref string failReason) { - if (!TryFindCall(source, scan, lineStarts, lineHint, methodName, false, out var nameStart, out var openParen)) + if (!TryFindCall(source, scan, lineStarts, lineHint, memberLine, methodName, false, out var nameStart, out var openParen)) { failReason = $"Could not find a {methodName} call near line {lineHint}. The source may have changed since the test run. Re-run the test."; return PatchStatus.NotFound; @@ -459,20 +501,21 @@ static string LeadingWhitespace(string source, List lineStarts, int offset) return source.Substring(lineStart, index - lineStart); } - static bool TryFindCall(string source, SourceScan scan, List lineStarts, int lineHint, out int openParen) => - TryFindCall(source, scan, lineStarts, lineHint, methodName, false, out _, out openParen); + static bool TryFindCall(string source, SourceScan scan, List lineStarts, int lineHint, int? memberLine, out int openParen) => + TryFindCall(source, scan, lineStarts, lineHint, memberLine, methodName, false, out _, out openParen); static bool TryFindCall( string source, SourceScan scan, List lineStarts, int lineHint, + int? memberLine, string name, bool byPrefix, out int nameStart, out int openParen) { - foreach (var call in FindCalls(source, scan, lineStarts, lineHint, name, byPrefix)) + foreach (var call in FindCalls(source, scan, lineStarts, lineHint, memberLine, name, byPrefix)) { (nameStart, openParen) = call; return true; @@ -484,9 +527,21 @@ static bool TryFindCall( } /// - /// Locates calls by name, searching outward from the hint: hint, hint+1, hint-1, hint+2 and so - /// on. A tie goes to the line at or after the hint, because a file that moved under a pending + /// Locates calls by name: the recorded line first, then outward - line, line+1, line-1, line+2 + /// and so on. A tie goes to the line at or after, because a file that moved under a pending /// patch usually grew above the call rather than below it. + /// + /// , where the patch named a member the file still declares, does + /// two things. It bounds the search: a call above that declaration cannot be inside the member + /// the patch came from, whatever else recommends it, so an identical snapshot in the test + /// above is no longer reachable at all. And it becomes the origin of the outward walk, so a + /// hint gone stale fans out from the right test rather than from a line that now belongs to + /// another one. + /// + /// + /// The recorded line is still tried first, since a hint that lands on a call is the whole + /// point of having one, and it is what keeps two snapshots in the same method apart. + /// /// matches any identifier starting with , /// which is how the several Verify overloads are found with one search. /// @@ -495,67 +550,150 @@ static bool TryFindCall( SourceScan scan, List lineStarts, int lineHint, + int? memberLine, string name, bool byPrefix) { var lineCount = lineStarts.Count; - lineHint = Math.Min(Math.Max(lineHint, 1), lineCount); + lineHint = Clamp(lineHint, lineCount); + var floor = memberLine is null ? 1 : Clamp(memberLine.Value, lineCount); + var origin = memberLine is null ? lineHint : floor; + if (lineHint >= floor) + { + foreach (var call in CallsOnLine(source, scan, lineStarts, lineHint, name, byPrefix)) + { + yield return call; + } + } + for (var distance = 0; distance < lineCount; distance++) { var candidates = distance == 0 - ? new[] { lineHint } - : new[] { lineHint + distance, lineHint - distance }; + ? new[] { origin } + : new[] { origin + distance, origin - distance }; foreach (var line in candidates) { - if (line < 1 || line > lineCount) + if (line < floor || + line > lineCount || + // Already tried, and yielding it twice would have a caller that rejects the + // first reject it again rather than move on + line == lineHint) { continue; } - var start = lineStarts[line - 1]; - var end = line < lineCount ? lineStarts[line] : source.Length; - var index = start; - while (true) + foreach (var call in CallsOnLine(source, scan, lineStarts, line, name, byPrefix)) { - index = source.IndexOf(name, index, StringComparison.Ordinal); - if (index < 0 || index >= end) - { - break; - } + yield return call; + } + } + } + } - var identifierEnd = index + name.Length; - if (byPrefix) - { - while (identifierEnd < source.Length && - scan.IsIdentifierChar(source[identifierEnd])) - { - identifierEnd++; - } - } - else if (identifierEnd < source.Length && - scan.IsIdentifierChar(source[identifierEnd])) - { - index += name.Length; - continue; - } + static int Clamp(int line, int lineCount) => + Math.Min(Math.Max(line, 1), lineCount); - // In code, a whole token, an invocation rather than a declaration, and - // followed by an argument list. A commented out example passes none of these - if (scan.IsCode(index) && - StartsToken(source, scan, index) && - !scan.IsDeclaration(index) && - !(byPrefix && IsForeignReceiver(source, scan, index)) && - TrySkipToParen(source, scan, identifierEnd, out var paren)) - { - yield return (index, paren); - } + static IEnumerable<(int nameStart, int openParen)> CallsOnLine( + string source, + SourceScan scan, + List lineStarts, + int line, + string name, + bool byPrefix) + { + var lineCount = lineStarts.Count; + var start = lineStarts[line - 1]; + var end = line < lineCount ? lineStarts[line] : source.Length; + var index = start; + while (true) + { + index = source.IndexOf(name, index, StringComparison.Ordinal); + if (index < 0 || index >= end) + { + break; + } - index += name.Length; + var identifierEnd = index + name.Length; + if (byPrefix) + { + while (identifierEnd < source.Length && + scan.IsIdentifierChar(source[identifierEnd])) + { + identifierEnd++; } } + else if (identifierEnd < source.Length && + scan.IsIdentifierChar(source[identifierEnd])) + { + index += name.Length; + continue; + } + + // In code, a whole token, an invocation rather than a declaration, and + // followed by an argument list. A commented out example passes none of these + if (scan.IsCode(index) && + StartsToken(source, scan, index) && + !scan.IsDeclaration(index) && + !(byPrefix && IsForeignReceiver(source, scan, index)) && + TrySkipToParen(source, scan, identifierEnd, out var paren)) + { + yield return (index, paren); + } + + index += name.Length; } } + /// + /// Where the member a patch came from is declared, or null when it named none or the file no + /// longer declares it - a test renamed since the run, which leaves the hint as all there is. + /// + /// A member is not an identity, since it holds any number of snapshots, but it is a region: a + /// call above the declaration is not in it, and that is what bounds the search in + /// . + /// + /// + /// The declaration nearest the hint wins, so overloads and partials pick the plausible one. + /// + /// + static int? MemberLine(string source, SourceScan scan, List lineStarts, int lineHint, string? memberName) + { + if (string.IsNullOrEmpty(memberName)) + { + return null; + } + + var best = -1; + var index = 0; + while (true) + { + // ReSharper disable once RedundantSuppressNullableWarningExpression + index = source.IndexOf(memberName!, index, StringComparison.Ordinal); + if (index < 0) + { + break; + } + + var end = index + memberName!.Length; + if (scan.IsCode(index) && + StartsToken(source, scan, index) && + (end >= source.Length || !scan.IsIdentifierChar(source[end])) && + scan.IsDeclaration(index)) + { + var line = LineOf(lineStarts, index); + if (best < 0 || + Math.Abs(line - lineHint) < Math.Abs(best - lineHint)) + { + best = line; + } + } + + index = end; + } + + return best < 0 ? null : best; + } + static bool StartsToken(string source, SourceScan scan, int index) => index == 0 || !scan.IsIdentifierChar(source[index - 1]); diff --git a/src/DiffEngine/Inline/SourceLanguage.cs b/src/DiffEngine/Inline/SourceLanguage.cs index 05d49780..908c14fe 100644 --- a/src/DiffEngine/Inline/SourceLanguage.cs +++ b/src/DiffEngine/Inline/SourceLanguage.cs @@ -107,11 +107,12 @@ internal string IndentFor(string statementIndent, string ownLineIndent) => /// Whether a patch from this language carries the source text of the expected argument, which /// is to say whether the compiler honours . /// - /// That expression is what a patch is anchored to: the call whose argument is still the text - /// the test run saw is the call to rewrite, whatever moved around it. Without one, all a patch - /// has is a line hint and the search outward from it, and a literal that differs has to be - /// taken as the snapshot that changed rather than as a conflict - the alternative being that - /// an inline snapshot can be accepted once and never updated. + /// An expression is what a patch is anchored to: the call whose argument is still what the + /// test run saw is the call to rewrite, whatever moved around it. A producer in a language + /// without one should send instead, which anchors just + /// as well. This flag is for the case where neither arrived: with no anchor at all, a literal + /// that differs has to be taken as the snapshot that changed rather than as a conflict, or an + /// inline snapshot could be accepted once and never updated. /// /// internal virtual bool SuppliesArgumentExpressions => true; diff --git a/src/DiffEngineTray.Tests/DebugReportTests.Owned.verified.txt b/src/DiffEngineTray.Tests/DebugReportTests.Owned.verified.txt index 5cfa2ce2..43d4d4ee 100644 --- a/src/DiffEngineTray.Tests/DebugReportTests.Owned.verified.txt +++ b/src/DiffEngineTray.Tests/DebugReportTests.Owned.verified.txt @@ -22,6 +22,8 @@ Snapshots (1) LineHint: 42 Mode: Set OriginalExpression: "old" + OriginalValue: + MemberName: NewContent: line one line two diff --git a/src/DiffEngineTray/DebugReport.cs b/src/DiffEngineTray/DebugReport.cs index 39506c65..0f161e1f 100644 --- a/src/DiffEngineTray/DebugReport.cs +++ b/src/DiffEngineTray/DebugReport.cs @@ -112,6 +112,8 @@ static void AppendPatch(StringBuilder builder, IReadOnlyList? que AppendField(builder, "LineHint", patch.LineHint); AppendField(builder, "Mode", patch.Mode); AppendField(builder, "OriginalExpression", patch.OriginalExpression); + AppendField(builder, "OriginalValue", patch.OriginalValue); + AppendField(builder, "MemberName", patch.MemberName); AppendField(builder, "NewContent", patch.NewContent); } From 2d95a8dde752de2e5d4a7f70e23ee8128b0c717f Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Sat, 15 Aug 2026 10:31:37 +1000 Subject: [PATCH 3/8] . --- claude.md | 3 +- docs/inline.md | 9 ++- docs/mdsource/inline.source.md | 9 ++- src/DiffEngine.Tests/FsStringLiteralTests.cs | 64 +++++++++++++++++- src/DiffEngine.Tests/InlinePatcherFsTests.cs | 34 ++++++++-- src/DiffEngine/Inline/FsStringLiteral.cs | 69 ++++++++++++++++++-- 6 files changed, 168 insertions(+), 20 deletions(-) diff --git a/claude.md b/claude.md index f0e043a3..ebf35715 100644 --- a/claude.md +++ b/claude.md @@ -80,7 +80,8 @@ fills a `SourceScan`, what tells a declaration from a call, and how a literal is back. F# is the awkward one, because it has no raw string: triple-quoted content is verbatim, so a multi-line snapshot is written at the left margin, and its last line then decides the column of everything after the literal. Where that would land left of the statement, F#'s offside rule stops -the file compiling, so the render falls back to a one-line escaped literal. That rule is asserted +the file compiling, so the render switches to backslash continuations instead — a line of snapshot +per line of source, all of it indented, at the cost of escaping. That rule is asserted by compiling patched source with `dotnet fsi` (`FsCompilerRoundTripTests`), because a belief about F#'s lexis is exactly the kind of thing a second copy of the same belief cannot check. diff --git a/docs/inline.md b/docs/inline.md index 1328d7e3..4dc72fef 100644 --- a/docs/inline.md +++ b/docs/inline.md @@ -150,12 +150,17 @@ Verifier.Verify(value).Snapshot("""line one line two""").ToTask() ``` -Writing content at the left margin has a consequence: the content's last line decides the column of the closing delimiter, and so of the closing paren and anything chained after it. F#'s offside rule wants those at or right of the column the statement started in, and a snapshot ending in a newline — or in a short line, at a deeply indented call site — puts them left of it. That is not a formatting complaint; the file stops compiling. So the multi-line form is used only where its last line clears the call site's indentation, and everything else falls back to a regular literal on one source line, newlines escaped: +Writing content at the left margin has a consequence: the content's last line decides the column of the closing delimiter, and so of the closing paren and anything chained after it. F#'s offside rule wants those at or right of the column the statement started in, and a snapshot ending in a newline — or in a short line, at a deeply indented call site — puts them left of it. That is not a formatting complaint; the file stops compiling. + +So the verbatim form is used where its last line clears the call site's indentation, and everything else takes the other form F# offers: a regular literal broken across lines with backslash continuations, which drop the line break and the indentation after them. One line of snapshot per line of source, every line indented, and nothing at the margin for the layout to trip over: ```fsharp -Verifier.Verify(value).Snapshot("line one\nline two\n").ToTask() +Verifier.Verify(value).Snapshot("line one\n\ + line two\n").ToTask() ``` +The cost is escaping — quotes, backslashes, control characters — and one more thing: a continuation eats the whitespace after it and cannot tell the indentation it was written with from a space the snapshot itself starts with. So a content line beginning with a space starts with `\x20`, which is where the skipping stops; every space after it is content. + There is also no way to widen the delimiter, so content that would run into it — content containing `"""`, or starting or ending with a quote — takes the verbatim form instead (`@"..."`, quotes doubled). Single line content is always a regular literal, escaping what F# escapes (`\` `"` `\a` `\b` `\f` `\t` `\v` `\n` `\r`) and `\uXXXX` for the rest, since F# has no `\0` or `\e`. Two syntax differences show up in `Append` and in an argument list. F# does not apply the implicit conversion that lets a `SettingsTask` be awaited, so an F# test ends its chain with `ToTask`; `Snapshot` returns the `SettingsTask`, so an appended call goes in front of that rather than after it. And an argument binds to a parameter with `=`, so an inserted named argument is `expected = "..."`. diff --git a/docs/mdsource/inline.source.md b/docs/mdsource/inline.source.md index 61824a5c..aa9687fe 100644 --- a/docs/mdsource/inline.source.md +++ b/docs/mdsource/inline.source.md @@ -143,12 +143,17 @@ Verifier.Verify(value).Snapshot("""line one line two""").ToTask() ``` -Writing content at the left margin has a consequence: the content's last line decides the column of the closing delimiter, and so of the closing paren and anything chained after it. F#'s offside rule wants those at or right of the column the statement started in, and a snapshot ending in a newline — or in a short line, at a deeply indented call site — puts them left of it. That is not a formatting complaint; the file stops compiling. So the multi-line form is used only where its last line clears the call site's indentation, and everything else falls back to a regular literal on one source line, newlines escaped: +Writing content at the left margin has a consequence: the content's last line decides the column of the closing delimiter, and so of the closing paren and anything chained after it. F#'s offside rule wants those at or right of the column the statement started in, and a snapshot ending in a newline — or in a short line, at a deeply indented call site — puts them left of it. That is not a formatting complaint; the file stops compiling. + +So the verbatim form is used where its last line clears the call site's indentation, and everything else takes the other form F# offers: a regular literal broken across lines with backslash continuations, which drop the line break and the indentation after them. One line of snapshot per line of source, every line indented, and nothing at the margin for the layout to trip over: ```fsharp -Verifier.Verify(value).Snapshot("line one\nline two\n").ToTask() +Verifier.Verify(value).Snapshot("line one\n\ + line two\n").ToTask() ``` +The cost is escaping — quotes, backslashes, control characters — and one more thing: a continuation eats the whitespace after it and cannot tell the indentation it was written with from a space the snapshot itself starts with. So a content line beginning with a space starts with `\x20`, which is where the skipping stops; every space after it is content. + There is also no way to widen the delimiter, so content that would run into it — content containing `"""`, or starting or ending with a quote — takes the verbatim form instead (`@"..."`, quotes doubled). Single line content is always a regular literal, escaping what F# escapes (`\` `"` `\a` `\b` `\f` `\t` `\v` `\n` `\r`) and `\uXXXX` for the rest, since F# has no `\0` or `\e`. Two syntax differences show up in `Append` and in an argument list. F# does not apply the implicit conversion that lets a `SettingsTask` be awaited, so an F# test ends its chain with `ToTask`; `Snapshot` returns the `SettingsTask`, so an appended call goes in front of that rather than after it. And an argument binds to a parameter with `=`, so an inserted named argument is `expected = "..."`. diff --git a/src/DiffEngine.Tests/FsStringLiteralTests.cs b/src/DiffEngine.Tests/FsStringLiteralTests.cs index a88de1e6..be05504a 100644 --- a/src/DiffEngine.Tests/FsStringLiteralTests.cs +++ b/src/DiffEngine.Tests/FsStringLiteralTests.cs @@ -142,17 +142,75 @@ public async Task RenderMultiLineIsVerbatim(string content) } // The closing delimiter lands left of the column the statement started in, which F# reads as - // the end of the statement rather than as part of it. One source line instead + // the end of the statement rather than as part of it. Continuations instead: a line at a time, + // and every line indented, so there is nothing for the layout to trip over [Test] + [Arguments("a\nb", "\"a\\n\\\n b\"")] + [Arguments("has \"quotes\" and a \\\nx", "\"has \\\"quotes\\\" and a \\\\\\n\\\n x\"")] + // A trailing newline leaves nothing to continue to, so its break stays on the line above [Arguments("abc\n", "\"abc\\n\"")] - [Arguments("a\nb", "\"a\\nb\"")] - [Arguments("has \"quotes\" and a \\\nx", "\"has \\\"quotes\\\" and a \\\\\\nx\"")] public async Task RenderMultiLineFallsBackWhenItWouldBreakTheLayout(string content, string expected) { var rendered = FsStringLiteral.Render(content, " ", "\n"); await Assert.That(rendered).IsEqualTo(expected); } + [Test] + public async Task RenderContinued() + { + var rendered = FsStringLiteral.RenderContinued("line one\nline two", " ", "\n"); + await Assert.That(rendered).IsEqualTo("\"line one\\n\\\n line two\""); + } + + // The continuation drops the newline and the whitespace after it, and cannot tell the + // indentation it was written with from a space the snapshot starts with. So the first one is + // an escape, which is where the skipping stops, and the rest are content + [Test] + public async Task RenderContinuedEscapesALeadingSpace() + { + var rendered = FsStringLiteral.RenderContinued("a\n indented\nb", " ", "\n"); + await Assert.That(rendered).IsEqualTo("\"a\\n\\\n \\x20 indented\\n\\\n b\""); + } + + [Test] + public async Task RenderContinuedBlankLine() + { + var rendered = FsStringLiteral.RenderContinued("a\n\nb", " ", "\n"); + await Assert.That(rendered).IsEqualTo("\"a\\n\\\n \\n\\\n b\""); + } + + [Test] + public async Task RenderContinuedTrailingNewline() + { + var rendered = FsStringLiteral.RenderContinued("a\nb\n", " ", "\n"); + await Assert.That(rendered).IsEqualTo("\"a\\n\\\n b\\n\""); + } + + [Test] + public async Task RenderContinuedCrlf() + { + var rendered = FsStringLiteral.RenderContinued("a\nb", "\t", "\r\n"); + await Assert.That(rendered).IsEqualTo("\"a\\n\\\r\n\tb\""); + } + + [Test] + public async Task RenderContinuedRoundTrips() + { + foreach (var content in renderRoundTripCases) + { + foreach (var eol in new[] { "\n", "\r\n" }) + { + foreach (var indent in new[] { "", " ", " " }) + { + var rendered = FsStringLiteral.RenderContinued(content, indent, eol); + var parsed = FsStringLiteral.TryParse(rendered, out var value); + await Assert.That(parsed).IsTrue(); + await Assert.That(value).IsEqualTo(SourceLanguage.NormalizeNewlines(content)); + } + } + } + } + // The last line reaches the splice column, so the verbatim form stands [Test] public async Task RenderMultiLineKeptWhenTheLastLineIsLongEnough() diff --git a/src/DiffEngine.Tests/InlinePatcherFsTests.cs b/src/DiffEngine.Tests/InlinePatcherFsTests.cs index 86b6aa92..ae863877 100644 --- a/src/DiffEngine.Tests/InlinePatcherFsTests.cs +++ b/src/DiffEngine.Tests/InlinePatcherFsTests.cs @@ -43,9 +43,10 @@ await Assert.That(newSource).IsEqualTo( } // The closing delimiter would land at column 0..2, left of the statement, and F# reads that as - // the end of the statement rather than as part of it. One source line instead + // the end of the statement rather than as part of it. Continuations instead, which keep a line + // of snapshot per line of source without putting anything at the margin [Test] - public async Task ContentEndingInANewlineTakesTheEscapedForm() + public async Task ContentEndingInANewlineTakesTheContinuedForm() { var source = Test(" Verifier.Verify(15).Snapshot().ToTask() |> Async.AwaitTask"); @@ -53,12 +54,14 @@ public async Task ContentEndingInANewlineTakesTheEscapedForm() await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).IsEqualTo( - Test(" Verifier.Verify(15).Snapshot(\"a\\nb\\n\").ToTask() |> Async.AwaitTask")); + Test( + " Verifier.Verify(15).Snapshot(\"a\\n\\\n" + + " b\\n\").ToTask() |> Async.AwaitTask")); } // Same content, deeper call site: the last line no longer reaches the statement's column [Test] - public async Task DeepCallSiteTakesTheEscapedForm() + public async Task DeepCallSiteTakesTheContinuedForm() { var source = Test( " let inner () =\n" + @@ -68,7 +71,28 @@ public async Task DeepCallSiteTakesTheEscapedForm() var status = TryApply(source, 6, InlinePatchMode.Set, null, "a\nb", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); - await Assert.That(newSource).Contains("Snapshot(\"a\\nb\").ToTask()"); + await Assert.That(newSource).Contains( + " Verifier.Verify(15).Snapshot(\"a\\n\\\n" + + " b\").ToTask()"); + } + + // The continued lines line up with the statement, so an indented snapshot keeps its own + // indentation as content and the source stays a block + [Test] + public async Task ContinuedFormKeepsContentIndentation() + { + var source = Test( + " let inner () =\n" + + " Verifier.Verify(15).Snapshot().ToTask()\n" + + " inner ()"); + + var status = TryApply(source, 6, InlinePatchMode.Set, null, "{\n \"name\": \"value\"\n}", out var newSource, out _); + + await Assert.That(status).IsEqualTo(PatchStatus.Applied); + await Assert.That(newSource).Contains( + " Verifier.Verify(15).Snapshot(\"{\\n\\\n" + + " \\x20 \\\"name\\\": \\\"value\\\"\\n\\\n" + + " }\").ToTask()"); } [Test] diff --git a/src/DiffEngine/Inline/FsStringLiteral.cs b/src/DiffEngine/Inline/FsStringLiteral.cs index 4a87ccf1..84a272ca 100644 --- a/src/DiffEngine/Inline/FsStringLiteral.cs +++ b/src/DiffEngine/Inline/FsStringLiteral.cs @@ -39,8 +39,7 @@ public static string Render(string content, string indent, string eol) return multiLine; } - // No layout to break: one source line, whatever it costs in escapes - return RenderRegular(SourceLanguage.NormalizeNewlines(content)); + return RenderContinued(content, indent, eol); } /// @@ -63,15 +62,74 @@ public static string Render(string content, string indent, string eol) public static bool ClearsOffsideLine(string rendered, string indent) => rendered.Length - (rendered.LastIndexOf('\n') + 1) >= indent.Length; + /// + /// Renders (\n newlines) as a regular literal spread over one + /// source line per snapshot line, each break carrying an escaped newline and a continuation. + /// + /// The form for content the verbatim one cannot hold at this indentation. A backslash before + /// a line break drops the break and the indentation after it, so the literal reads a line at + /// a time while its value stays exactly the content - and, since every line is indented, it + /// has no way to break the layout it sits in. The cost is escaping: quotes, backslashes and + /// control characters, and a leading space per line, which the continuation would otherwise + /// eat along with the indentation it cannot tell that space from. + /// + /// + /// Snapshot text with \n newlines. + /// Whitespace prefix for the continued lines. Layout only: F# drops it. + /// The target file's line ending ("\r\n" or "\n"). + public static string RenderContinued(string content, string indent, string eol) + { + var lines = SourceLanguage.NormalizeNewlines(content).Split('\n'); + var sourceLines = new List(lines.Length); + for (var index = 0; index < lines.Length; index++) + { + var builder = new StringBuilder(); + // The first line follows the opening quote, so nothing has been dropped in front of it + AppendEscaped(builder, lines[index], escapeLeadingSpace: index > 0); + if (index < lines.Length - 1) + { + builder.Append("\\n"); + } + + sourceLines.Add(builder.ToString()); + } + + // Content ending in a newline leaves an empty last line, and its break is already on the + // line above. Continuing to it would put the closing quote alone on a line of its own + if (sourceLines[sourceLines.Count - 1].Length == 0) + { + sourceLines.RemoveAt(sourceLines.Count - 1); + } + + return $"\"{string.Join($"\\{eol}{indent}", sourceLines)}\""; + } + /// /// Renders content as a regular literal on one source line, escaping what the form cannot hold - /// verbatim - newlines included, which is what makes this the form that cannot break the - /// layout around it. + /// verbatim - newlines included. /// static string RenderRegular(string content) { var builder = new StringBuilder(content.Length + 2); builder.Append('"'); + AppendEscaped(builder, content, escapeLeadingSpace: false); + builder.Append('"'); + return builder.ToString(); + } + + static void AppendEscaped(StringBuilder builder, string content, bool escapeLeadingSpace) + { + if (escapeLeadingSpace && + content.Length > 0 && + content[0] == ' ') + { + // Written as an escape so the continuation's whitespace skipping stops on it. Only the + // first one has to be: the skip ends at the backslash, and every space after it is + // content. \x takes exactly two digits in F#, so nothing that follows extends it + builder.Append("\\x20"); + content = content.Substring(1); + } + foreach (var ch in content) { switch (ch) @@ -116,9 +174,6 @@ static string RenderRegular(string content) builder.Append(ch); } - - builder.Append('"'); - return builder.ToString(); } /// From 7e02497d556243033179b7d6b29677ff9117c4a5 Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Sat, 15 Aug 2026 10:42:53 +1000 Subject: [PATCH 4/8] . --- claude.md | 3 +- docs/inline.md | 9 +-- docs/mdsource/inline.source.md | 9 +-- src/DiffEngine.Tests/FsStringLiteralTests.cs | 64 +----------------- src/DiffEngine.Tests/InlinePatcherFsTests.cs | 34 ++-------- src/DiffEngine/Inline/FsStringLiteral.cs | 69 ++------------------ 6 files changed, 20 insertions(+), 168 deletions(-) diff --git a/claude.md b/claude.md index ebf35715..f0e043a3 100644 --- a/claude.md +++ b/claude.md @@ -80,8 +80,7 @@ fills a `SourceScan`, what tells a declaration from a call, and how a literal is back. F# is the awkward one, because it has no raw string: triple-quoted content is verbatim, so a multi-line snapshot is written at the left margin, and its last line then decides the column of everything after the literal. Where that would land left of the statement, F#'s offside rule stops -the file compiling, so the render switches to backslash continuations instead — a line of snapshot -per line of source, all of it indented, at the cost of escaping. That rule is asserted +the file compiling, so the render falls back to a one-line escaped literal. That rule is asserted by compiling patched source with `dotnet fsi` (`FsCompilerRoundTripTests`), because a belief about F#'s lexis is exactly the kind of thing a second copy of the same belief cannot check. diff --git a/docs/inline.md b/docs/inline.md index 4dc72fef..1328d7e3 100644 --- a/docs/inline.md +++ b/docs/inline.md @@ -150,17 +150,12 @@ Verifier.Verify(value).Snapshot("""line one line two""").ToTask() ``` -Writing content at the left margin has a consequence: the content's last line decides the column of the closing delimiter, and so of the closing paren and anything chained after it. F#'s offside rule wants those at or right of the column the statement started in, and a snapshot ending in a newline — or in a short line, at a deeply indented call site — puts them left of it. That is not a formatting complaint; the file stops compiling. - -So the verbatim form is used where its last line clears the call site's indentation, and everything else takes the other form F# offers: a regular literal broken across lines with backslash continuations, which drop the line break and the indentation after them. One line of snapshot per line of source, every line indented, and nothing at the margin for the layout to trip over: +Writing content at the left margin has a consequence: the content's last line decides the column of the closing delimiter, and so of the closing paren and anything chained after it. F#'s offside rule wants those at or right of the column the statement started in, and a snapshot ending in a newline — or in a short line, at a deeply indented call site — puts them left of it. That is not a formatting complaint; the file stops compiling. So the multi-line form is used only where its last line clears the call site's indentation, and everything else falls back to a regular literal on one source line, newlines escaped: ```fsharp -Verifier.Verify(value).Snapshot("line one\n\ - line two\n").ToTask() +Verifier.Verify(value).Snapshot("line one\nline two\n").ToTask() ``` -The cost is escaping — quotes, backslashes, control characters — and one more thing: a continuation eats the whitespace after it and cannot tell the indentation it was written with from a space the snapshot itself starts with. So a content line beginning with a space starts with `\x20`, which is where the skipping stops; every space after it is content. - There is also no way to widen the delimiter, so content that would run into it — content containing `"""`, or starting or ending with a quote — takes the verbatim form instead (`@"..."`, quotes doubled). Single line content is always a regular literal, escaping what F# escapes (`\` `"` `\a` `\b` `\f` `\t` `\v` `\n` `\r`) and `\uXXXX` for the rest, since F# has no `\0` or `\e`. Two syntax differences show up in `Append` and in an argument list. F# does not apply the implicit conversion that lets a `SettingsTask` be awaited, so an F# test ends its chain with `ToTask`; `Snapshot` returns the `SettingsTask`, so an appended call goes in front of that rather than after it. And an argument binds to a parameter with `=`, so an inserted named argument is `expected = "..."`. diff --git a/docs/mdsource/inline.source.md b/docs/mdsource/inline.source.md index aa9687fe..61824a5c 100644 --- a/docs/mdsource/inline.source.md +++ b/docs/mdsource/inline.source.md @@ -143,17 +143,12 @@ Verifier.Verify(value).Snapshot("""line one line two""").ToTask() ``` -Writing content at the left margin has a consequence: the content's last line decides the column of the closing delimiter, and so of the closing paren and anything chained after it. F#'s offside rule wants those at or right of the column the statement started in, and a snapshot ending in a newline — or in a short line, at a deeply indented call site — puts them left of it. That is not a formatting complaint; the file stops compiling. - -So the verbatim form is used where its last line clears the call site's indentation, and everything else takes the other form F# offers: a regular literal broken across lines with backslash continuations, which drop the line break and the indentation after them. One line of snapshot per line of source, every line indented, and nothing at the margin for the layout to trip over: +Writing content at the left margin has a consequence: the content's last line decides the column of the closing delimiter, and so of the closing paren and anything chained after it. F#'s offside rule wants those at or right of the column the statement started in, and a snapshot ending in a newline — or in a short line, at a deeply indented call site — puts them left of it. That is not a formatting complaint; the file stops compiling. So the multi-line form is used only where its last line clears the call site's indentation, and everything else falls back to a regular literal on one source line, newlines escaped: ```fsharp -Verifier.Verify(value).Snapshot("line one\n\ - line two\n").ToTask() +Verifier.Verify(value).Snapshot("line one\nline two\n").ToTask() ``` -The cost is escaping — quotes, backslashes, control characters — and one more thing: a continuation eats the whitespace after it and cannot tell the indentation it was written with from a space the snapshot itself starts with. So a content line beginning with a space starts with `\x20`, which is where the skipping stops; every space after it is content. - There is also no way to widen the delimiter, so content that would run into it — content containing `"""`, or starting or ending with a quote — takes the verbatim form instead (`@"..."`, quotes doubled). Single line content is always a regular literal, escaping what F# escapes (`\` `"` `\a` `\b` `\f` `\t` `\v` `\n` `\r`) and `\uXXXX` for the rest, since F# has no `\0` or `\e`. Two syntax differences show up in `Append` and in an argument list. F# does not apply the implicit conversion that lets a `SettingsTask` be awaited, so an F# test ends its chain with `ToTask`; `Snapshot` returns the `SettingsTask`, so an appended call goes in front of that rather than after it. And an argument binds to a parameter with `=`, so an inserted named argument is `expected = "..."`. diff --git a/src/DiffEngine.Tests/FsStringLiteralTests.cs b/src/DiffEngine.Tests/FsStringLiteralTests.cs index be05504a..a88de1e6 100644 --- a/src/DiffEngine.Tests/FsStringLiteralTests.cs +++ b/src/DiffEngine.Tests/FsStringLiteralTests.cs @@ -142,75 +142,17 @@ public async Task RenderMultiLineIsVerbatim(string content) } // The closing delimiter lands left of the column the statement started in, which F# reads as - // the end of the statement rather than as part of it. Continuations instead: a line at a time, - // and every line indented, so there is nothing for the layout to trip over + // the end of the statement rather than as part of it. One source line instead [Test] - [Arguments("a\nb", "\"a\\n\\\n b\"")] - [Arguments("has \"quotes\" and a \\\nx", "\"has \\\"quotes\\\" and a \\\\\\n\\\n x\"")] - // A trailing newline leaves nothing to continue to, so its break stays on the line above [Arguments("abc\n", "\"abc\\n\"")] + [Arguments("a\nb", "\"a\\nb\"")] + [Arguments("has \"quotes\" and a \\\nx", "\"has \\\"quotes\\\" and a \\\\\\nx\"")] public async Task RenderMultiLineFallsBackWhenItWouldBreakTheLayout(string content, string expected) { var rendered = FsStringLiteral.Render(content, " ", "\n"); await Assert.That(rendered).IsEqualTo(expected); } - [Test] - public async Task RenderContinued() - { - var rendered = FsStringLiteral.RenderContinued("line one\nline two", " ", "\n"); - await Assert.That(rendered).IsEqualTo("\"line one\\n\\\n line two\""); - } - - // The continuation drops the newline and the whitespace after it, and cannot tell the - // indentation it was written with from a space the snapshot starts with. So the first one is - // an escape, which is where the skipping stops, and the rest are content - [Test] - public async Task RenderContinuedEscapesALeadingSpace() - { - var rendered = FsStringLiteral.RenderContinued("a\n indented\nb", " ", "\n"); - await Assert.That(rendered).IsEqualTo("\"a\\n\\\n \\x20 indented\\n\\\n b\""); - } - - [Test] - public async Task RenderContinuedBlankLine() - { - var rendered = FsStringLiteral.RenderContinued("a\n\nb", " ", "\n"); - await Assert.That(rendered).IsEqualTo("\"a\\n\\\n \\n\\\n b\""); - } - - [Test] - public async Task RenderContinuedTrailingNewline() - { - var rendered = FsStringLiteral.RenderContinued("a\nb\n", " ", "\n"); - await Assert.That(rendered).IsEqualTo("\"a\\n\\\n b\\n\""); - } - - [Test] - public async Task RenderContinuedCrlf() - { - var rendered = FsStringLiteral.RenderContinued("a\nb", "\t", "\r\n"); - await Assert.That(rendered).IsEqualTo("\"a\\n\\\r\n\tb\""); - } - - [Test] - public async Task RenderContinuedRoundTrips() - { - foreach (var content in renderRoundTripCases) - { - foreach (var eol in new[] { "\n", "\r\n" }) - { - foreach (var indent in new[] { "", " ", " " }) - { - var rendered = FsStringLiteral.RenderContinued(content, indent, eol); - var parsed = FsStringLiteral.TryParse(rendered, out var value); - await Assert.That(parsed).IsTrue(); - await Assert.That(value).IsEqualTo(SourceLanguage.NormalizeNewlines(content)); - } - } - } - } - // The last line reaches the splice column, so the verbatim form stands [Test] public async Task RenderMultiLineKeptWhenTheLastLineIsLongEnough() diff --git a/src/DiffEngine.Tests/InlinePatcherFsTests.cs b/src/DiffEngine.Tests/InlinePatcherFsTests.cs index ae863877..86b6aa92 100644 --- a/src/DiffEngine.Tests/InlinePatcherFsTests.cs +++ b/src/DiffEngine.Tests/InlinePatcherFsTests.cs @@ -43,10 +43,9 @@ await Assert.That(newSource).IsEqualTo( } // The closing delimiter would land at column 0..2, left of the statement, and F# reads that as - // the end of the statement rather than as part of it. Continuations instead, which keep a line - // of snapshot per line of source without putting anything at the margin + // the end of the statement rather than as part of it. One source line instead [Test] - public async Task ContentEndingInANewlineTakesTheContinuedForm() + public async Task ContentEndingInANewlineTakesTheEscapedForm() { var source = Test(" Verifier.Verify(15).Snapshot().ToTask() |> Async.AwaitTask"); @@ -54,14 +53,12 @@ public async Task ContentEndingInANewlineTakesTheContinuedForm() await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).IsEqualTo( - Test( - " Verifier.Verify(15).Snapshot(\"a\\n\\\n" + - " b\\n\").ToTask() |> Async.AwaitTask")); + Test(" Verifier.Verify(15).Snapshot(\"a\\nb\\n\").ToTask() |> Async.AwaitTask")); } // Same content, deeper call site: the last line no longer reaches the statement's column [Test] - public async Task DeepCallSiteTakesTheContinuedForm() + public async Task DeepCallSiteTakesTheEscapedForm() { var source = Test( " let inner () =\n" + @@ -71,28 +68,7 @@ public async Task DeepCallSiteTakesTheContinuedForm() var status = TryApply(source, 6, InlinePatchMode.Set, null, "a\nb", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); - await Assert.That(newSource).Contains( - " Verifier.Verify(15).Snapshot(\"a\\n\\\n" + - " b\").ToTask()"); - } - - // The continued lines line up with the statement, so an indented snapshot keeps its own - // indentation as content and the source stays a block - [Test] - public async Task ContinuedFormKeepsContentIndentation() - { - var source = Test( - " let inner () =\n" + - " Verifier.Verify(15).Snapshot().ToTask()\n" + - " inner ()"); - - var status = TryApply(source, 6, InlinePatchMode.Set, null, "{\n \"name\": \"value\"\n}", out var newSource, out _); - - await Assert.That(status).IsEqualTo(PatchStatus.Applied); - await Assert.That(newSource).Contains( - " Verifier.Verify(15).Snapshot(\"{\\n\\\n" + - " \\x20 \\\"name\\\": \\\"value\\\"\\n\\\n" + - " }\").ToTask()"); + await Assert.That(newSource).Contains("Snapshot(\"a\\nb\").ToTask()"); } [Test] diff --git a/src/DiffEngine/Inline/FsStringLiteral.cs b/src/DiffEngine/Inline/FsStringLiteral.cs index 84a272ca..4a87ccf1 100644 --- a/src/DiffEngine/Inline/FsStringLiteral.cs +++ b/src/DiffEngine/Inline/FsStringLiteral.cs @@ -39,7 +39,8 @@ public static string Render(string content, string indent, string eol) return multiLine; } - return RenderContinued(content, indent, eol); + // No layout to break: one source line, whatever it costs in escapes + return RenderRegular(SourceLanguage.NormalizeNewlines(content)); } /// @@ -62,74 +63,15 @@ public static string Render(string content, string indent, string eol) public static bool ClearsOffsideLine(string rendered, string indent) => rendered.Length - (rendered.LastIndexOf('\n') + 1) >= indent.Length; - /// - /// Renders (\n newlines) as a regular literal spread over one - /// source line per snapshot line, each break carrying an escaped newline and a continuation. - /// - /// The form for content the verbatim one cannot hold at this indentation. A backslash before - /// a line break drops the break and the indentation after it, so the literal reads a line at - /// a time while its value stays exactly the content - and, since every line is indented, it - /// has no way to break the layout it sits in. The cost is escaping: quotes, backslashes and - /// control characters, and a leading space per line, which the continuation would otherwise - /// eat along with the indentation it cannot tell that space from. - /// - /// - /// Snapshot text with \n newlines. - /// Whitespace prefix for the continued lines. Layout only: F# drops it. - /// The target file's line ending ("\r\n" or "\n"). - public static string RenderContinued(string content, string indent, string eol) - { - var lines = SourceLanguage.NormalizeNewlines(content).Split('\n'); - var sourceLines = new List(lines.Length); - for (var index = 0; index < lines.Length; index++) - { - var builder = new StringBuilder(); - // The first line follows the opening quote, so nothing has been dropped in front of it - AppendEscaped(builder, lines[index], escapeLeadingSpace: index > 0); - if (index < lines.Length - 1) - { - builder.Append("\\n"); - } - - sourceLines.Add(builder.ToString()); - } - - // Content ending in a newline leaves an empty last line, and its break is already on the - // line above. Continuing to it would put the closing quote alone on a line of its own - if (sourceLines[sourceLines.Count - 1].Length == 0) - { - sourceLines.RemoveAt(sourceLines.Count - 1); - } - - return $"\"{string.Join($"\\{eol}{indent}", sourceLines)}\""; - } - /// /// Renders content as a regular literal on one source line, escaping what the form cannot hold - /// verbatim - newlines included. + /// verbatim - newlines included, which is what makes this the form that cannot break the + /// layout around it. /// static string RenderRegular(string content) { var builder = new StringBuilder(content.Length + 2); builder.Append('"'); - AppendEscaped(builder, content, escapeLeadingSpace: false); - builder.Append('"'); - return builder.ToString(); - } - - static void AppendEscaped(StringBuilder builder, string content, bool escapeLeadingSpace) - { - if (escapeLeadingSpace && - content.Length > 0 && - content[0] == ' ') - { - // Written as an escape so the continuation's whitespace skipping stops on it. Only the - // first one has to be: the skip ends at the backslash, and every space after it is - // content. \x takes exactly two digits in F#, so nothing that follows extends it - builder.Append("\\x20"); - content = content.Substring(1); - } - foreach (var ch in content) { switch (ch) @@ -174,6 +116,9 @@ static void AppendEscaped(StringBuilder builder, string content, bool escapeLead builder.Append(ch); } + + builder.Append('"'); + return builder.ToString(); } /// From c636b3f534f476ad53df27a9945a3e46e62b3b2a Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Sat, 15 Aug 2026 10:46:42 +1000 Subject: [PATCH 5/8] . --- docs/inline.md | 10 +++++++--- docs/mdsource/inline.source.md | 10 +++++++--- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/docs/inline.md b/docs/inline.md index 1328d7e3..1cce8df9 100644 --- a/docs/inline.md +++ b/docs/inline.md @@ -143,11 +143,15 @@ What differs is the literal, because F# has no raw string. A triple-quoted strin ```fsharp // single line content -Verifier.Verify(value).Snapshot("the value").ToTask() +Verifier.Verify(value) + .Snapshot("the value") + .ToTask() // multi-line content -Verifier.Verify(value).Snapshot("""line one -line two""").ToTask() +Verifier.Verify(value) + .Snapshot("""line one +line two""") + .ToTask() ``` Writing content at the left margin has a consequence: the content's last line decides the column of the closing delimiter, and so of the closing paren and anything chained after it. F#'s offside rule wants those at or right of the column the statement started in, and a snapshot ending in a newline — or in a short line, at a deeply indented call site — puts them left of it. That is not a formatting complaint; the file stops compiling. So the multi-line form is used only where its last line clears the call site's indentation, and everything else falls back to a regular literal on one source line, newlines escaped: diff --git a/docs/mdsource/inline.source.md b/docs/mdsource/inline.source.md index 61824a5c..09ff8775 100644 --- a/docs/mdsource/inline.source.md +++ b/docs/mdsource/inline.source.md @@ -136,11 +136,15 @@ What differs is the literal, because F# has no raw string. A triple-quoted strin ```fsharp // single line content -Verifier.Verify(value).Snapshot("the value").ToTask() +Verifier.Verify(value) + .Snapshot("the value") + .ToTask() // multi-line content -Verifier.Verify(value).Snapshot("""line one -line two""").ToTask() +Verifier.Verify(value) + .Snapshot("""line one +line two""") + .ToTask() ``` Writing content at the left margin has a consequence: the content's last line decides the column of the closing delimiter, and so of the closing paren and anything chained after it. F#'s offside rule wants those at or right of the column the statement started in, and a snapshot ending in a newline — or in a short line, at a deeply indented call site — puts them left of it. That is not a formatting complaint; the file stops compiling. So the multi-line form is used only where its last line clears the call site's indentation, and everything else falls back to a regular literal on one source line, newlines escaped: From 647db21708f0c8b29a6ef24c170dd59a0d31a0d4 Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Sat, 15 Aug 2026 12:44:49 +1000 Subject: [PATCH 6/8] . --- claude.md | 4 +- docs/inline.md | 16 +- docs/mdsource/inline.source.md | 16 +- pack-local.ps1 | 51 +++ .../FsCompilerRoundTripTests.cs | 37 ++- src/DiffEngine.Tests/FsStringLiteralTests.cs | 196 +++++------ src/DiffEngine.Tests/InlineApplierTests.cs | 2 +- src/DiffEngine.Tests/InlinePatcherFsTests.cs | 73 +++-- src/DiffEngine/Inline/CsLanguage.cs | 6 + src/DiffEngine/Inline/CsStringLiteral.cs | 283 ++-------------- src/DiffEngine/Inline/FsLanguage.cs | 11 +- src/DiffEngine/Inline/FsStringLiteral.cs | 284 ++++------------ src/DiffEngine/Inline/InlinePatcher.cs | 26 +- src/DiffEngine/Inline/SourceLanguage.cs | 36 +-- src/DiffEngine/Inline/StringLiteral.cs | 303 ++++++++++++++++++ 15 files changed, 685 insertions(+), 659 deletions(-) create mode 100644 pack-local.ps1 create mode 100644 src/DiffEngine/Inline/StringLiteral.cs diff --git a/claude.md b/claude.md index f0e043a3..7ee9ebce 100644 --- a/claude.md +++ b/claude.md @@ -80,7 +80,9 @@ fills a `SourceScan`, what tells a declaration from a call, and how a literal is back. F# is the awkward one, because it has no raw string: triple-quoted content is verbatim, so a multi-line snapshot is written at the left margin, and its last line then decides the column of everything after the literal. Where that would land left of the statement, F#'s offside rule stops -the file compiling, so the render falls back to a one-line escaped literal. That rule is asserted +the file compiling, so the render falls back to a one-line escaped literal — unless nothing follows +the literal on its line, which is the shape a formatter writes and where the rule cannot bite. That +rule is asserted by compiling patched source with `dotnet fsi` (`FsCompilerRoundTripTests`), because a belief about F#'s lexis is exactly the kind of thing a second copy of the same belief cannot check. diff --git a/docs/inline.md b/docs/inline.md index 1cce8df9..44980cf0 100644 --- a/docs/inline.md +++ b/docs/inline.md @@ -149,7 +149,8 @@ Verifier.Verify(value) // multi-line content Verifier.Verify(value) - .Snapshot("""line one + .Snapshot( +"""line one line two""") .ToTask() ``` @@ -160,6 +161,19 @@ Writing content at the left margin has a consequence: the content's last line de Verifier.Verify(value).Snapshot("line one\nline two\n").ToTask() ``` +That measurement is about what follows the literal *on its line*. A call split across lines leaves nothing there — the closing paren is below it, at a column the file already chose — and then the verbatim form stands however short the content's last line is. This is the shape Fantomas writes whenever a snapshot spans lines, so a formatted file keeps `"""` in the cases a single-line call would have escaped, and the patched result is already canonical: running the formatter over it changes nothing. + +```fsharp +Verifier + .Verify(value) + .Snapshot( + """line one +line two +""" + ) + .ToTask() +``` + There is also no way to widen the delimiter, so content that would run into it — content containing `"""`, or starting or ending with a quote — takes the verbatim form instead (`@"..."`, quotes doubled). Single line content is always a regular literal, escaping what F# escapes (`\` `"` `\a` `\b` `\f` `\t` `\v` `\n` `\r`) and `\uXXXX` for the rest, since F# has no `\0` or `\e`. Two syntax differences show up in `Append` and in an argument list. F# does not apply the implicit conversion that lets a `SettingsTask` be awaited, so an F# test ends its chain with `ToTask`; `Snapshot` returns the `SettingsTask`, so an appended call goes in front of that rather than after it. And an argument binds to a parameter with `=`, so an inserted named argument is `expected = "..."`. diff --git a/docs/mdsource/inline.source.md b/docs/mdsource/inline.source.md index 09ff8775..c2869989 100644 --- a/docs/mdsource/inline.source.md +++ b/docs/mdsource/inline.source.md @@ -142,7 +142,8 @@ Verifier.Verify(value) // multi-line content Verifier.Verify(value) - .Snapshot("""line one + .Snapshot( +"""line one line two""") .ToTask() ``` @@ -153,6 +154,19 @@ Writing content at the left margin has a consequence: the content's last line de Verifier.Verify(value).Snapshot("line one\nline two\n").ToTask() ``` +That measurement is about what follows the literal *on its line*. A call split across lines leaves nothing there — the closing paren is below it, at a column the file already chose — and then the verbatim form stands however short the content's last line is. This is the shape Fantomas writes whenever a snapshot spans lines, so a formatted file keeps `"""` in the cases a single-line call would have escaped, and the patched result is already canonical: running the formatter over it changes nothing. + +```fsharp +Verifier + .Verify(value) + .Snapshot( + """line one +line two +""" + ) + .ToTask() +``` + There is also no way to widen the delimiter, so content that would run into it — content containing `"""`, or starting or ending with a quote — takes the verbatim form instead (`@"..."`, quotes doubled). Single line content is always a regular literal, escaping what F# escapes (`\` `"` `\a` `\b` `\f` `\t` `\v` `\n` `\r`) and `\uXXXX` for the rest, since F# has no `\0` or `\e`. Two syntax differences show up in `Append` and in an argument list. F# does not apply the implicit conversion that lets a `SettingsTask` be awaited, so an F# test ends its chain with `ToTask`; `Snapshot` returns the `SettingsTask`, so an appended call goes in front of that rather than after it. And an argument binds to a parameter with `=`, so an inserted named argument is `expected = "..."`. diff --git a/pack-local.ps1 b/pack-local.ps1 new file mode 100644 index 00000000..bc99be2a --- /dev/null +++ b/pack-local.ps1 @@ -0,0 +1,51 @@ +# Packs DiffEngine into ./nugets under a version no feed will ever publish, so a consumer in +# another repo can reference the working tree rather than the last release. +# +# The version is fixed rather than stamped with the time, so the consumer's pin stays put across +# rebuilds. That only works because the cached copy is deleted first: NuGet caches by id and +# version, and would otherwise keep serving the package from the previous run forever. +# +# ./pack-local.ps1 pack as 20.0.0-local +# ./pack-local.ps1 -Version 1.2.3 pack as 1.2.3 +# +# Consuming it from Verify is two lines, both in src: a local in nuget.config pointing at +# this folder, and the DiffEngine PackageVersion set to the same version. +[CmdletBinding()] +param( + [string] $Version = '20.0.0-local' +) + +$ErrorActionPreference = 'Stop' +$root = $PSScriptRoot +$output = Join-Path $root 'nugets' + +# The cached copy of a version already restored once, which is what makes a fixed version safe +$cached = Join-Path $env:USERPROFILE ".nuget\packages\diffengine\$Version" +if (Test-Path $cached) +{ + Write-Host "Removing cached $Version" + Remove-Item $cached -Recurse -Force +} + +$package = Join-Path $output "DiffEngine.$Version.nupkg" +if (Test-Path $package) +{ + Remove-Item $package -Force +} + +# A build rather than a pack: ProjectDefaults sets GeneratePackageOnBuild for Release, and the +# viewer heads DiffEngine bundles are published by targets that only run on the way through. +Write-Host "Packing $Version" +dotnet build (Join-Path $root 'src\DiffEngine\DiffEngine.csproj') --configuration Release -p:Version=$Version +if ($LASTEXITCODE -ne 0) +{ + throw "Build failed with $LASTEXITCODE" +} + +if (-not (Test-Path $package)) +{ + throw "No package at $package" +} + +Write-Host "Packed $package" +Write-Host 'Consumers should clear obj/ or restore with --no-cache if they had this version already.' diff --git a/src/DiffEngine.Tests/FsCompilerRoundTripTests.cs b/src/DiffEngine.Tests/FsCompilerRoundTripTests.cs index 6c96d080..34dc7f53 100644 --- a/src/DiffEngine.Tests/FsCompilerRoundTripTests.cs +++ b/src/DiffEngine.Tests/FsCompilerRoundTripTests.cs @@ -82,11 +82,36 @@ let Verify (value: string) = Chain(value) let mutable failures = 0 - let check (name: string) (actual: string) (expectedBase64: string) = + // The reader's half of the convention, written out in F# rather than called into + // DiffEngine: what a test library has to do with what the compiler handed it, and the + // only way this checks the agreement rather than one side of it twice + let strip (value: string) = + let normalized = value.Replace("\r\n", "\n") + let lines = normalized.Split('\n') + if lines.Length < 2 then + normalized + else + let closeIndent = lines.[lines.Length - 1] + let middle = lines.[1 .. lines.Length - 2] + let malformed = + middle + |> Array.exists (fun line -> + line.Length > 0 && not (line.StartsWith closeIndent) && line.Trim().Length > 0) + if lines.[0].Trim().Length > 0 || closeIndent.Trim().Length > 0 || malformed then + normalized + else + middle + |> Array.map (fun line -> + if line.StartsWith closeIndent then line.Substring closeIndent.Length else "") + |> String.concat "\n" + + let check (name: string) (literal: string) (expectedBase64: string) = let expected = System.Text.Encoding.UTF8.GetString(System.Convert.FromBase64String expectedBase64) + let actual = strip literal if actual <> expected then failures <- failures + 1 printfn "FAIL %s" name + printfn " literal %A" literal printfn " actual %A" actual printfn " expected %A" expected @@ -124,6 +149,16 @@ let check (name: string) (actual: string) (expectedBase64: string) = InlinePatchMode.Set, content)); builder.Append($"check \"chain{index}\" (chain{index} ()) \"{expected}\"\n\n"); + + // The shape an F# formatter writes: the literal on its own line with the closing paren + // below it, where the verbatim form is kept whatever the content's last line is + builder.Append( + Patch( + $"let formatted{index} () =\n Verify(\"x\")\n .Snapshot(\n \"\"\"placeholder\"\"\"\n )\n .ToTask()\n", + 4, + InlinePatchMode.Set, + content)); + builder.Append($"check \"formatted{index}\" (formatted{index} ()) \"{expected}\"\n\n"); } builder.Append( diff --git a/src/DiffEngine.Tests/FsStringLiteralTests.cs b/src/DiffEngine.Tests/FsStringLiteralTests.cs index a88de1e6..701457dc 100644 --- a/src/DiffEngine.Tests/FsStringLiteralTests.cs +++ b/src/DiffEngine.Tests/FsStringLiteralTests.cs @@ -24,56 +24,70 @@ public class FsStringLiteralTests "emoji 🎈 and unicode ☂", "line1\n indented\nline3", "back\\slash\nsecond", - "tab\there\nsecond" + "tab\there\nsecond", + "{\n \"name\": \"value\"\n}" ]; - // Content is verbatim, so it starts on the delimiter's line and stays at the left margin + // The same shape C# writes: the content indented under the call, with the first line and the + // closing delimiter's indentation there to be taken back off [Test] public async Task RenderMultiLine() { - var rendered = FsStringLiteral.RenderMultiLine("a\nb", "\n"); - await Assert.That(rendered).IsEqualTo("\"\"\"a\nb\"\"\""); + var rendered = FsStringLiteral.Render("line one\nline two", " ", "\n"); + await Assert.That(rendered).IsEqualTo("\"\"\"\n line one\n line two\n \"\"\""); } [Test] - public async Task RenderMultiLineIndentIsContent() + public async Task RenderMultiLineMatchesCSharp() { - var rendered = FsStringLiteral.RenderMultiLine("a\n indented\nb", "\n"); - await Assert.That(rendered).IsEqualTo("\"\"\"a\n indented\nb\"\"\""); + foreach (var content in renderRoundTripCases) + { + if (content.IndexOf('\n') == -1 || + content.Contains("\"\"\"") || + content.StartsWith('"') || + content.EndsWith('"')) + { + continue; + } + + var fsharp = FsStringLiteral.Render(content, " ", "\n"); + var csharp = CsStringLiteral.Render(content, " ", "\n"); + await Assert.That(fsharp).IsEqualTo(csharp); + } } [Test] - public async Task RenderEmpty() + public async Task RenderBlankLineHasNoTrailingWhitespace() { - var rendered = FsStringLiteral.RenderMultiLine("", "\n"); - await Assert.That(rendered).IsEqualTo("\"\""); + var rendered = FsStringLiteral.Render("a\n\nb", " ", "\n"); + await Assert.That(rendered).IsEqualTo("\"\"\"\n a\n\n b\n \"\"\""); } [Test] public async Task RenderCrlf() { - var rendered = FsStringLiteral.RenderMultiLine("a\nb", "\r\n"); - await Assert.That(rendered).IsEqualTo("\"\"\"a\r\nb\"\"\""); + var rendered = FsStringLiteral.Render("a\nb", "\t", "\r\n"); + await Assert.That(rendered).IsEqualTo("\"\"\"\r\n\ta\r\n\tb\r\n\t\"\"\""); } - // F# cannot widen a triple-quoted delimiter, so content that runs into one takes the - // verbatim form instead + // F# cannot widen a delimiter the way C# can, so content that runs into one has no multi-line + // form at all and takes a regular literal on one line [Test] - [Arguments("has \"\"\" inside\nsecond", "@\"has \"\"\"\"\"\" inside\nsecond\"")] - [Arguments("\"starts with a quote\nsecond", "@\"\"\"starts with a quote\nsecond\"")] - [Arguments("ends with a quote\nsecond\"", "@\"ends with a quote\nsecond\"\"\"")] - public async Task RenderMultiLineFallsBackToVerbatim(string content, string expected) + [Arguments("has \"\"\" inside\nsecond", "\"has \\\"\\\"\\\" inside\\nsecond\"")] + [Arguments("\"starts with a quote\nsecond", "\"\\\"starts with a quote\\nsecond\"")] + [Arguments("ends with a quote\nsecond\"", "\"ends with a quote\\nsecond\\\"\"")] + public async Task RenderFallsBackWhenTheDelimiterCannotHoldIt(string content, string expected) { - var rendered = FsStringLiteral.RenderMultiLine(content, "\n"); + var rendered = FsStringLiteral.Render(content, " ", "\n"); await Assert.That(rendered).IsEqualTo(expected); } // A single quote in the middle is no problem for the triple-quoted form [Test] - public async Task RenderMultiLineKeepsQuotesInTheMiddle() + public async Task RenderKeepsQuotesInTheMiddle() { - var rendered = FsStringLiteral.RenderMultiLine("a \"quoted\" word\nsecond", "\n"); - await Assert.That(rendered).IsEqualTo("\"\"\"a \"quoted\" word\nsecond\"\"\""); + var rendered = FsStringLiteral.Render("a \"quoted\" word\nsecond", " ", "\n"); + await Assert.That(rendered).IsEqualTo("\"\"\"\n a \"quoted\" word\n second\n \"\"\""); } [Test] @@ -86,8 +100,8 @@ public async Task RenderNormalizesCarriageReturns(string content) var normalized = content.Replace("\r\n", "\n").Replace('\r', '\n'); foreach (var eol in new[] { "\n", "\r\n" }) { - var rendered = FsStringLiteral.RenderMultiLine(content, eol); - await Assert.That(rendered).IsEqualTo(FsStringLiteral.RenderMultiLine(normalized, eol)); + var rendered = FsStringLiteral.Render(content, " ", eol); + await Assert.That(rendered).IsEqualTo(FsStringLiteral.Render(normalized, " ", eol)); var parsed = FsStringLiteral.TryParse(rendered, out var value); await Assert.That(parsed).IsTrue(); @@ -95,21 +109,6 @@ public async Task RenderNormalizesCarriageReturns(string content) } } - [Test] - public async Task RenderRoundTrips() - { - foreach (var content in renderRoundTripCases) - { - foreach (var eol in new[] { "\n", "\r\n" }) - { - var rendered = FsStringLiteral.RenderMultiLine(content, eol); - var parsed = FsStringLiteral.TryParse(rendered, out var value); - await Assert.That(parsed).IsTrue(); - await Assert.That(value).IsEqualTo(content); - } - } - } - [Test] [Arguments("abc", "\"abc\"")] [Arguments("", "\"\"")] @@ -130,57 +129,13 @@ public async Task RenderSingleLineIsRegular(string content, string expected) } [Test] - [Arguments("a\nb")] - [Arguments("a\rb")] - [Arguments("a\r\nb")] - [Arguments("\nabc")] - [Arguments("abc\n")] - public async Task RenderMultiLineIsVerbatim(string content) - { - var rendered = FsStringLiteral.Render(content, "", "\n"); - await Assert.That(rendered).IsEqualTo(FsStringLiteral.RenderMultiLine(content, "\n")); - } - - // The closing delimiter lands left of the column the statement started in, which F# reads as - // the end of the statement rather than as part of it. One source line instead - [Test] - [Arguments("abc\n", "\"abc\\n\"")] - [Arguments("a\nb", "\"a\\nb\"")] - [Arguments("has \"quotes\" and a \\\nx", "\"has \\\"quotes\\\" and a \\\\\\nx\"")] - public async Task RenderMultiLineFallsBackWhenItWouldBreakTheLayout(string content, string expected) - { - var rendered = FsStringLiteral.Render(content, " ", "\n"); - await Assert.That(rendered).IsEqualTo(expected); - } - - // The last line reaches the splice column, so the verbatim form stands - [Test] - public async Task RenderMultiLineKeptWhenTheLastLineIsLongEnough() - { - var rendered = FsStringLiteral.Render("first\nlong enough", " ", "\n"); - await Assert.That(rendered).IsEqualTo("\"\"\"first\nlong enough\"\"\""); - } - - [Test] - [Arguments("first\nabcde", " ", true)] - [Arguments("first\nabcd", " ", false)] - [Arguments("first\na", " ", true)] - [Arguments("first\n", " ", false)] - [Arguments("first\n", "", true)] - public async Task ClearsOffsideLine(string content, string indent, bool expected) - { - var rendered = FsStringLiteral.RenderMultiLine(content, "\n"); - await Assert.That(FsStringLiteral.ClearsOffsideLine(rendered, indent)).IsEqualTo(expected); - } - - [Test] - public async Task RenderRoundTripsWhicheverFormItPicks() + public async Task RenderRoundTrips() { foreach (var content in renderRoundTripCases) { foreach (var eol in new[] { "\n", "\r\n" }) { - foreach (var indent in new[] { "", " ", " " }) + foreach (var indent in new[] { "", " ", " " }) { var rendered = FsStringLiteral.Render(content, indent, eol); var parsed = FsStringLiteral.TryParse(rendered, out var value); @@ -191,6 +146,47 @@ public async Task RenderRoundTripsWhicheverFormItPicks() } } + // What F# hands over for a rendered literal is the source text between the delimiters, so the + // convention has to give the content back + [Test] + public async Task StripLayoutIsTheInverseOfRender() + { + foreach (var content in renderRoundTripCases) + { + foreach (var indent in new[] { "", " ", " " }) + { + var rendered = FsStringLiteral.Render(content, indent, "\n"); + // What the F# compiler produces: everything between the delimiters, verbatim + if (!rendered.StartsWith("\"\"\"")) + { + continue; + } + + var compilerValue = rendered.Substring(3, rendered.Length - 6); + await Assert.That(FsStringLiteral.StripLayout(compilerValue)).IsEqualTo(content); + } + } + } + + [Test] + public async Task StripLayout() => + await Assert.That(FsStringLiteral.StripLayout("\n line one\n line two\n ")).IsEqualTo("line one\nline two"); + + // A blank line before the closing delimiter is how content ending in a newline is written + [Test] + public async Task StripLayoutTrailingNewline() => + await Assert.That(FsStringLiteral.StripLayout("\n line one\n\n ")).IsEqualTo("line one\n"); + + // Anything not in that shape is its own value: a single line, or a literal written some other + // way, or a snapshot that only looks like layout + [Test] + [Arguments("the value")] + [Arguments("line one\nline two")] + [Arguments("\n line one\n not the indent")] + [Arguments("")] + public async Task StripLayoutLeavesOtherValuesAlone(string value) => + await Assert.That(FsStringLiteral.StripLayout(value)).IsEqualTo(value); + [Test] [Arguments("\"a\"", "a")] [Arguments("\"\"", "")] @@ -206,6 +202,7 @@ public async Task RenderRoundTripsWhicheverFormItPicks() [Arguments("@\"\"", "")] // Verbatim has no triple-quoted form: the run after @" is an escaped quote [Arguments("@\"\"\"abc\"\"\"", "\"abc\"")] + // Single line triple-quoted content is verbatim, with no layout to take off [Arguments("\"\"\"a\"b\"\"\"", "a\"b")] // Ordinary F# strings may span lines [Arguments("\"a\nb\"", "a\nb")] @@ -216,6 +213,15 @@ public async Task Parse(string expression, string expected) await Assert.That(value).IsEqualTo(expected); } + [Test] + public async Task ParseMultiLineTakesTheLayoutOff() + { + var expression = "\"\"\"\n a\n\n b\n \"\"\""; + var parsed = FsStringLiteral.TryParse(expression, out var value); + await Assert.That(parsed).IsTrue(); + await Assert.That(value).IsEqualTo("a\n\nb"); + } + // A backslash before a line break drops the break and the indentation that follows it [Test] public async Task ParseLineContinuation() @@ -233,17 +239,6 @@ public async Task ParseMultiLineVerbatim() await Assert.That(value).IsEqualTo("a\nb"); } - // Where C# would drop the first line and strip the closing delimiter's indent, F# keeps - // every character between the delimiters - [Test] - public async Task ParseTripleQuotedIsVerbatim() - { - var expression = "\"\"\"\n a\n\n b\n \"\"\""; - var parsed = FsStringLiteral.TryParse(expression, out var value); - await Assert.That(parsed).IsTrue(); - await Assert.That(value).IsEqualTo("\n a\n\n b\n "); - } - [Test] [Arguments("$\"interpolated\"")] [Arguments("$\"\"\"interpolated\"\"\"")] @@ -265,4 +260,13 @@ public async Task ParseRejects(string expression) var parsed = FsStringLiteral.TryParse(expression, out _); 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 + [Test] + public async Task ParseRejectsMalformedIndent() + { + var parsed = FsStringLiteral.TryParse("\"\"\"\n a\n \"\"\"", out _); + await Assert.That(parsed).IsFalse(); + } } diff --git a/src/DiffEngine.Tests/InlineApplierTests.cs b/src/DiffEngine.Tests/InlineApplierTests.cs index d392c576..310ad86c 100644 --- a/src/DiffEngine.Tests/InlineApplierTests.cs +++ b/src/DiffEngine.Tests/InlineApplierTests.cs @@ -257,7 +257,7 @@ public async Task FSharpFileGetsAnFSharpLiteral(string extension) await Assert.That(result.Status).IsEqualTo(InlineApplyStatus.Applied); await Assert.That(await File.ReadAllTextAsync(path)).IsEqualTo( - "module Tests\n\nlet MyTest () =\n Verifier.Verify(value).Snapshot(\"\"\"a\nb\"\"\").ToTask()\n"); + "module Tests\n\nlet MyTest () =\n Verifier.Verify(value).Snapshot(\n \"\"\"\n a\n b\n \"\"\").ToTask()\n"); } finally { diff --git a/src/DiffEngine.Tests/InlinePatcherFsTests.cs b/src/DiffEngine.Tests/InlinePatcherFsTests.cs index 86b6aa92..b941411f 100644 --- a/src/DiffEngine.Tests/InlinePatcherFsTests.cs +++ b/src/DiffEngine.Tests/InlinePatcherFsTests.cs @@ -28,10 +28,10 @@ await Assert.That(newSource).IsEqualTo( Test(" Verifier.Verify(15).Snapshot(\"new\").ToTask() |> Async.AwaitTask")); } - // The content of a triple-quoted literal is verbatim, so it starts on the delimiter's line - // and every line after it sits at the left margin. An indent would be snapshot content + // 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] - public async Task MultiLineContentIsNotIndented() + public async Task MultiLineContentIsIndented() { var source = Test(" Verifier.Verify(15).Snapshot().ToTask() |> Async.AwaitTask"); @@ -39,13 +39,18 @@ public async Task MultiLineContentIsNotIndented() await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).IsEqualTo( - Test(" Verifier.Verify(15).Snapshot(\"\"\"a\nb\"\"\").ToTask() |> Async.AwaitTask")); + Test( + " Verifier.Verify(15).Snapshot(\n" + + " \"\"\"\n" + + " a\n" + + " b\n" + + " \"\"\").ToTask() |> Async.AwaitTask")); } - // The closing delimiter would land at column 0..2, left of the statement, and F# reads that as - // the end of the statement rather than as part of it. One source line instead + // Content ending in a newline is a blank line before the closing delimiter, which is what + // stops the layout being ambiguous with content that does not [Test] - public async Task ContentEndingInANewlineTakesTheEscapedForm() + public async Task ContentEndingInANewline() { var source = Test(" Verifier.Verify(15).Snapshot().ToTask() |> Async.AwaitTask"); @@ -53,12 +58,19 @@ public async Task ContentEndingInANewlineTakesTheEscapedForm() await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).IsEqualTo( - Test(" Verifier.Verify(15).Snapshot(\"a\\nb\\n\").ToTask() |> Async.AwaitTask")); + Test( + " Verifier.Verify(15).Snapshot(\n" + + " \"\"\"\n" + + " a\n" + + " b\n" + + "\n" + + " \"\"\").ToTask() |> Async.AwaitTask")); } - // Same content, deeper call site: the last line no longer reaches the statement's column + // Nothing about the call site changes the form any more: the literal is indented wherever it + // lands, so a deeper one only indents further [Test] - public async Task DeepCallSiteTakesTheEscapedForm() + public async Task DeepCallSiteIndentsFurther() { var source = Test( " let inner () =\n" + @@ -68,19 +80,34 @@ public async Task DeepCallSiteTakesTheEscapedForm() var status = TryApply(source, 6, InlinePatchMode.Set, null, "a\nb", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); - await Assert.That(newSource).Contains("Snapshot(\"a\\nb\").ToTask()"); + await Assert.That(newSource).Contains( + " Verifier.Verify(15).Snapshot(\n" + + " \"\"\"\n" + + " a\n" + + " b\n" + + " \"\"\").ToTask()"); } [Test] public async Task ReplaceTripleQuotedLiteral() { - var source = Test(" Verifier.Verify(15).Snapshot(\"\"\"old1\nold2\"\"\").ToTask() |> Async.AwaitTask"); + var literal = + "\"\"\"\n" + + " old1\n" + + " old2\n" + + " \"\"\""; + var source = Test(" Verifier.Verify(15).Snapshot(" + literal + ").ToTask() |> Async.AwaitTask"); - var status = TryApply(source, 5, InlinePatchMode.Set, "\"\"\"old1\nold2\"\"\"", "new1\nnew2", out var newSource, out _); + var status = TryApply(source, 5, InlinePatchMode.Set, literal, "new1\nnew2", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).IsEqualTo( - Test(" Verifier.Verify(15).Snapshot(\"\"\"new1\nnew2\"\"\").ToTask() |> Async.AwaitTask")); + Test( + " Verifier.Verify(15).Snapshot(\n" + + " \"\"\"\n" + + " new1\n" + + " new2\n" + + " \"\"\").ToTask() |> Async.AwaitTask")); } [Test] @@ -91,7 +118,7 @@ public async Task ReplacementUsesFileEol() var status = TryApply(source, 5, InlinePatchMode.Set, "\"old\"", "a\nb", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); - await Assert.That(newSource).Contains("Snapshot(\"\"\"a\r\nb\"\"\")"); + await Assert.That(newSource).Contains("Snapshot(\r\n \"\"\"\r\n a\r\n b\r\n \"\"\")"); await Assert.That(newSource).DoesNotContain("a\nb"); } @@ -180,12 +207,18 @@ public async Task ValueAnchorGoneAndContentAlreadyThereIsAlreadyApplied() [Test] public async Task ValueAnchorAcrossAMultiLineLiteral() { - var source = Test(" Verifier.Verify(15).Snapshot(\"\"\"old1\nold2\"\"\").ToTask()"); + var source = Test( + " Verifier.Verify(15).Snapshot(\n" + + " \"\"\"\n" + + " old1\n" + + " old2\n" + + " \"\"\").ToTask()"); var status = TryApply(source, 5, InlinePatchMode.Set, null, "new", out var newSource, out _, originalValue: "old1\nold2"); await Assert.That(status).IsEqualTo(PatchStatus.Applied); - await Assert.That(newSource).Contains("Snapshot(\"new\")"); + // The old literal started its own line, so the new one keeps that line + await Assert.That(newSource).Contains("Snapshot(\n \"new\").ToTask()"); } static string TwoTests(string literalA, string literalB) => @@ -344,7 +377,11 @@ public async Task AppendMultiLineContent() await Assert.That(newSource).IsEqualTo( Test( " Verifier.Verify(15)\n" + - " .Snapshot(\"\"\"a\nb\"\"\").ToTask()")); + " .Snapshot(\n" + + " \"\"\"\n" + + " a\n" + + " b\n" + + " \"\"\").ToTask()")); } [Test] diff --git a/src/DiffEngine/Inline/CsLanguage.cs b/src/DiffEngine/Inline/CsLanguage.cs index 39699a3a..313d3f1f 100644 --- a/src/DiffEngine/Inline/CsLanguage.cs +++ b/src/DiffEngine/Inline/CsLanguage.cs @@ -6,6 +6,12 @@ sealed class CsLanguage : SourceLanguage public override string Render(string content, string indent, string eol) => CsStringLiteral.Render(content, indent, eol); + /// + /// The compiler already did it: a raw string arrives with its first line and its closing + /// indentation gone. + /// + public override string SnapshotValue(string literalValue) => literalValue; + public override bool TryParse(string expression, [NotNullWhen(true)] out string? value) => CsStringLiteral.TryParse(expression, out value); diff --git a/src/DiffEngine/Inline/CsStringLiteral.cs b/src/DiffEngine/Inline/CsStringLiteral.cs index f911b73b..6f892a7c 100644 --- a/src/DiffEngine/Inline/CsStringLiteral.cs +++ b/src/DiffEngine/Inline/CsStringLiteral.cs @@ -3,6 +3,11 @@ namespace DiffEngine; /// /// Renders snapshot text as a C# raw string literal, and parses C# string literal /// expressions back to their runtime values. +/// +/// The shapes it writes, and most of the reading, are shared with +/// through . What is C#'s own is here: a delimiter that widens to hold +/// any content, and the escapes a regular literal can carry. +/// /// public static class CsStringLiteral { @@ -17,62 +22,9 @@ public static class CsStringLiteral public static string Render(string content, string indent, string eol) => content.IndexOf('\n') == -1 && content.IndexOf('\r') == -1 - ? RenderRegular(content) + ? StringLiteral.RenderRegular(content) : RenderRaw(content, indent, eol); - /// - /// Renders single line content as a regular literal, escaping what the form cannot hold - /// verbatim. - /// - static string RenderRegular(string content) - { - var builder = new StringBuilder(content.Length + 2); - builder.Append('"'); - foreach (var ch in content) - { - switch (ch) - { - case '\\': - builder.Append("\\\\"); - continue; - case '"': - builder.Append("\\\""); - continue; - case '\0': - builder.Append("\\0"); - continue; - case '\a': - builder.Append("\\a"); - continue; - case '\b': - builder.Append("\\b"); - continue; - case '\f': - builder.Append("\\f"); - continue; - case '\t': - builder.Append("\\t"); - continue; - case '\v': - builder.Append("\\v"); - continue; - } - - // Everything else a literal cannot carry as itself - if (ch < ' ' || ch == '\u007f') - { - builder.Append("\\u"); - builder.Append(((int) ch).ToString("x4")); - continue; - } - - builder.Append(ch); - } - - builder.Append('"'); - return builder.ToString(); - } - /// /// Renders (\n newlines) as a multi-line raw string literal. /// The returned text starts with the opening quotes (no leading indent on the first line) @@ -90,54 +42,10 @@ public static string RenderRaw(string content, string indent, string eol) return "\"\""; } - if (content.IndexOf('\r') != -1) - { - // Content is meant to arrive \n normalized. Be defensive: a stray \r would - // otherwise be emitted into the literal as content, corrupting the snapshot - content = SourceLanguage.NormalizeNewlines(content); - } - - var delimiter = new string('"', Math.Max(3, LongestQuoteRun(content) + 1)); - var builder = new StringBuilder(); - builder.Append(delimiter); - builder.Append(eol); - foreach (var line in content.Split('\n')) - { - if (line.Length > 0) - { - builder.Append(indent); - builder.Append(line); - } - - builder.Append(eol); - } - - builder.Append(indent); - builder.Append(delimiter); - return builder.ToString(); - } - - static int LongestQuoteRun(string content) - { - var longest = 0; - var current = 0; - foreach (var ch in content) - { - if (ch == '"') - { - current++; - if (current > longest) - { - longest = current; - } - } - else - { - current = 0; - } - } - - return longest; + // Three quotes, or one more than the longest run in the content, which is the widening + // F# does not have and the reason it needs a fallback where C# does not + var delimiter = new string('"', Math.Max(3, StringLiteral.LongestQuoteRun(content) + 1)); + return StringLiteral.RenderMultiLine(content, indent, eol, delimiter); } /// @@ -176,7 +84,7 @@ public static bool TryParse(string expression, [NotNullWhen(true)] out string? v /// first character of the literal: '"' or '@'). On success is the /// index one past the closing quote. The value is NOT newline normalized. /// - internal static bool TryScanLiteral(string text, int start, out string? value, out int end) + static bool TryScanLiteral(string text, int start, out string? value, out int end) { value = null; end = start; @@ -199,7 +107,7 @@ internal static bool TryScanLiteral(string text, int start, out string? value, o return false; } - var quotes = QuoteRunLength(text, index); + var quotes = StringLiteral.QuoteRunLength(text, index); if (quotes >= 3) { if (verbatim) @@ -207,12 +115,12 @@ internal static bool TryScanLiteral(string text, int start, out string? value, o return false; } - return TryScanRaw(text, index, quotes, out value, out end); + return StringLiteral.TryScanMultiLine(text, index, quotes, out value, out end); } if (verbatim) { - return TryScanVerbatim(text, index + 1, out value, out end); + return StringLiteral.TryScanVerbatim(text, index + 1, out value, out end); } if (quotes == 2) @@ -226,141 +134,6 @@ internal static bool TryScanLiteral(string text, int start, out string? value, o return TryScanRegular(text, index + 1, out value, out end); } - static int QuoteRunLength(string text, int index) - { - var count = 0; - while (index + count < text.Length && - text[index + count] == '"') - { - count++; - } - - return count; - } - - static bool TryScanRaw(string text, int start, int quotes, out string? value, out int end) - { - value = null; - end = start; - var contentStart = start + quotes; - // Find the closing delimiter: a run of quotes with length >= quotes. - // Content quote runs are shorter than the delimiter by the language rules. - var index = contentStart; - while (true) - { - if (index >= text.Length) - { - return false; - } - - if (text[index] != '"') - { - index++; - continue; - } - - var run = QuoteRunLength(text, index); - if (run >= quotes) - { - break; - } - - index += run; - } - - var contentEnd = index; - end = index + quotes; - var content = text.Substring(contentStart, contentEnd - contentStart); - if (!content.Contains('\n')) - { - // Single line raw string: content is verbatim. - value = content; - return true; - } - - // Multi line raw string: - // * first line (after the opening quotes) must be whitespace only and is dropped - // * the last line holds the closing quotes; its leading whitespace is the indent - // stripped from every content line, and the line itself is dropped - var normalized = SourceLanguage.NormalizeNewlines(content); - var lines = normalized.Split('\n'); - var first = lines[0]; - if (first.Trim().Length > 0) - { - return false; - } - - var closeIndent = lines[^1]; - if (closeIndent.Trim().Length > 0) - { - return false; - } - - var builder = new StringBuilder(); - for (var lineIndex = 1; lineIndex < lines.Length - 1; lineIndex++) - { - if (lineIndex > 1) - { - builder.Append('\n'); - } - - var line = lines[lineIndex]; - if (line.Length == 0) - { - continue; - } - - if (line.StartsWith(closeIndent, StringComparison.Ordinal)) - { - builder.Append(line, closeIndent.Length, line.Length - closeIndent.Length); - continue; - } - - if (line.Trim().Length == 0) - { - // Whitespace-only line shorter than the indent - continue; - } - - // Malformed indentation - return false; - } - - value = builder.ToString(); - return true; - } - - static bool TryScanVerbatim(string text, int start, out string? value, out int end) - { - value = null; - end = start; - var builder = new StringBuilder(); - var index = start; - while (index < text.Length) - { - var ch = text[index]; - if (ch == '"') - { - if (index + 1 < text.Length && - text[index + 1] == '"') - { - builder.Append('"'); - index += 2; - continue; - } - - value = builder.ToString(); - end = index + 1; - return true; - } - - builder.Append(ch); - index++; - } - - return false; - } - static bool TryScanRegular(string text, int start, out string? value, out int end) { value = null; @@ -437,23 +210,23 @@ static bool TryScanRegular(string text, int start, out string? value, out int en builder.Append('\v'); break; case 'u': - if (!TryReadHex(text, ref index, 4, 4, out var utf16)) + if (!StringLiteral.TryReadHex(text, ref index, 4, 4, out var utf16)) { return false; } - builder.Append((char)utf16); + builder.Append((char) utf16); break; case 'x': - if (!TryReadHex(text, ref index, 1, 4, out var variable)) + if (!StringLiteral.TryReadHex(text, ref index, 1, 4, out var variable)) { return false; } - builder.Append((char)variable); + builder.Append((char) variable); break; case 'U': - if (!TryReadHex(text, ref index, 8, 8, out var codePoint)) + if (!StringLiteral.TryReadHex(text, ref index, 8, 8, out var codePoint)) { return false; } @@ -463,7 +236,7 @@ static bool TryScanRegular(string text, int start, out string? value, out int en return false; } - builder.Append(char.ConvertFromUtf32((int)codePoint)); + builder.Append(char.ConvertFromUtf32((int) codePoint)); break; default: return false; @@ -472,20 +245,4 @@ static bool TryScanRegular(string text, int start, out string? value, out int en return false; } - - static bool TryReadHex(string text, ref int index, int min, int max, out uint result) - { - result = 0; - var count = 0; - while (count < max && - index < text.Length && - Uri.IsHexDigit(text[index])) - { - result = (result << 4) + (uint)Uri.FromHex(text[index]); - index++; - count++; - } - - return count >= min; - } } diff --git a/src/DiffEngine/Inline/FsLanguage.cs b/src/DiffEngine/Inline/FsLanguage.cs index 9cd6f4f8..6f07186e 100644 --- a/src/DiffEngine/Inline/FsLanguage.cs +++ b/src/DiffEngine/Inline/FsLanguage.cs @@ -13,6 +13,9 @@ sealed class FsLanguage : SourceLanguage public override string Render(string content, string indent, string eol) => FsStringLiteral.Render(content, indent, eol); + public override string SnapshotValue(string literalValue) => + FsStringLiteral.StripLayout(literalValue); + public override bool TryParse(string expression, [NotNullWhen(true)] out string? value) => FsStringLiteral.TryParse(expression, out value); @@ -20,14 +23,6 @@ public override bool TryParse(string expression, [NotNullWhen(true)] out string? internal override char NameSeparator => '='; - /// - /// The content of a multi-line literal is verbatim, so it starts hard against the opening - /// delimiter and there is nothing to be gained by giving that delimiter a line of its own. - /// Moving it down a line would also move the closing delimiter left, which is the one thing - /// exists to stop. - /// - internal override bool LiteralOnOwnLine => false; - /// /// F# does not apply the implicit conversion that lets a SettingsTask be awaited, so an F# /// test ends the chain with ToTask. Snapshot returns the SettingsTask and ToTask does not, so diff --git a/src/DiffEngine/Inline/FsStringLiteral.cs b/src/DiffEngine/Inline/FsStringLiteral.cs index 4a87ccf1..8ede64d1 100644 --- a/src/DiffEngine/Inline/FsStringLiteral.cs +++ b/src/DiffEngine/Inline/FsStringLiteral.cs @@ -1,175 +1,84 @@ namespace DiffEngine; /// -/// Renders snapshot text as an F# string literal, and parses F# string literal expressions back to -/// their runtime values. +/// Renders snapshot text as an F# string literal, and reads one back as the snapshot it holds. /// -/// The peer of , and its indent means something else entirely. F# has -/// no raw string: a triple-quoted one is verbatim from the character after the opening delimiter -/// to the one before the closing delimiter, with no first line dropped and no common indentation -/// stripped, so an indent written into it would be snapshot content. Multi-line content is -/// therefore written hard against the left margin, and the indent is a floor the result has to -/// clear rather than a prefix to add. +/// The peer of , and it writes the same shapes: one line for one +/// line, and a triple-quoted literal with its content indented under the call otherwise. The +/// difference is who takes the layout off. C# has raw strings, so its compiler drops the first +/// line and the closing delimiter's indentation and hands the caller the snapshot. F# has no such +/// form: a triple-quoted literal is verbatim, so what F# hands over still carries the line break +/// after the opening delimiter and the indentation of every line. +/// +/// +/// So that trimming is a convention between whoever writes the literal and whoever reads it, and +/// this class is both ends of it: writes the shape and +/// takes it back off. A test library comparing an F# +/// expected argument must go through that, or every F# snapshot differs from itself by an indent +/// and never passes. The alternative was writing snapshots at the left margin, which F#'s offside +/// rule then rejects for anything ending in a newline. /// /// public static class FsStringLiteral { /// /// Renders (\n newlines) as an F# string literal expression: a - /// verbatim multi-line one where the layout allows it, and a regular literal - one source - /// line, newlines escaped - everywhere else. + /// regular literal when it is a single line, and an indented triple-quoted one otherwise. /// /// Snapshot text with \n newlines. - /// - /// The indentation the literal is being spliced at. Not a prefix: the column that whatever - /// follows the literal has to reach. See . - /// + /// Whitespace prefix for content lines and the closing delimiter. /// The target file's line ending ("\r\n" or "\n"). public static string Render(string content, string indent, string eol) { if (content.IndexOf('\n') == -1 && content.IndexOf('\r') == -1) { - return RenderRegular(content); + return StringLiteral.RenderRegular(content); } - var multiLine = RenderMultiLine(content, eol); - if (ClearsOffsideLine(multiLine, indent)) + if (!CanTripleQuote(content)) { - return multiLine; + // F# cannot widen a delimiter the way C# can (FS1232), so content that runs into one + // has no multi-line form at all. A regular literal on one source line always works, + // whatever it costs in escapes + return StringLiteral.RenderRegular(SourceLanguage.NormalizeNewlines(content)); } - // No layout to break: one source line, whatever it costs in escapes - return RenderRegular(SourceLanguage.NormalizeNewlines(content)); + return StringLiteral.RenderMultiLine(content, indent, eol, "\"\"\""); } /// - /// Whether a multi-line literal can be spliced at without breaking - /// F#'s offside rule. - /// - /// The content is verbatim, so its last line decides where the closing delimiter sits, and - /// therefore where the closing paren and any chained call after it sit. F# requires those to - /// be at or right of the column the statement started in, and a snapshot whose last line is - /// short - anything ending in a newline, for a start - puts them left of it. That is not a - /// formatting complaint: the file no longer compiles. - /// - /// - /// is the splice site's indentation, which is at or right of the - /// statement's own, so measuring against it is the conservative side of the rule. Measured in - /// characters because F# rejects tabs in source outright (FS1161), which makes a column count - /// and a character count the same number. - /// + /// Whether a triple-quoted literal can hold this content. A quote at either end would sit + /// against the delimiter and be read as part of it, and a run of three anywhere would close + /// the literal early. /// - public static bool ClearsOffsideLine(string rendered, string indent) => - rendered.Length - (rendered.LastIndexOf('\n') + 1) >= indent.Length; - - /// - /// Renders content as a regular literal on one source line, escaping what the form cannot hold - /// verbatim - newlines included, which is what makes this the form that cannot break the - /// layout around it. - /// - static string RenderRegular(string content) - { - var builder = new StringBuilder(content.Length + 2); - builder.Append('"'); - foreach (var ch in content) - { - switch (ch) - { - case '\\': - builder.Append("\\\\"); - continue; - case '"': - builder.Append("\\\""); - continue; - case '\n': - builder.Append("\\n"); - continue; - case '\r': - builder.Append("\\r"); - continue; - case '\a': - builder.Append("\\a"); - continue; - case '\b': - builder.Append("\\b"); - continue; - case '\f': - builder.Append("\\f"); - continue; - case '\t': - builder.Append("\\t"); - continue; - case '\v': - builder.Append("\\v"); - continue; - } - - // Everything else a literal cannot carry as itself. F# has no \0 or \e escape, so the - // \u form covers every remaining control character rather than only the exotic ones - if (ch < ' ' || ch == '\u007f') - { - builder.Append("\\u"); - builder.Append(((int) ch).ToString("x4")); - continue; - } - - builder.Append(ch); - } - - builder.Append('"'); - return builder.ToString(); - } + static bool CanTripleQuote(string content) => + content[0] != '"' && + content[content.Length - 1] != '"' && + content.IndexOf("\"\"\"", StringComparison.Ordinal) == -1; /// - /// Renders (\n newlines) as a multi-line literal. Triple-quoted - /// where it can be, since that form escapes nothing at all and so carries a snapshot as it - /// reads; verbatim where the content would collide with the delimiter. + /// The snapshot a triple-quoted literal was written to hold: the value F# produced for it, + /// with the line break after the opening delimiter and the closing delimiter's indentation + /// taken back off. /// - /// The content lines are written as they are, with no indentation added: F# takes them - /// verbatim, so an indent would be snapshot content rather than layout. + /// Applied to a value rather than to source text, because F# does not implement + /// and a test library never sees the literal + /// it was handed. A value not in that shape is returned unchanged: a single line snapshot, a + /// literal written some other way, or a snapshot that genuinely looks like layout and is + /// therefore not one this ever wrote. /// /// - /// Snapshot text with \n newlines. - /// The target file's line ending ("\r\n" or "\n"). - public static string RenderMultiLine(string content, string eol) - { - if (content.Length == 0) - { - return "\"\""; - } - - if (content.IndexOf('\r') != -1) - { - // Content is meant to arrive \n normalized. Be defensive: a stray \r would - // otherwise be emitted into the literal as content, corrupting the snapshot - content = SourceLanguage.NormalizeNewlines(content); - } - - var body = content.Replace("\n", eol); - if (CanTripleQuote(content)) - { - return $"\"\"\"{body}\"\"\""; - } - - return $"@\"{body.Replace("\"", "\"\"")}\""; - } - - /// - /// A triple-quoted literal has no way to escape its own delimiter and no way to widen it, so - /// content that runs into the delimiter has to take the verbatim form instead. A quote at - /// either end counts: it would sit against the delimiter and be read as part of it. - /// - static bool CanTripleQuote(string content) => - content[0] != '"' && - content[content.Length - 1] != '"' && - content.IndexOf("\"\"\"", StringComparison.Ordinal) == -1; + public static string StripLayout(string value) => + StringLiteral.TryStripLayout(SourceLanguage.NormalizeNewlines(value), out var stripped) + ? stripped + : value; /// - /// Parses an F# string literal expression back to its runtime value. - /// Supports triple-quoted ("""..."""), verbatim (@"...") and regular ("...") literals. - /// Returns false for interpolated strings, byte strings, concatenations, or any other - /// expression. Newlines in the returned value are normalized to \n. + /// 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. /// public static bool TryParse(string expression, [NotNullWhen(true)] out string? value) { @@ -228,13 +137,15 @@ static bool TryScanLiteral(string text, int start, out string? value, out int en { // There is no verbatim triple-quoted form, so a run of quotes after @" is an escaped // quote and the rest of the string, not a delimiter - return TryScanVerbatim(text, index + 1, out value, out end); + return StringLiteral.TryScanVerbatim(text, index + 1, out value, out end); } - var quotes = QuoteRunLength(text, index); + var quotes = StringLiteral.QuoteRunLength(text, index); if (quotes >= 3) { - return TryScanTripleQuoted(text, index, out value, out end); + // 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); } if (quotes == 2) @@ -248,75 +159,6 @@ static bool TryScanLiteral(string text, int start, out string? value, out int en return TryScanRegular(text, index + 1, out value, out end); } - static int QuoteRunLength(string text, int index) - { - var count = 0; - while (index + count < text.Length && - text[index + count] == '"') - { - count++; - } - - return count; - } - - /// - /// Content is whatever sits between the delimiters, exactly. Unlike a C# raw string there is - /// no first line to drop and no indent to strip. - /// - static bool TryScanTripleQuoted(string text, int start, out string? value, out int end) - { - value = null; - end = start; - var contentStart = start + 3; - var index = contentStart; - while (index < text.Length) - { - if (text[index] == '"' && - QuoteRunLength(text, index) >= 3) - { - value = text.Substring(contentStart, index - contentStart); - end = index + 3; - return true; - } - - index++; - } - - return false; - } - - static bool TryScanVerbatim(string text, int start, out string? value, out int end) - { - value = null; - end = start; - var builder = new StringBuilder(); - var index = start; - while (index < text.Length) - { - var ch = text[index]; - if (ch == '"') - { - if (index + 1 < text.Length && - text[index + 1] == '"') - { - builder.Append('"'); - index += 2; - continue; - } - - value = builder.ToString(); - end = index + 1; - return true; - } - - builder.Append(ch); - index++; - } - - return false; - } - static bool TryScanRegular(string text, int start, out string? value, out int end) { value = null; @@ -382,7 +224,7 @@ static bool TryScanRegular(string text, int start, out string? value, out int en builder.Append('\v'); break; case 'u': - if (!TryReadHex(text, ref index, 4, out var utf16)) + if (!StringLiteral.TryReadHex(text, ref index, 4, 4, out var utf16)) { return false; } @@ -390,7 +232,7 @@ static bool TryScanRegular(string text, int start, out string? value, out int en builder.Append((char) utf16); break; case 'x': - if (!TryReadHex(text, ref index, 2, out var byteValue)) + if (!StringLiteral.TryReadHex(text, ref index, 2, 2, out var byteValue)) { return false; } @@ -398,7 +240,7 @@ static bool TryScanRegular(string text, int start, out string? value, out int en builder.Append((char) byteValue); break; case 'U': - if (!TryReadHex(text, ref index, 8, out var codePoint)) + if (!StringLiteral.TryReadHex(text, ref index, 8, 8, out var codePoint)) { return false; } @@ -448,22 +290,6 @@ static bool TryScanRegular(string text, int start, out string? value, out int en return false; } - static bool TryReadHex(string text, ref int index, int count, out uint result) - { - result = 0; - var read = 0; - while (read < count && - index < text.Length && - Uri.IsHexDigit(text[index])) - { - result = (result << 4) + (uint) Uri.FromHex(text[index]); - index++; - read++; - } - - return read == count; - } - static bool TryReadTrigraph(string text, ref int index, char first, out char result) { result = '\0'; diff --git a/src/DiffEngine/Inline/InlinePatcher.cs b/src/DiffEngine/Inline/InlinePatcher.cs index 4cc074c2..0c8e4144 100644 --- a/src/DiffEngine/Inline/InlinePatcher.cs +++ b/src/DiffEngine/Inline/InlinePatcher.cs @@ -193,10 +193,7 @@ static PatchStatus InsertOrCheck( } var namedIndent = IndentForSpan(source, lineStarts, expected.ListStart, fileUnit); - var namedRendered = scan.Language.Render( - newContent, - scan.Language.IndentFor(LeadingWhitespace(source, lineStarts, expected.ListStart), namedIndent), - eol); + var namedRendered = scan.Language.Render(newContent, namedIndent, eol); newSource = Splice(source, expected.ListStart, expected.ListStart, $"{scan.Language.NamePrefix(parameterName)}{namedRendered}, "); return PatchStatus.Applied; } @@ -352,8 +349,8 @@ static PatchStatus TryAppend( ? statementIndent + unit : LeadingWhitespace(source, lineStarts, insertAt - 1); var contentIndent = callIndent + unit; - var rendered = scan.Language.Render(newContent, scan.Language.IndentFor(statementIndent, contentIndent), eol); - var argument = OnOwnLine(scan.Language, rendered, contentIndent, eol); + var rendered = scan.Language.Render(newContent, contentIndent, eol); + var argument = OnOwnLine(rendered, contentIndent, eol); newSource = Splice(source, insertAt, insertAt, $"{eol}{callIndent}.{methodName}({argument})"); return PatchStatus.Applied; } @@ -878,25 +875,22 @@ static void TrimSpan(string source, SourceScan scan, ref int start, ref int end) static string RenderArgument(SourceLanguage language, string source, List lineStarts, int spanStart, string newContent, string eol, string fileUnit) { var indent = IndentForSpan(source, lineStarts, spanStart, fileUnit); - var rendered = language.Render(newContent, language.IndentFor(LeadingWhitespace(source, lineStarts, spanStart), indent), eol); + var rendered = language.Render(newContent, indent, eol); if (StartsLine(source, lineStarts, spanStart)) { return rendered; } - return OnOwnLine(language, rendered, indent, eol); + return OnOwnLine(rendered, indent, eol); } /// - /// Puts a raw literal on its own line rather than trailing the open paren, so its opening - /// delimiter sits with its content and its closing one. A regular literal stays where it is, - /// since it has nothing to line up with, and so does every literal in a language whose - /// multi-line form carries its first line of content on the delimiter's line. + /// Puts a multi-line literal on its own line rather than trailing the open paren, so its + /// opening delimiter sits with its content and its closing one. A regular literal stays where + /// it is, since it has nothing to line up with. /// - static string OnOwnLine(SourceLanguage language, string rendered, string indent, string eol) => - !language.LiteralOnOwnLine || rendered.IndexOf('\n') == -1 - ? rendered - : $"{eol}{indent}{rendered}"; + static string OnOwnLine(string rendered, string indent, string eol) => + rendered.IndexOf('\n') == -1 ? rendered : $"{eol}{indent}{rendered}"; /// /// True when only whitespace precedes the offset on its line. diff --git a/src/DiffEngine/Inline/SourceLanguage.cs b/src/DiffEngine/Inline/SourceLanguage.cs index 908c14fe..c2679d8c 100644 --- a/src/DiffEngine/Inline/SourceLanguage.cs +++ b/src/DiffEngine/Inline/SourceLanguage.cs @@ -38,14 +38,21 @@ public static SourceLanguage ForFile(string path) /// language. /// /// Snapshot text with \n newlines. - /// - /// The indentation of the line the literal will start on. A prefix for the content lines where - /// the multi-line form indents them (C#), and a floor the result has to clear where it cannot - /// (F#, whose content is verbatim - see ). - /// + /// Whitespace prefix for the content lines of a multi-line literal. /// The target file's line ending ("\r\n" or "\n"). public abstract string Render(string content, string indent, string eol); + /// + /// The snapshot an expected argument holds, given the value the compiler produced for it. + /// + /// The identity for C#, whose compiler has already taken the layout off a raw string, and the + /// place F# pays for not having one: see . A test library + /// comparing an expected argument goes through this rather than branching on the language + /// itself. + /// + /// + public abstract string SnapshotValue(string literalValue); + /// /// Parses a string literal expression back to its runtime value. Returns false for /// interpolated strings, concatenations, or any other expression. Newlines in the returned @@ -78,25 +85,6 @@ public static SourceLanguage ForFile(string path) /// internal abstract char NameSeparator { get; } - /// - /// Whether a multi-line literal goes on the line below the open paren. True where the literal's - /// opening delimiter has content to line up with, and false where the content is verbatim and - /// so has to start hard against the delimiter. - /// - internal virtual bool LiteralOnOwnLine => true; - - /// - /// Which indentation is given, out of the two the call site has: the line - /// a literal of its own would sit on, or the statement the literal ends up inside. - /// - /// A language that indents its literal is told where to indent it to. One that cannot is told - /// what its result has to clear, which is the statement's indentation rather than the line's, - /// because that is the column F#'s layout rule is measured from. - /// - /// - internal string IndentFor(string statementIndent, string ownLineIndent) => - LiteralOnOwnLine ? ownLineIndent : statementIndent; - /// /// A chained call that a Snapshot call has to be appended in front of rather than after, or /// null when the end of the chain is always the insertion point. diff --git a/src/DiffEngine/Inline/StringLiteral.cs b/src/DiffEngine/Inline/StringLiteral.cs new file mode 100644 index 00000000..6070ec38 --- /dev/null +++ b/src/DiffEngine/Inline/StringLiteral.cs @@ -0,0 +1,303 @@ +/// +/// The parts of writing and reading a string literal that C# and F# now share. +/// +/// They share them because they write the same shapes: a regular literal for one line, and an +/// indented multi-line one whose first line and closing indentation are layout rather than +/// content. C# has that second form in the language; F# has it by agreement between whoever +/// writes the literal and whoever reads it back, which is what +/// documents. Either way the text is the same, so it is produced and consumed here once. +/// +/// +/// What is left per language is small and real: which delimiter can hold the content, and what +/// the escapes in a regular literal mean. +/// +/// +static class StringLiteral +{ + /// + /// Renders content as a regular literal, escaping what the form cannot hold verbatim. + /// + /// One escape set for both languages, which costs a NUL written as \u0000 rather than + /// C#'s shorter \0 - F# has no \0, and a snapshot containing a NUL is not worth + /// a second implementation. + /// + /// + public static string RenderRegular(string content) + { + var builder = new StringBuilder(content.Length + 2); + builder.Append('"'); + foreach (var ch in content) + { + switch (ch) + { + case '\\': + builder.Append("\\\\"); + continue; + case '"': + builder.Append("\\\""); + continue; + case '\n': + builder.Append("\\n"); + continue; + case '\r': + builder.Append("\\r"); + continue; + case '\a': + builder.Append("\\a"); + continue; + case '\b': + builder.Append("\\b"); + continue; + case '\f': + builder.Append("\\f"); + continue; + case '\t': + builder.Append("\\t"); + continue; + case '\v': + builder.Append("\\v"); + continue; + } + + // Everything else a literal cannot carry as itself + if (ch < ' ' || ch == '\u007f') + { + builder.Append("\\u"); + builder.Append(((int) ch).ToString("x4")); + continue; + } + + builder.Append(ch); + } + + builder.Append('"'); + return builder.ToString(); + } + + /// + /// Renders (\n newlines) as a multi-line literal delimited by + /// . The result starts with the opening delimiter (no leading + /// indent on the first line) and ends with the closing one (no trailing newline). + /// + /// Snapshot text with \n newlines. + /// Whitespace prefix for content lines and the closing delimiter. + /// The target file's line ending ("\r\n" or "\n"). + /// The quote run that opens and closes the literal. + public static string RenderMultiLine(string content, string indent, string eol, string delimiter) + { + if (content.IndexOf('\r') != -1) + { + // Content is meant to arrive \n normalized. Be defensive: a stray \r would + // otherwise be emitted into the literal as content, corrupting the snapshot + content = SourceLanguage.NormalizeNewlines(content); + } + + var builder = new StringBuilder(); + builder.Append(delimiter); + builder.Append(eol); + foreach (var line in content.Split('\n')) + { + if (line.Length > 0) + { + builder.Append(indent); + builder.Append(line); + } + + builder.Append(eol); + } + + builder.Append(indent); + builder.Append(delimiter); + return builder.ToString(); + } + + public static int LongestQuoteRun(string content) + { + var longest = 0; + var current = 0; + foreach (var ch in content) + { + if (ch == '"') + { + current++; + if (current > longest) + { + longest = current; + } + } + else + { + current = 0; + } + } + + return longest; + } + + public static int QuoteRunLength(string text, int index) + { + var count = 0; + while (index + count < text.Length && + text[index + count] == '"') + { + count++; + } + + return count; + } + + /// + /// Scans a multi-line literal opening at with a run of + /// , and returns what it holds with the layout taken off. + /// + public static bool TryScanMultiLine(string text, int start, int quotes, out string? value, out int end) + { + value = null; + end = start; + var contentStart = start + quotes; + // The closing delimiter is a run of quotes at least as long as the opening one. A run + // inside the content is shorter than that, by the rule that chose the opening length + var index = contentStart; + while (true) + { + if (index >= text.Length) + { + return false; + } + + if (text[index] != '"') + { + index++; + continue; + } + + var run = QuoteRunLength(text, index); + if (run >= quotes) + { + break; + } + + index += run; + } + + end = index + quotes; + var content = text.Substring(contentStart, index - contentStart); + if (!content.Contains('\n')) + { + // Single line: content is verbatim, with no layout to take off + value = content; + return true; + } + + return TryStripLayout(SourceLanguage.NormalizeNewlines(content), out value); + } + + /// + /// Takes the layout off a multi-line literal's content: the first line, which holds nothing + /// but the break after the opening delimiter, the indentation the closing delimiter sits at, + /// and the line that delimiter is on. + /// + /// Returns false when the text is not in that shape - a content line less indented than the + /// closing delimiter, or a first line with something on it - which is a literal nobody wrote + /// to this convention and whose value is therefore whatever it says. + /// + /// + public static bool TryStripLayout(string text, [NotNullWhen(true)] out string? value) + { + value = null; + var lines = text.Split('\n'); + if (lines.Length < 2 || + lines[0].Trim().Length > 0) + { + return false; + } + + var closeIndent = lines[lines.Length - 1]; + if (closeIndent.Trim().Length > 0) + { + return false; + } + + var builder = new StringBuilder(); + for (var index = 1; index < lines.Length - 1; index++) + { + if (index > 1) + { + builder.Append('\n'); + } + + var line = lines[index]; + if (line.Length == 0) + { + continue; + } + + if (line.StartsWith(closeIndent, StringComparison.Ordinal)) + { + builder.Append(line, closeIndent.Length, line.Length - closeIndent.Length); + continue; + } + + if (line.Trim().Length == 0) + { + // Whitespace-only line shorter than the indent + continue; + } + + return false; + } + + value = builder.ToString(); + return true; + } + + /// + /// Scans a verbatim literal, where the only escape is a doubled quote. + /// + public static bool TryScanVerbatim(string text, int start, out string? value, out int end) + { + value = null; + end = start; + var builder = new StringBuilder(); + var index = start; + while (index < text.Length) + { + var ch = text[index]; + if (ch == '"') + { + if (index + 1 < text.Length && + text[index + 1] == '"') + { + builder.Append('"'); + index += 2; + continue; + } + + value = builder.ToString(); + end = index + 1; + return true; + } + + builder.Append(ch); + index++; + } + + return false; + } + + public static bool TryReadHex(string text, ref int index, int min, int max, out uint result) + { + result = 0; + var count = 0; + while (count < max && + index < text.Length && + Uri.IsHexDigit(text[index])) + { + result = (result << 4) + (uint) Uri.FromHex(text[index]); + index++; + count++; + } + + return count >= min; + } +} From 182e5b7ab6368d3e8383703a3af26fbe6bbd2f8a Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Sat, 15 Aug 2026 12:46:34 +1000 Subject: [PATCH 7/8] . --- claude.md | 19 +++++++++++-------- docs/inline.md | 31 +++++++++++-------------------- docs/mdsource/inline.source.md | 31 +++++++++++-------------------- 3 files changed, 33 insertions(+), 48 deletions(-) diff --git a/claude.md b/claude.md index 7ee9ebce..df7da9ce 100644 --- a/claude.md +++ b/claude.md @@ -77,14 +77,17 @@ The source may be C# or F#, decided by the file's extension (`SourceLanguage.For stated on the patch. `InlinePatcher` walks the same structure either way — a name, an argument list, a chain hung off it — and everything per language sits on `SourceLanguage`: the lexing that fills a `SourceScan`, what tells a declaration from a call, and how a literal is written and read -back. F# is the awkward one, because it has no raw string: triple-quoted content is verbatim, so a -multi-line snapshot is written at the left margin, and its last line then decides the column of -everything after the literal. Where that would land left of the statement, F#'s offside rule stops -the file compiling, so the render falls back to a one-line escaped literal — unless nothing follows -the literal on its line, which is the shape a formatter writes and where the rule cannot bite. That -rule is asserted -by compiling patched source with `dotnet fsi` (`FsCompilerRoundTripTests`), because a belief about -F#'s lexis is exactly the kind of thing a second copy of the same belief cannot check. +back. F# is the awkward one, because it has no raw string: its compiler hands over a triple-quoted +literal verbatim, line break after the opening delimiter and every line's indentation included. So +the same shape C# writes is written for F# too and the trimming is a convention - +`SourceLanguage.SnapshotValue` is the reader's half, and a test library that skips it fails every +F# snapshot against itself. Writing content at the left margin instead was tried and abandoned: +F#'s offside rule then rejects anything ending in a newline. The agreement is asserted by compiling +patched source with `dotnet fsi` and applying the trim there in F# (`FsCompilerRoundTripTests`), +because a belief about F#'s lexis is exactly the kind of thing a second copy of the same belief +cannot check. With both languages on the same shapes, the rendering and most of the parsing is one +implementation in `StringLiteral`; what is left per language is delimiter widening, which F# lacks +(FS1232), and the escapes a regular literal carries. A patch is anchored to the call by `OriginalExpression`, the argument's source text from `CallerArgumentExpression`, so a file that moved since the run still patches the right call. F# diff --git a/docs/inline.md b/docs/inline.md index 44980cf0..8ad14379 100644 --- a/docs/inline.md +++ b/docs/inline.md @@ -139,7 +139,9 @@ await Verify(value).Snapshot( A patch says which file it edits, so nothing has to say which language that file is in: `.fs`, `.fsx` and `.fsi` are patched as F#, everything else as C#. The line ending, indentation and encoding rules above are the same either way, and so is everything else on this page — one applier, one queue, one protocol. `SourceLanguage.ForFile` returns the right one, and `FsStringLiteral` is the public peer of `CsStringLiteral`. -What differs is the literal, because F# has no raw string. A triple-quoted string is verbatim from the character after the opening delimiter to the one before the closing one — no first line dropped, no common indentation stripped — so any indent written into it would be snapshot content. Multi-line content therefore starts on the delimiter's line and continues at the left margin, which is the only way a literal spanning lines reads back as the value it was rendered from. That much is checked by compiling patched source with `dotnet fsi` rather than by asserting what F# is believed to mean: +What differs is who takes the layout off. C# has raw strings, so its compiler drops the line break after the opening delimiter and the indentation the closing one sits at, and hands the caller the snapshot. F# has no such form: a triple-quoted literal is verbatim, so what F# hands over still carries that break and the indentation of every line. + +So the same shape is written either way, and for F# the trimming is a convention between whoever writes the literal and whoever reads it back. `FsStringLiteral.Render` writes it and `SourceLanguage.SnapshotValue` takes it off - the identity for C#, since its compiler already did. A test library comparing an F# expected argument has to go through that, or every snapshot differs from itself by an indent and never passes. ```fsharp // single line content @@ -150,31 +152,20 @@ Verifier.Verify(value) // multi-line content Verifier.Verify(value) .Snapshot( -"""line one -line two""") + """ + line one + line two + """) .ToTask() ``` -Writing content at the left margin has a consequence: the content's last line decides the column of the closing delimiter, and so of the closing paren and anything chained after it. F#'s offside rule wants those at or right of the column the statement started in, and a snapshot ending in a newline — or in a short line, at a deeply indented call site — puts them left of it. That is not a formatting complaint; the file stops compiling. So the multi-line form is used only where its last line clears the call site's indentation, and everything else falls back to a regular literal on one source line, newlines escaped: - -```fsharp -Verifier.Verify(value).Snapshot("line one\nline two\n").ToTask() -``` +Content ending in a newline is a blank line before the closing delimiter, exactly as in C#, which is what keeps it distinguishable from content that does not. -That measurement is about what follows the literal *on its line*. A call split across lines leaves nothing there — the closing paren is below it, at a column the file already chose — and then the verbatim form stands however short the content's last line is. This is the shape Fantomas writes whenever a snapshot spans lines, so a formatted file keeps `"""` in the cases a single-line call would have escaped, and the patched result is already canonical: running the formatter over it changes nothing. +The convention is checked by compiling patched source with `dotnet fsi` and applying the trim there, written out in F# rather than called back into DiffEngine - which is what makes it a test of the agreement rather than of one side of it twice. -```fsharp -Verifier - .Verify(value) - .Snapshot( - """line one -line two -""" - ) - .ToTask() -``` +The alternative, writing content at the left margin so the literal means itself, was tried and abandoned: F# then ends the statement at the closing delimiter, and any snapshot ending in a newline stops the file compiling. -There is also no way to widen the delimiter, so content that would run into it — content containing `"""`, or starting or ending with a quote — takes the verbatim form instead (`@"..."`, quotes doubled). Single line content is always a regular literal, escaping what F# escapes (`\` `"` `\a` `\b` `\f` `\t` `\v` `\n` `\r`) and `\uXXXX` for the rest, since F# has no `\0` or `\e`. +One thing C# can do that F# cannot is widen a delimiter (FS1232), so content containing `"""`, or starting or ending with a quote, has no multi-line form at all and takes a regular literal on one source line. Single line content is always a regular literal, escaping what both languages escape (`\` `"` `\a` `\b` `\f` `\t` `\v` `\n` `\r`) and `\uXXXX` for the rest, since F# has no `\0` or `\e`. Two syntax differences show up in `Append` and in an argument list. F# does not apply the implicit conversion that lets a `SettingsTask` be awaited, so an F# test ends its chain with `ToTask`; `Snapshot` returns the `SettingsTask`, so an appended call goes in front of that rather than after it. And an argument binds to a parameter with `=`, so an inserted named argument is `expected = "..."`. diff --git a/docs/mdsource/inline.source.md b/docs/mdsource/inline.source.md index c2869989..9a9dc229 100644 --- a/docs/mdsource/inline.source.md +++ b/docs/mdsource/inline.source.md @@ -132,7 +132,9 @@ await Verify(value).Snapshot( A patch says which file it edits, so nothing has to say which language that file is in: `.fs`, `.fsx` and `.fsi` are patched as F#, everything else as C#. The line ending, indentation and encoding rules above are the same either way, and so is everything else on this page — one applier, one queue, one protocol. `SourceLanguage.ForFile` returns the right one, and `FsStringLiteral` is the public peer of `CsStringLiteral`. -What differs is the literal, because F# has no raw string. A triple-quoted string is verbatim from the character after the opening delimiter to the one before the closing one — no first line dropped, no common indentation stripped — so any indent written into it would be snapshot content. Multi-line content therefore starts on the delimiter's line and continues at the left margin, which is the only way a literal spanning lines reads back as the value it was rendered from. That much is checked by compiling patched source with `dotnet fsi` rather than by asserting what F# is believed to mean: +What differs is who takes the layout off. C# has raw strings, so its compiler drops the line break after the opening delimiter and the indentation the closing one sits at, and hands the caller the snapshot. F# has no such form: a triple-quoted literal is verbatim, so what F# hands over still carries that break and the indentation of every line. + +So the same shape is written either way, and for F# the trimming is a convention between whoever writes the literal and whoever reads it back. `FsStringLiteral.Render` writes it and `SourceLanguage.SnapshotValue` takes it off - the identity for C#, since its compiler already did. A test library comparing an F# expected argument has to go through that, or every snapshot differs from itself by an indent and never passes. ```fsharp // single line content @@ -143,31 +145,20 @@ Verifier.Verify(value) // multi-line content Verifier.Verify(value) .Snapshot( -"""line one -line two""") + """ + line one + line two + """) .ToTask() ``` -Writing content at the left margin has a consequence: the content's last line decides the column of the closing delimiter, and so of the closing paren and anything chained after it. F#'s offside rule wants those at or right of the column the statement started in, and a snapshot ending in a newline — or in a short line, at a deeply indented call site — puts them left of it. That is not a formatting complaint; the file stops compiling. So the multi-line form is used only where its last line clears the call site's indentation, and everything else falls back to a regular literal on one source line, newlines escaped: - -```fsharp -Verifier.Verify(value).Snapshot("line one\nline two\n").ToTask() -``` +Content ending in a newline is a blank line before the closing delimiter, exactly as in C#, which is what keeps it distinguishable from content that does not. -That measurement is about what follows the literal *on its line*. A call split across lines leaves nothing there — the closing paren is below it, at a column the file already chose — and then the verbatim form stands however short the content's last line is. This is the shape Fantomas writes whenever a snapshot spans lines, so a formatted file keeps `"""` in the cases a single-line call would have escaped, and the patched result is already canonical: running the formatter over it changes nothing. +The convention is checked by compiling patched source with `dotnet fsi` and applying the trim there, written out in F# rather than called back into DiffEngine - which is what makes it a test of the agreement rather than of one side of it twice. -```fsharp -Verifier - .Verify(value) - .Snapshot( - """line one -line two -""" - ) - .ToTask() -``` +The alternative, writing content at the left margin so the literal means itself, was tried and abandoned: F# then ends the statement at the closing delimiter, and any snapshot ending in a newline stops the file compiling. -There is also no way to widen the delimiter, so content that would run into it — content containing `"""`, or starting or ending with a quote — takes the verbatim form instead (`@"..."`, quotes doubled). Single line content is always a regular literal, escaping what F# escapes (`\` `"` `\a` `\b` `\f` `\t` `\v` `\n` `\r`) and `\uXXXX` for the rest, since F# has no `\0` or `\e`. +One thing C# can do that F# cannot is widen a delimiter (FS1232), so content containing `"""`, or starting or ending with a quote, has no multi-line form at all and takes a regular literal on one source line. Single line content is always a regular literal, escaping what both languages escape (`\` `"` `\a` `\b` `\f` `\t` `\v` `\n` `\r`) and `\uXXXX` for the rest, since F# has no `\0` or `\e`. Two syntax differences show up in `Append` and in an argument list. F# does not apply the implicit conversion that lets a `SettingsTask` be awaited, so an F# test ends its chain with `ToTask`; `Snapshot` returns the `SettingsTask`, so an appended call goes in front of that rather than after it. And an argument binds to a parameter with `=`, so an inserted named argument is `expected = "..."`. From 4b6e706d8df0a2bedf78eabc3c103645cff05239 Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Sat, 15 Aug 2026 12:46:46 +1000 Subject: [PATCH 8/8] Update Directory.Build.props --- src/Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Directory.Build.props b/src/Directory.Build.props index c5893e66..d345b00c 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -2,7 +2,7 @@ CS1591;CS0649;NU1608;NU1109 - 20.0.0-beta.17 + 20.0.0-beta.18 1.0.0 Testing, Snapshot, Diff, Compare Launches diff tools based on file extensions. Designed to be consumed by snapshot testing libraries.