diff --git a/claude.md b/claude.md index 492624ae..df7da9ce 100644 --- a/claude.md +++ b/claude.md @@ -73,6 +73,33 @@ 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: 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# +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 933fee21..8ad14379 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 ``` @@ -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 @@ -126,11 +135,65 @@ 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 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 +Verifier.Verify(value) + .Snapshot("the value") + .ToTask() + +// multi-line content +Verifier.Verify(value) + .Snapshot( + """ + line one + line two + """) + .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. + +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. + +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. + +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 = "..."`. + +```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`. `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. + +`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..9a9dc229 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 ``` @@ -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 @@ -119,11 +128,65 @@ 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 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 +Verifier.Verify(value) + .Snapshot("the value") + .ToTask() + +// multi-line content +Verifier.Verify(value) + .Snapshot( + """ + line one + line two + """) + .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. + +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. + +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. + +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 = "..."`. + +```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`. `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. + +`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/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 new file mode 100644 index 00000000..34dc7f53 --- /dev/null +++ b/src/DiffEngine.Tests/FsCompilerRoundTripTests.cs @@ -0,0 +1,204 @@ +/// +/// 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 + + // 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 + + + """); + + 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"); + + // 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( + """ + 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, null, 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..701457dc --- /dev/null +++ b/src/DiffEngine.Tests/FsStringLiteralTests.cs @@ -0,0 +1,272 @@ +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", + "{\n \"name\": \"value\"\n}" + ]; + + // 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.Render("line one\nline two", " ", "\n"); + await Assert.That(rendered).IsEqualTo("\"\"\"\n line one\n line two\n \"\"\""); + } + + [Test] + public async Task RenderMultiLineMatchesCSharp() + { + 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 RenderBlankLineHasNoTrailingWhitespace() + { + 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.Render("a\nb", "\t", "\r\n"); + await Assert.That(rendered).IsEqualTo("\"\"\"\r\n\ta\r\n\tb\r\n\t\"\"\""); + } + + // 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 RenderFallsBackWhenTheDelimiterCannotHoldIt(string content, string expected) + { + 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 RenderKeepsQuotesInTheMiddle() + { + var rendered = FsStringLiteral.Render("a \"quoted\" word\nsecond", " ", "\n"); + await Assert.That(rendered).IsEqualTo("\"\"\"\n a \"quoted\" word\n second\n \"\"\""); + } + + [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.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(); + await Assert.That(value).IsEqualTo(normalized); + } + } + + [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] + public async Task RenderRoundTrips() + { + 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); + } + } + } + } + + // 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("\"\"", "")] + [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\"")] + // 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")] + 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); + } + + [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() + { + 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"); + } + + [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(); + } + + // 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 4b536c9a..310ad86c 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(\n \"\"\"\n a\n b\n \"\"\").ToTask()\n"); + } + finally + { + File.Delete(path); + } + } + [Test] public async Task MissingFileFails() { @@ -422,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 new file mode 100644 index 00000000..b941411f --- /dev/null +++ b/src/DiffEngine.Tests/InlinePatcherFsTests.cs @@ -0,0 +1,686 @@ +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, + 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() + { + 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 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 MultiLineContentIsIndented() + { + 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(\n" + + " \"\"\"\n" + + " a\n" + + " b\n" + + " \"\"\").ToTask() |> Async.AwaitTask")); + } + + // 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 ContentEndingInANewline() + { + 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(\n" + + " \"\"\"\n" + + " a\n" + + " b\n" + + "\n" + + " \"\"\").ToTask() |> Async.AwaitTask")); + } + + // 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 DeepCallSiteIndentsFurther() + { + 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( + " Verifier.Verify(15).Snapshot(\n" + + " \"\"\"\n" + + " a\n" + + " b\n" + + " \"\"\").ToTask()"); + } + + [Test] + public async Task ReplaceTripleQuotedLiteral() + { + 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, literal, "new1\nnew2", out var newSource, out _); + + await Assert.That(status).IsEqualTo(PatchStatus.Applied); + await Assert.That(newSource).IsEqualTo( + Test( + " Verifier.Verify(15).Snapshot(\n" + + " \"\"\"\n" + + " new1\n" + + " new2\n" + + " \"\"\").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(\r\n \"\"\"\r\n a\r\n b\r\n \"\"\")"); + 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 is anchored by the + // previous value instead: the call whose literal still means what the test run saw + [Test] + 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 _, 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 ValueAnchorBeatsAStaleHint() + { + 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, 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(\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); + // 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) => + 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() + { + 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(\n" + + " \"\"\"\n" + + " a\n" + + " b\n" + + " \"\"\").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..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(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(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(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(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(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(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(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(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(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 = 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(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 = 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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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("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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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.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..313d3f1f 100644 --- a/src/DiffEngine/Inline/CsScan.cs +++ b/src/DiffEngine/Inline/CsLanguage.cs @@ -1,34 +1,30 @@ /// -/// 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. + /// The compiler already did it: a raw string arrives with its first line and its closing + /// indentation gone. /// - readonly Dictionary skips = new(); + public override string SnapshotValue(string literalValue) => literalValue; - /// - /// 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(); + public override bool TryParse(string expression, [NotNullWhen(true)] out string? value) => + CsStringLiteral.TryParse(expression, out value); + + internal override string NamePrefix(string name) => $"{name}: "; + + internal override char NameSeparator => ':'; + + internal override bool IsIdentifierChar(char ch) => + char.IsLetterOrDigit(ch) || ch == '_'; - public CsScan(string source) + 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 +34,7 @@ public CsScan(string source) case '/': if (TrySkipComment(source, ref index)) { - AddSkip(start, index); + scan.AddSkip(start, index, comment: true); continue; } @@ -46,7 +42,7 @@ public CsScan(string source) case '\'': if (TrySkipCharLiteral(source, ref index)) { - AddSkip(start, index); + scan.AddSkip(start, index, comment: false); continue; } @@ -67,81 +63,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. + /// 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 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. - /// - 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 +103,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 +120,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..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 = 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); } /// @@ -167,21 +75,16 @@ 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 /// 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; @@ -204,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) @@ -212,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) @@ -231,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 = 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; @@ -442,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; } @@ -468,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; @@ -477,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 new file mode 100644 index 00000000..6f07186e --- /dev/null +++ b/src/DiffEngine/Inline/FsLanguage.cs @@ -0,0 +1,502 @@ +/// +/// 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 string SnapshotValue(string literalValue) => + FsStringLiteral.StripLayout(literalValue); + + 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 => '='; + + /// + /// 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..8ede64d1 --- /dev/null +++ b/src/DiffEngine/Inline/FsStringLiteral.cs @@ -0,0 +1,313 @@ +namespace DiffEngine; + +/// +/// Renders snapshot text as an F# string literal, and reads one back as the snapshot it holds. +/// +/// 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 + /// regular literal when it is a single line, and an indented triple-quoted one otherwise. + /// + /// Snapshot text with \n newlines. + /// 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 StringLiteral.RenderRegular(content); + } + + if (!CanTripleQuote(content)) + { + // 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)); + } + + return StringLiteral.RenderMultiLine(content, indent, eol, "\"\"\""); + } + + /// + /// 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. + /// + static bool CanTripleQuote(string content) => + content[0] != '"' && + content[content.Length - 1] != '"' && + content.IndexOf("\"\"\"", StringComparison.Ordinal) == -1; + + /// + /// 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. + /// + /// 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. + /// + /// + public static string StripLayout(string value) => + StringLiteral.TryStripLayout(SourceLanguage.NormalizeNewlines(value), out var stripped) + ? stripped + : value; + + /// + /// Parses an F# string literal expression back to the snapshot it holds: triple-quoted + /// ("""..."""), with the layout taken off, verbatim (@"...") and regular ("..."), which carry + /// their value as it is. Returns false for interpolated strings, byte strings, concatenations, + /// or any other expression. Newlines in the returned value are normalized to \n. + /// + 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 StringLiteral.TryScanVerbatim(text, index + 1, out value, out end); + } + + var quotes = StringLiteral.QuoteRunLength(text, index); + if (quotes >= 3) + { + // 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) + { + // Empty regular string "" + value = ""; + end = index + 2; + return true; + } + + return TryScanRegular(text, index + 1, out value, out end); + } + + 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 (!StringLiteral.TryReadHex(text, ref index, 4, 4, out var utf16)) + { + return false; + } + + builder.Append((char) utf16); + break; + case 'x': + if (!StringLiteral.TryReadHex(text, ref index, 2, 2, out var byteValue)) + { + return false; + } + + builder.Append((char) byteValue); + break; + case 'U': + if (!StringLiteral.TryReadHex(text, ref index, 8, 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 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..9b2dd380 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,10 +104,13 @@ static InlineApplyResult LockedApply(string fullPath, InlinePatch patch, string } var status = InlinePatcher.TryApply( + SourceLanguage.ForFile(fullPath), source, 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 368765bc..c1eff1ef 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,36 @@ public InlinePatch( } /// - /// Full path to the .cs file. + /// 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 + /// (). /// public string SourceFile { get; set; } @@ -84,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 bb5be7e2..0c8e4144 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,10 +41,13 @@ static class InlinePatcher const string verifierType = "Verifier"; public static PatchStatus TryApply( + SourceLanguage language, string source, int lineHint, InlinePatchMode mode, string? originalExpression, + string? originalValue, + string? memberName, string newContent, out string newSource, out string failReason) @@ -47,18 +56,19 @@ public static PatchStatus TryApply( failReason = ""; var eol = DetectEol(source); var lineStarts = BuildLineStarts(source); - var scan = new CsScan(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)) @@ -71,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)) @@ -79,29 +89,66 @@ 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; } // 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( string source, - CsScan scan, + SourceScan scan, List lineStarts, int lineHint, + int? memberLine, string newContent, string eol, string fileUnit, @@ -109,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; @@ -130,7 +177,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 +193,8 @@ 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, namedIndent, eol); + newSource = Splice(source, expected.ListStart, expected.ListStart, $"{scan.Language.NamePrefix(parameterName)}{namedRendered}, "); return PatchStatus.Applied; } @@ -160,18 +207,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 +283,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 +297,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,20 +306,23 @@ 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, + 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; @@ -288,7 +349,7 @@ static PatchStatus TryAppend( ? statementIndent + unit : LeadingWhitespace(source, lineStarts, insertAt - 1); var contentIndent = callIndent + unit; - var rendered = CsStringLiteral.Render(newContent, 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; @@ -300,13 +361,14 @@ static PatchStatus TryAppend( /// static PatchStatus TryRemove( string source, - CsScan scan, + 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; @@ -362,12 +424,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 +443,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 +451,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 +460,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,20 +498,21 @@ 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) => - 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, - CsScan scan, + 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; @@ -443,81 +524,176 @@ 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. /// static IEnumerable<(int nameStart, int openParen)> FindCalls( string source, - CsScan scan, + 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 && - CsScan.IsIdentifierChar(source[identifierEnd])) - { - identifierEnd++; - } - } - else if (identifierEnd < source.Length && - CsScan.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, 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; + } + + 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; + } - index += name.Length; + 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, 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 +706,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 +724,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 +732,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 +742,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 +765,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 +824,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 +839,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,10 +872,10 @@ 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, indent, eol); if (StartsLine(source, lineStarts, spanStart)) { return rendered; @@ -748,9 +885,9 @@ static string RenderArgument(string source, List lineStarts, int spanStart, } /// - /// 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. + /// 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(string rendered, string indent, string eol) => rendered.IndexOf('\n') == -1 ? rendered : $"{eol}{indent}{rendered}"; @@ -827,7 +964,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 +1103,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..c2679d8c --- /dev/null +++ b/src/DiffEngine/Inline/SourceLanguage.cs @@ -0,0 +1,211 @@ +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. + /// 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 + /// 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; } + + /// + /// 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 . + /// + /// 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; + + /// + /// 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/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; + } +} 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); } 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."); } } 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.