diff --git a/docs/inline.md b/docs/inline.md index d77a76c7..933fee21 100644 --- a/docs/inline.md +++ b/docs/inline.md @@ -7,17 +7,12 @@ To change this file edit the source file and then run MarkdownSnippets. # Inline snapshots -The plumbing DiffEngine provides for reviewing -[inline snapshots](https://github.com/VerifyTests/Verify/blob/main/docs/inline-snapshots.md): -carrying a pending edit from the test process to a reviewer, showing it, and splicing it into -the source file once accepted. Verify is the producing side; this page documents the DiffEngine -half, for anyone integrating with it — a test library, an IDE plugin, or another review surface. +The plumbing DiffEngine provides for reviewing [inline snapshots](https://github.com/VerifyTests/Verify/blob/main/docs/inline-snapshots.md): carrying a pending edit from the test process to a reviewer, showing it, and splicing it into the source file once accepted. Verify is the producing side; this page documents the DiffEngine half, for anyone integrating with it — a test library, an IDE plugin, or another review surface. ## The parts -Five parties, three transports: two loopback ports, stdin for a cold launch, and staged files -for when no viewer is available. +Five parties, three transports: two loopback ports, stdin for a cold launch, and staged files for when no viewer is available. ```mermaid flowchart LR @@ -40,18 +35,9 @@ flowchart LR Plugin -->|"InlineApplier"| Files ``` -The queue of pending snapshots has exactly one owner per session: whichever process bound port -3493 first, decided once and never transferred. When the tray owns it, its edges to the owner -above are in-process calls; when a viewer owns it, the tray drives that viewer over the same -verbs. Either way both hosts run the same `InlineQueue` implementation, so they cannot disagree -on what accepting or settling means. [DiffEngineViewer](/docs/viewer.md) and -[DiffEngineTray](/docs/tray.md) cover the two arrangements in detail. +The queue of pending snapshots has exactly one owner per session: whichever process bound port 3493 first, decided once and never transferred. When the tray owns it, its edges to the owner above are in-process calls; when a viewer owns it, the tray drives that viewer over the same verbs. Either way both hosts run the same `InlineQueue` implementation, so they cannot disagree on what accepting or settling means. [DiffEngineViewer](/docs/viewer.md) and [DiffEngineTray](/docs/tray.md) cover the two arrangements in detail. -Pending file moves and deletes follow the same rule. They go to the tray when one is running, -over the port they have always used, and to the queue owner when one is not — so with no tray -installed they are reviewed in the viewer rather than going nowhere. A delete starts a viewer if -nothing owns the queue, because it has no second file to compare against and so no diff tool ever -opens for it. A move does not: DiffEngine has already opened a diff tool for that file pair. +Pending file moves and deletes follow the same rule. They go to the tray when one is running, over the port they have always used, and to the queue owner when one is not — so with no tray installed they are reviewed in the viewer rather than going nowhere. A delete starts a viewer if nothing owns the queue, because it has no second file to compare against and so no diff tool ever opens for it. A move does not: DiffEngine has already opened a diff tool for that file pair. ## When a test fails @@ -78,10 +64,7 @@ sequenceDiagram Owner->>Owner: drop the entry ``` -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: +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 @@ -92,26 +75,15 @@ DiffEngineViewer --inline --source --line < the.inlinepatch 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. -* Setting `DiffEngine_InlineViewer` to `false` reports `NoViewerFound` without probing, which is - how a user opts into reviewing in their IDE instead of a window. +* `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. +* Setting `DiffEngine_InlineViewer` to `false` reports `NoViewerFound` without probing, which is how a user opts into reviewing in their IDE instead of a window. ## The patch file -`InlinePatchFile` reads and writes the wire and staging format for a patch. Plain text, content -fields base64 encoded so snapshot text needs no escaping and no JSON dependency: +`InlinePatchFile` reads and writes the wire and staging format for a patch. Plain text, content fields base64 encoded so snapshot text needs no escaping and no JSON dependency: ``` version: 2 @@ -124,35 +96,46 @@ testName: {base64} framework: net9.0 ``` -`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. +`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. + + +## How the literal is written + +`newContent` is the snapshot value, not source text. Turning it into C# is four decisions — which literal form, how long a delimiter, where the argument sits, and how far it is indented — plus the line endings, and every one of them is read off the file being patched rather than configured. `CsStringLiteral.Render` is public, so a surface that wants to produce the identical text can call it instead of reimplementing the rules below. + +**Form.** Single line content becomes a regular literal, escaping what that form cannot hold verbatim (`\` `"`, the control characters, `\uXXXX` for the rest). Multi-line content becomes a raw literal. A raw string spends three lines and an indentation rule to carry one line of content, so it is used only where it earns that. Empty content is `""`. + +**Delimiter.** Three quotes, or one more than the longest run of quotes in the content — so a snapshot containing `"""` is carried by `""""`. + +**Placement.** A raw literal goes on the line below the open paren, so its opening delimiter lines up with its content and its closing one. A regular literal has nothing to line up with and stays in the argument list. An argument that already starts its own line keeps that line. + +```csharp +// single line content +await Verify(value).Snapshot("the value"); + +// multi-line content +await Verify(value).Snapshot( + """ + line one + line two + """); +``` + +**Indentation.** The call line's own leading whitespace, plus one level. What a level is comes from two places: the character from the call site, so a tab indented method inside a space indented file stays on tabs, and the width from the file, taken as the most common run of whitespace its lines add to the line above. A file that indents by two spaces gets two; four spaces is a fallback for a file with no indentation to read, not a default. Blank lines inside the content are emitted bare, so the literal carries no trailing whitespace. + +**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. ## 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. +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. -* **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. +* **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. +* **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. +`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. -`Remove` mode patches are configuration changes with nothing to review: apply them directly; -`AddInlineAsync` refuses them. +`Remove` mode patches are configuration changes with nothing to review: apply them directly; `AddInlineAsync` refuses them. ## Ports @@ -162,8 +145,4 @@ with a message (locked file, unreadable source), which is retryable. | 3492 | a tray is here | one way payloads: moves and deletes ([tray](/docs/tray.md#payloads)) | | 3493 | the inline queue owner is here | request/response verbs, internal | -Two ports because they answer different questions: the owner of 3493 is sometimes a viewer, and -a late starting tray still receives every move on 3492 while it is. `DiffEngine_ViewerPort` -overrides 3493, which test suites use to keep out of the way of a live tray. The 3493 protocol -is internal and versioned; integrate through `DiffRunner` and `InlineApplier` rather than -speaking it directly. +Two ports because they answer different questions: the owner of 3493 is sometimes a viewer, and a late starting tray still receives every move on 3492 while it is. `DiffEngine_ViewerPort` overrides 3493, which test suites use to keep out of the way of a live tray. The 3493 protocol is internal and versioned; integrate through `DiffRunner` and `InlineApplier` rather than speaking it directly. diff --git a/docs/mdsource/inline.source.md b/docs/mdsource/inline.source.md index 13f3aeff..d2ffe4ca 100644 --- a/docs/mdsource/inline.source.md +++ b/docs/mdsource/inline.source.md @@ -1,16 +1,11 @@ # Inline snapshots -The plumbing DiffEngine provides for reviewing -[inline snapshots](https://github.com/VerifyTests/Verify/blob/main/docs/inline-snapshots.md): -carrying a pending edit from the test process to a reviewer, showing it, and splicing it into -the source file once accepted. Verify is the producing side; this page documents the DiffEngine -half, for anyone integrating with it — a test library, an IDE plugin, or another review surface. +The plumbing DiffEngine provides for reviewing [inline snapshots](https://github.com/VerifyTests/Verify/blob/main/docs/inline-snapshots.md): carrying a pending edit from the test process to a reviewer, showing it, and splicing it into the source file once accepted. Verify is the producing side; this page documents the DiffEngine half, for anyone integrating with it — a test library, an IDE plugin, or another review surface. ## The parts -Five parties, three transports: two loopback ports, stdin for a cold launch, and staged files -for when no viewer is available. +Five parties, three transports: two loopback ports, stdin for a cold launch, and staged files for when no viewer is available. ```mermaid flowchart LR @@ -33,18 +28,9 @@ flowchart LR Plugin -->|"InlineApplier"| Files ``` -The queue of pending snapshots has exactly one owner per session: whichever process bound port -3493 first, decided once and never transferred. When the tray owns it, its edges to the owner -above are in-process calls; when a viewer owns it, the tray drives that viewer over the same -verbs. Either way both hosts run the same `InlineQueue` implementation, so they cannot disagree -on what accepting or settling means. [DiffEngineViewer](/docs/viewer.md) and -[DiffEngineTray](/docs/tray.md) cover the two arrangements in detail. +The queue of pending snapshots has exactly one owner per session: whichever process bound port 3493 first, decided once and never transferred. When the tray owns it, its edges to the owner above are in-process calls; when a viewer owns it, the tray drives that viewer over the same verbs. Either way both hosts run the same `InlineQueue` implementation, so they cannot disagree on what accepting or settling means. [DiffEngineViewer](/docs/viewer.md) and [DiffEngineTray](/docs/tray.md) cover the two arrangements in detail. -Pending file moves and deletes follow the same rule. They go to the tray when one is running, -over the port they have always used, and to the queue owner when one is not — so with no tray -installed they are reviewed in the viewer rather than going nowhere. A delete starts a viewer if -nothing owns the queue, because it has no second file to compare against and so no diff tool ever -opens for it. A move does not: DiffEngine has already opened a diff tool for that file pair. +Pending file moves and deletes follow the same rule. They go to the tray when one is running, over the port they have always used, and to the queue owner when one is not — so with no tray installed they are reviewed in the viewer rather than going nowhere. A delete starts a viewer if nothing owns the queue, because it has no second file to compare against and so no diff tool ever opens for it. A move does not: DiffEngine has already opened a diff tool for that file pair. ## When a test fails @@ -71,10 +57,7 @@ sequenceDiagram Owner->>Owner: drop the entry ``` -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: +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 @@ -85,26 +68,15 @@ DiffEngineViewer --inline --source --line < the.inlinepatch 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. -* Setting `DiffEngine_InlineViewer` to `false` reports `NoViewerFound` without probing, which is - how a user opts into reviewing in their IDE instead of a window. +* `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. +* Setting `DiffEngine_InlineViewer` to `false` reports `NoViewerFound` without probing, which is how a user opts into reviewing in their IDE instead of a window. ## The patch file -`InlinePatchFile` reads and writes the wire and staging format for a patch. Plain text, content -fields base64 encoded so snapshot text needs no escaping and no JSON dependency: +`InlinePatchFile` reads and writes the wire and staging format for a patch. Plain text, content fields base64 encoded so snapshot text needs no escaping and no JSON dependency: ``` version: 2 @@ -117,35 +89,46 @@ testName: {base64} framework: net9.0 ``` -`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. +`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. + + +## How the literal is written + +`newContent` is the snapshot value, not source text. Turning it into C# is four decisions — which literal form, how long a delimiter, where the argument sits, and how far it is indented — plus the line endings, and every one of them is read off the file being patched rather than configured. `CsStringLiteral.Render` is public, so a surface that wants to produce the identical text can call it instead of reimplementing the rules below. + +**Form.** Single line content becomes a regular literal, escaping what that form cannot hold verbatim (`\` `"`, the control characters, `\uXXXX` for the rest). Multi-line content becomes a raw literal. A raw string spends three lines and an indentation rule to carry one line of content, so it is used only where it earns that. Empty content is `""`. + +**Delimiter.** Three quotes, or one more than the longest run of quotes in the content — so a snapshot containing `"""` is carried by `""""`. + +**Placement.** A raw literal goes on the line below the open paren, so its opening delimiter lines up with its content and its closing one. A regular literal has nothing to line up with and stays in the argument list. An argument that already starts its own line keeps that line. + +```csharp +// single line content +await Verify(value).Snapshot("the value"); + +// multi-line content +await Verify(value).Snapshot( + """ + line one + line two + """); +``` + +**Indentation.** The call line's own leading whitespace, plus one level. What a level is comes from two places: the character from the call site, so a tab indented method inside a space indented file stays on tabs, and the width from the file, taken as the most common run of whitespace its lines add to the line above. A file that indents by two spaces gets two; four spaces is a fallback for a file with no indentation to read, not a default. Blank lines inside the content are emitted bare, so the literal carries no trailing whitespace. + +**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. ## 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. +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. -* **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. +* **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. +* **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. +`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. -`Remove` mode patches are configuration changes with nothing to review: apply them directly; -`AddInlineAsync` refuses them. +`Remove` mode patches are configuration changes with nothing to review: apply them directly; `AddInlineAsync` refuses them. ## Ports @@ -155,8 +138,4 @@ with a message (locked file, unreadable source), which is retryable. | 3492 | a tray is here | one way payloads: moves and deletes ([tray](/docs/tray.md#payloads)) | | 3493 | the inline queue owner is here | request/response verbs, internal | -Two ports because they answer different questions: the owner of 3493 is sometimes a viewer, and -a late starting tray still receives every move on 3492 while it is. `DiffEngine_ViewerPort` -overrides 3493, which test suites use to keep out of the way of a live tray. The 3493 protocol -is internal and versioned; integrate through `DiffRunner` and `InlineApplier` rather than -speaking it directly. +Two ports because they answer different questions: the owner of 3493 is sometimes a viewer, and a late starting tray still receives every move on 3492 while it is. `DiffEngine_ViewerPort` overrides 3493, which test suites use to keep out of the way of a live tray. The 3493 protocol is internal and versioned; integrate through `DiffRunner` and `InlineApplier` rather than speaking it directly. diff --git a/docs/mdsource/tray.source.md b/docs/mdsource/tray.source.md index ff63f83b..1426fbb3 100644 --- a/docs/mdsource/tray.source.md +++ b/docs/mdsource/tray.source.md @@ -46,18 +46,11 @@ Clicking "file1" or "file2" will delete file1 or file2 respectively. The drop do ### Pending snapshots -[Inline snapshots](/docs/viewer.md) are reviewed in DiffEngineViewer rather than in a diff tool. -The queue of them lives in whichever process claims the loopback port first, and stays there for as -long as that process runs. With no tray, that is the viewer. With one, it is normally the tray, -because the tray starts at login and the viewer only starts when a snapshot fails. +[Inline snapshots](/docs/viewer.md) are reviewed in DiffEngineViewer rather than in a diff tool. The queue of them lives in whichever process claims the loopback port first, and stays there for as long as that process runs. With no tray, that is the viewer. With one, it is normally the tray, because the tray starts at login and the viewer only starts when a snapshot fails. -When the tray holds it, the window becomes disposable. A viewer that is closed, killed or crashes -takes nothing pending with it, and the tray opens a new one on the same queue. A snapshot arriving -with no window open starts one. +When the tray holds it, the window becomes disposable. A viewer that is closed, killed or crashes takes nothing pending with it, and the tray opens a new one on the same queue. A snapshot arriving with no window open starts one. -"Pending Snapshots" accepts all of them. Clicking one accepts that one, and its drop down offers -discard, opening the viewer on it, and opening the source file. A snapshot that failed to apply is -marked with `!` and stays pending, so it can be retried once whatever blocked it is out of the way. +"Pending Snapshots" accepts all of them. Clicking one accepts that one, and its drop down offers discard, opening the viewer on it, and opening the source file. A snapshot that failed to apply is marked with `!` and stays pending, so it can be retried once whatever blocked it is out of the way. A tray restart loses the queue, as it loses pending moves and deletes. Re-run the tests. @@ -81,9 +74,7 @@ If accepting a move fails because the files are locked by another process (for e ### Discard -Discard will clear all currently tracked items. It is the same discard as the per item menu and as -the one in [DiffEngineViewer](/docs/viewer.md): a move loses its received file, a pending delete -keeps its file and is untracked, and every pending inline snapshot is dropped. +Discard will clear all currently tracked items. It is the same discard as the per item menu and as the one in [DiffEngineViewer](/docs/viewer.md): a move loses its received file, a pending delete keeps its file and is untracked, and every pending inline snapshot is dropped. ### Purge verified files diff --git a/docs/mdsource/viewer.source.md b/docs/mdsource/viewer.source.md index f27602d5..11e9672f 100644 --- a/docs/mdsource/viewer.source.md +++ b/docs/mdsource/viewer.source.md @@ -1,12 +1,8 @@ # DiffEngineViewer -DiffEngineViewer is a cross platform diff tool for text files, images and inline snapshots. It is -the reviewer for [inline snapshots](https://github.com/VerifyTests/Verify/blob/main/docs/inline-snapshots.md): -it shows the received text against the expected text, and accepting rewrites the literal in the -source file. +DiffEngineViewer is a cross platform diff tool for text files, images and inline snapshots. It is the reviewer for [inline snapshots](https://github.com/VerifyTests/Verify/blob/main/docs/inline-snapshots.md): it shows the received text against the expected text, and accepting rewrites the literal in the source file. -Unlike every other entry in the [tool list](/docs/diff-tool.md), it does not need to be installed. -A copy ships inside the DiffEngine package, so it is always present. +Unlike every other entry in the [tool list](/docs/diff-tool.md), it does not need to be installed. A copy ships inside the DiffEngine package, so it is always present. The renderer is native to each platform: @@ -16,8 +12,7 @@ The renderer is native to each platform: | macOS | AppKit and Core Text | | Linux | [Dear ImGui](https://github.com/ocornut/imgui) through [raylib](https://github.com/raysan5/raylib) | -All three draw the same screen model, and the layout, scrolling and keyboard handling are shared, -so the only difference is how the pixels get there. +All three draw the same screen model, and the layout, scrolling and keyboard handling are shared, so the only difference is how the pixels get there. ## NuGet @@ -26,16 +21,13 @@ so the only difference is how the pixels get there. * https://www.nuget.org/packages/DiffEngineViewer.Mac * https://www.nuget.org/packages/DiffEngineViewer.Linux -Only needed to use the viewer outside a project that references DiffEngine, since DiffEngine -already bundles it. +Only needed to use the viewer outside a project that references DiffEngine, since DiffEngine already bundles it. ``` dotnet tool install -g DiffEngineViewer.Windows ``` -One package per operating system rather than one for all of them, because WinForms has to be named -as a framework dependency and a package that names it cannot start anywhere else. The copy bundled -in DiffEngine is unaffected: it is published per RID and resolved by directory. +One package per operating system rather than one for all of them, because WinForms has to be named as a framework dependency and a package that names it cannot start anywhere else. The copy bundled in DiffEngine is unaffected: it is published per RID and resolved by directory. ## Usage @@ -64,8 +56,7 @@ Displaying a queue held by another process, which is how [DiffEngineTray](/docs/ DiffEngineViewer --attach ``` -Nothing is written to disk for inline review. The patch travels over stdin, or over a loopback -socket when something is already holding the queue. +Nothing is written to disk for inline review. The patch travels over stdin, or over a loopback socket when something is already holding the queue. ## Keys @@ -84,119 +75,68 @@ socket when something is already holding the queue. ## Multiple pending snapshots -A test run that fails several inline snapshots produces one window, not several. Whichever process -binds the loopback port holds the queue; everything else hands its patch to that one. The window -lists everything pending and offers **Accept all**. +A test run that fails several inline snapshots produces one window, not several. Whichever process binds the loopback port holds the queue; everything else hands its patch to that one. The window lists everything pending and offers **Accept all**. -The list sits in a column on the left. Drag the divider beside it to widen the column when the file -names are longer than it is. When the list outgrows the window it follows the selection, keeping -the selected row visible. +The list sits in a column on the left. Drag the divider beside it to widen the column when the file names are longer than it is. When the list outgrows the window it follows the selection, keeping the selected row visible. -Row labels are the shortest thing that tells one entry from another, so hovering one fills in what -it left out: the whole path, the test behind a call site, every framework behind a conflict, and the -failure behind a `!`. A row with nothing to add shows no tooltip at all. +Row labels are the shortest thing that tells one entry from another, so hovering one fills in what it left out: the whole path, the test behind a call site, every framework behind a conflict, and the failure behind a `!`. A row with nothing to add shows no tooltip at all. The panes carry a scrollbar, which moves with the keys and the wheel. -Closing the window discards the queue, unless [DiffEngineTray](/docs/tray.md) is running, in which -case the tray still has it and can reopen a window on it. +Closing the window discards the queue, unless [DiffEngineTray](/docs/tray.md) is running, in which case the tray still has it and can reopen a window on it. ## Context menus Every row of the pending column answers a right-click: - * An inline snapshot offers **Accept**, **Discard** and **Open source file**, plus - **Show next variant** when frameworks disagree about it. - * A move offers **Accept move**, **Discard** and **Open target directory**; a delete offers - **Accept delete**, **Discard** and **Open directory**. - * A solution header offers **Accept all in ...** and **Discard all in ...** for that solution - only, and a test sub-header the same for that test's changes. Bulk accepts skip conflicted - snapshots, the way accept-all does. + * An inline snapshot offers **Accept**, **Discard** and **Open source file**, plus **Show next variant** when frameworks disagree about it. + * A move offers **Accept move**, **Discard** and **Open target directory**; a delete offers **Accept delete**, **Discard** and **Open directory**. + * A solution header offers **Accept all in ...** and **Discard all in ...** for that solution only, and a test sub-header the same for that test's changes. Bulk accepts skip conflicted snapshots, the way accept-all does. -Right-clicking an entry selects it first, so the menu acts on what is highlighted. Opening a file -manager is always local — the files are on this machine, wherever the queue lives. +Right-clicking an entry selects it first, so the menu acts on what is highlighted. Opening a file manager is always local — the files are on this machine, wherever the queue lives. -On Windows and macOS this is the real OS menu, so it also takes the arrow keys, Enter, Escape and -type-to-select, flips rather than clips near the edge of a screen, and is readable by a screen -reader. A click that dismisses it is consumed doing so, which is why right-clicking a different row -while a menu is open takes two clicks. On Linux it is drawn by the viewer, and any other click or -key closes it. +On Windows and macOS this is the real OS menu, so it also takes the arrow keys, Enter, Escape and type-to-select, flips rather than clips near the edge of a screen, and is readable by a screen reader. A click that dismisses it is consumed doing so, which is why right-clicking a different row while a menu is open takes two clicks. On Linux it is drawn by the viewer, and any other click or key closes it. macOS also carries a menu bar, listing the same commands as the keys below. ## Grouping -When the pending items span more than one solution, the list groups them under solution headers -with counts. The solution is found by walking up from each source file; items with no discoverable -solution trail at the end, ungrouped. A queue from one solution stays flat. +When the pending items span more than one solution, the list groups them under solution headers with counts. The solution is found by walking up from each source file; items with no discoverable solution trail at the end, ungrouped. A queue from one solution stays flat. -When one test produced more than one change, those changes gather under a sub-header carrying the -test name. Test names come from the caller (Verify) and are optional; without them, items are -labeled by call site. Two items that would read identically — the same file name and line in two -projects — grow the shortest distinguishing directory prefix. +When one test produced more than one change, those changes gather under a sub-header carrying the test name. Test names come from the caller (Verify) and are optional; without them, items are labeled by call site. Two items that would read identically — the same file name and line in two projects — grow the shortest distinguishing directory prefix. -Every header carries a marker: `-` when open, `+` when folded. Clicking a header folds its group, -and the header's right-click menu offers the same. A fold is only a view — what it hides is still -pending, still counted by the header hiding it, and still taken by **Accept all**. `Tab` steps over -folded items rather than into them, and anything that selects an item from outside the window -unfolds whatever was hiding it. +Every header carries a marker: `-` when open, `+` when folded. Clicking a header folds its group, and the header's right-click menu offers the same. A fold is only a view — what it hides is still pending, still counted by the header hiding it, and still taken by **Accept all**. `Tab` steps over folded items rather than into them, and anything that selects an item from outside the window unfolds whatever was hiding it. ## Conflicting snapshots -A test run under several target frameworks can produce different content for the same call site. -The queue keeps each distinct content as a labeled variant of one entry — `net8.0`, `net9.0` — -rather than letting the last writer win. Identical content from several frameworks merges into one -variant carrying all their labels. +A test run under several target frameworks can produce different content for the same call site. The queue keeps each distinct content as a labeled variant of one entry — `net8.0`, `net9.0` — rather than letting the last writer win. Identical content from several frameworks merges into one variant carrying all their labels. -A conflicted entry is marked `*` in the list, the pane header names the framework on screen -(`received (net8.0)`), and a **Variant** button (or `v`) cycles through the disagreeing contents. -Accepting applies exactly the variant on screen and resolves the whole call site; a framework that -still disagrees will re-report on its next run. **Accept all** never picks sides: it skips -conflicted entries and says how many still need review. A framework whose test starts passing -settles only its own variant, so the other framework's still-failing content stays reviewable. +A conflicted entry is marked `*` in the list, the pane header names the framework on screen (`received (net8.0)`), and a **Variant** button (or `v`) cycles through the disagreeing contents. Accepting applies exactly the variant on screen and resolves the whole call site; a framework that still disagrees will re-report on its next run. **Accept all** never picks sides: it skips conflicted entries and says how many still need review. A framework whose test starts passing settles only its own variant, so the other framework's still-failing content stays reviewable. ## Moves and deletes -When [DiffEngineTray](/docs/tray.md) owns the queue, the viewer also lists the tray's pending file -moves and deletes beside the snapshots, grouped by solution like everything else. A move shows the -received file against the committed one; a delete shows the file's content against nothing. The -files are read locally — the protocol never leaves the machine — and accept and discard are -forwarded to the tray, which is why the buttons name the act: **Accept move**, **Accept delete**. +When [DiffEngineTray](/docs/tray.md) owns the queue, the viewer also lists the tray's pending file moves and deletes beside the snapshots, grouped by solution like everything else. A move shows the received file against the committed one; a delete shows the file's content against nothing. The files are read locally — the protocol never leaves the machine — and accept and discard are forwarded to the tray, which is why the buttons name the act: **Accept move**, **Accept delete**. -**Accept all** on a tray-owned queue sweeps everything the window shows: deletes, moves and -snapshots, with conflicted snapshots skipped and anything locked kept pending and counted. +**Accept all** on a tray-owned queue sweeps everything the window shows: deletes, moves and snapshots, with conflicted snapshots skipped and anything locked kept pending and counted. -A viewer that owns the queue itself never shows moves or deletes, because DiffEngine only sends -them to a running tray. +A viewer that owns the queue itself never shows moves or deletes, because DiffEngine only sends them to a running tray. ## Images -`.png`, `.jpg`, `.jpeg`, `.gif`, `.bmp`, `.webp` and `.ico` are compared as pictures rather than as -text, wherever they turn up: a pair passed on the command line, or a move or delete the tray is -holding. +`.png`, `.jpg`, `.jpeg`, `.gif`, `.bmp`, `.webp` and `.ico` are compared as pictures rather than as text, wherever they turn up: a pair passed on the command line, or a move or delete the tray is holding. -Each pane lists what its own side is — format, pixel dimensions and byte count — with a property -that matches the other side reading as unchanged and one that does not reading as modified, the same -colouring a line of text gets. Whether the two are the same picture belongs to the pair rather than -to either side, so it is stated in the status line: **images are identical**, **images differ**, or -**only \ exists** when one side has nothing yet, which is the normal state of a brand new -image snapshot. +Each pane lists what its own side is — format, pixel dimensions and byte count — with a property that matches the other side reading as unchanged and one that does not reading as modified, the same colouring a line of text gets. Whether the two are the same picture belongs to the pair rather than to either side, so it is stated in the status line: **images are identical**, **images differ**, or **only \ exists** when one side has nothing yet, which is the normal state of a brand new image snapshot. -The extension decides, not the content. A `.png` holding something that is not one is still an -image side, and says its format was not recognized instead of rendering the bytes as text. +The extension decides, not the content. A `.png` holding something that is not one is still an image side, and says its format was not recognized instead of rendering the bytes as text. -Each pane also draws the picture itself, one blank line under those rows: fitted to the space, -never enlarged past its own size, on a checkerboard so transparency reads as transparent. All three -heads place it identically, from the size the file's own header gave rather than from whatever -their decoder reported. +Each pane also draws the picture itself, one blank line under those rows: fitted to the space, never enlarged past its own size, on a checkerboard so transparency reads as transparent. All three heads place it identically, from the size the file's own header gave rather than from whatever their decoder reported. -Which formats can be drawn is the platform's answer rather than the viewer's, because each head -uses the decoder its toolkit ships with: +Which formats can be drawn is the platform's answer rather than the viewer's, because each head uses the decoder its toolkit ships with: | Head | Drawn | | --- | --- | @@ -204,58 +144,36 @@ uses the decoder its toolkit ships with: | macOS (ImageIO) | all seven | | Linux (raylib) | `.png` `.jpg` `.jpeg` `.gif` `.bmp` | -A format a head cannot decode draws nothing, and the comparison is still there in the rows above -it. That is why those rows are the description and the picture is an addition to it. +A format a head cannot decode draws nothing, and the comparison is still there in the rows above it. That is why those rows are the description and the picture is an addition to it. -Accepting is the same act it is for text — copy the received file over the expected one, or forward -the move to the tray — so nothing about reviewing an image changes what accepting one does. +Accepting is the same act it is for text — copy the received file over the expected one, or forward the move to the tray — so nothing about reviewing an image changes what accepting one does. ## With DiffEngineTray -The tray starts at login, so it normally binds the port first and holds the queue. The viewer then -displays it: it reads the pending snapshots back over the socket and forwards accept and discard -rather than applying them. That is what `--attach` is for, and the tray starts one whenever a -snapshot arrives with no window open. +The tray starts at login, so it normally binds the port first and holds the queue. The viewer then displays it: it reads the pending snapshots back over the socket and forwards accept and discard rather than applying them. That is what `--attach` is for, and the tray starts one whenever a snapshot arrives with no window open. -The point of that arrangement is that the queue outlives the window. A viewer that is closed, -killed or crashes takes nothing with it, and there is no 52 MB process kept resident purely to hold -a list. +The point of that arrangement is that the queue outlives the window. A viewer that is closed, killed or crashes takes nothing with it, and there is no 52 MB process kept resident purely to hold a list. -If a viewer was already running when the tray started, the viewer keeps the queue for as long as it -lives and the tray drives it remotely instead. Ownership is decided once and never moves. Either -way both surfaces run the same queue implementation, so they cannot disagree on what accepting or -settling means, and the tray's **Pending Snapshots** group can accept, discard, open the viewer on -a particular snapshot, and close the viewer. +If a viewer was already running when the tray started, the viewer keeps the queue for as long as it lives and the tray drives it remotely instead. Ownership is decided once and never moves. Either way both surfaces run the same queue implementation, so they cannot disagree on what accepting or settling means, and the tray's **Pending Snapshots** group can accept, discard, open the viewer on a particular snapshot, and close the viewer. A tray restart loses the queue, as it loses pending file moves and deletes. Re-run the tests. ## With no tray -Pending file moves and deletes go to the tray when one is running. When one is not, they go to the -viewer, which holds and applies them itself — so a received file waiting to be promoted, or a -verified file a passing test no longer produces, is reviewable rather than invisible. +Pending file moves and deletes go to the tray when one is running. When one is not, they go to the viewer, which holds and applies them itself — so a received file waiting to be promoted, or a verified file a passing test no longer produces, is reviewable rather than invisible. -A pending delete starts a viewer if none is running. It is the one change with no second file to -compare against, so no diff tool ever opens for it, and a window is the only surface it can have. A pending move does not start one: DiffEngine has already opened a diff tool for that file -pair, and a second window competing with it is not an improvement. A move joins a window that is -already open. +A pending delete starts a viewer if none is running. It is the one change with no second file to compare against, so no diff tool ever opens for it, and a window is the only surface it can have. A pending move does not start one: DiffEngine has already opened a diff tool for that file pair, and a second window competing with it is not an improvement. A move joins a window that is already open. -Both look and behave exactly as they do when the tray owns them — same rows, same context menu, -same **Accept all** — because which process is holding a pending file depends only on whether a -tray happened to be running. +Both look and behave exactly as they do when the tray owns them — same rows, same context menu, same **Accept all** — because which process is holding a pending file depends only on whether a tray happened to be running. ## Disabling -Set `DiffEngine_InlineViewer` to `false` to stop inline snapshots opening a window. The viewer also -never launches when [DiffEngine is disabled](/docs/#disabled), which covers build servers, -continuous testing and AI CLIs. +Set `DiffEngine_InlineViewer` to `false` to stop inline snapshots opening a window. The viewer also never launches when [DiffEngine is disabled](/docs/#disabled), which covers build servers, continuous testing and AI CLIs. ## Platforms -Ships for `win-x64`, `win-arm64`, `linux-x64`, `linux-arm64`, `osx-x64` and `osx-arm64`. On a -platform with no matching build, resolution falls through to a globally installed -DiffEngineViewer tool, and then to whatever other diff tool is available. +Ships for `win-x64`, `win-arm64`, `linux-x64`, `linux-arm64`, `osx-x64` and `osx-arm64`. On a platform with no matching build, resolution falls through to a globally installed DiffEngineViewer tool, and then to whatever other diff tool is available. diff --git a/docs/tray.md b/docs/tray.md index e0d03e56..622e0e45 100644 --- a/docs/tray.md +++ b/docs/tray.md @@ -53,18 +53,11 @@ Clicking "file1" or "file2" will delete file1 or file2 respectively. The drop do ### Pending snapshots -[Inline snapshots](/docs/viewer.md) are reviewed in DiffEngineViewer rather than in a diff tool. -The queue of them lives in whichever process claims the loopback port first, and stays there for as -long as that process runs. With no tray, that is the viewer. With one, it is normally the tray, -because the tray starts at login and the viewer only starts when a snapshot fails. +[Inline snapshots](/docs/viewer.md) are reviewed in DiffEngineViewer rather than in a diff tool. The queue of them lives in whichever process claims the loopback port first, and stays there for as long as that process runs. With no tray, that is the viewer. With one, it is normally the tray, because the tray starts at login and the viewer only starts when a snapshot fails. -When the tray holds it, the window becomes disposable. A viewer that is closed, killed or crashes -takes nothing pending with it, and the tray opens a new one on the same queue. A snapshot arriving -with no window open starts one. +When the tray holds it, the window becomes disposable. A viewer that is closed, killed or crashes takes nothing pending with it, and the tray opens a new one on the same queue. A snapshot arriving with no window open starts one. -"Pending Snapshots" accepts all of them. Clicking one accepts that one, and its drop down offers -discard, opening the viewer on it, and opening the source file. A snapshot that failed to apply is -marked with `!` and stays pending, so it can be retried once whatever blocked it is out of the way. +"Pending Snapshots" accepts all of them. Clicking one accepts that one, and its drop down offers discard, opening the viewer on it, and opening the source file. A snapshot that failed to apply is marked with `!` and stays pending, so it can be retried once whatever blocked it is out of the way. A tray restart loses the queue, as it loses pending moves and deletes. Re-run the tests. @@ -88,9 +81,7 @@ If accepting a move fails because the files are locked by another process (for e ### Discard -Discard will clear all currently tracked items. It is the same discard as the per item menu and as -the one in [DiffEngineViewer](/docs/viewer.md): a move loses its received file, a pending delete -keeps its file and is untracked, and every pending inline snapshot is dropped. +Discard will clear all currently tracked items. It is the same discard as the per item menu and as the one in [DiffEngineViewer](/docs/viewer.md): a move loses its received file, a pending delete keeps its file and is untracked, and every pending inline snapshot is dropped. ### Purge verified files diff --git a/docs/viewer.md b/docs/viewer.md index cd3ea767..6425ca82 100644 --- a/docs/viewer.md +++ b/docs/viewer.md @@ -7,13 +7,9 @@ To change this file edit the source file and then run MarkdownSnippets. # DiffEngineViewer -DiffEngineViewer is a cross platform diff tool for text files, images and inline snapshots. It is -the reviewer for [inline snapshots](https://github.com/VerifyTests/Verify/blob/main/docs/inline-snapshots.md): -it shows the received text against the expected text, and accepting rewrites the literal in the -source file. +DiffEngineViewer is a cross platform diff tool for text files, images and inline snapshots. It is the reviewer for [inline snapshots](https://github.com/VerifyTests/Verify/blob/main/docs/inline-snapshots.md): it shows the received text against the expected text, and accepting rewrites the literal in the source file. -Unlike every other entry in the [tool list](/docs/diff-tool.md), it does not need to be installed. -A copy ships inside the DiffEngine package, so it is always present. +Unlike every other entry in the [tool list](/docs/diff-tool.md), it does not need to be installed. A copy ships inside the DiffEngine package, so it is always present. The renderer is native to each platform: @@ -23,8 +19,7 @@ The renderer is native to each platform: | macOS | AppKit and Core Text | | Linux | [Dear ImGui](https://github.com/ocornut/imgui) through [raylib](https://github.com/raysan5/raylib) | -All three draw the same screen model, and the layout, scrolling and keyboard handling are shared, -so the only difference is how the pixels get there. +All three draw the same screen model, and the layout, scrolling and keyboard handling are shared, so the only difference is how the pixels get there. ## NuGet @@ -33,16 +28,13 @@ so the only difference is how the pixels get there. * https://www.nuget.org/packages/DiffEngineViewer.Mac * https://www.nuget.org/packages/DiffEngineViewer.Linux -Only needed to use the viewer outside a project that references DiffEngine, since DiffEngine -already bundles it. +Only needed to use the viewer outside a project that references DiffEngine, since DiffEngine already bundles it. ``` dotnet tool install -g DiffEngineViewer.Windows ``` -One package per operating system rather than one for all of them, because WinForms has to be named -as a framework dependency and a package that names it cannot start anywhere else. The copy bundled -in DiffEngine is unaffected: it is published per RID and resolved by directory. +One package per operating system rather than one for all of them, because WinForms has to be named as a framework dependency and a package that names it cannot start anywhere else. The copy bundled in DiffEngine is unaffected: it is published per RID and resolved by directory. ## Usage @@ -71,8 +63,7 @@ Displaying a queue held by another process, which is how [DiffEngineTray](/docs/ DiffEngineViewer --attach ``` -Nothing is written to disk for inline review. The patch travels over stdin, or over a loopback -socket when something is already holding the queue. +Nothing is written to disk for inline review. The patch travels over stdin, or over a loopback socket when something is already holding the queue. ## Keys @@ -91,119 +82,68 @@ socket when something is already holding the queue. ## Multiple pending snapshots -A test run that fails several inline snapshots produces one window, not several. Whichever process -binds the loopback port holds the queue; everything else hands its patch to that one. The window -lists everything pending and offers **Accept all**. +A test run that fails several inline snapshots produces one window, not several. Whichever process binds the loopback port holds the queue; everything else hands its patch to that one. The window lists everything pending and offers **Accept all**. -The list sits in a column on the left. Drag the divider beside it to widen the column when the file -names are longer than it is. When the list outgrows the window it follows the selection, keeping -the selected row visible. +The list sits in a column on the left. Drag the divider beside it to widen the column when the file names are longer than it is. When the list outgrows the window it follows the selection, keeping the selected row visible. -Row labels are the shortest thing that tells one entry from another, so hovering one fills in what -it left out: the whole path, the test behind a call site, every framework behind a conflict, and the -failure behind a `!`. A row with nothing to add shows no tooltip at all. +Row labels are the shortest thing that tells one entry from another, so hovering one fills in what it left out: the whole path, the test behind a call site, every framework behind a conflict, and the failure behind a `!`. A row with nothing to add shows no tooltip at all. The panes carry a scrollbar, which moves with the keys and the wheel. -Closing the window discards the queue, unless [DiffEngineTray](/docs/tray.md) is running, in which -case the tray still has it and can reopen a window on it. +Closing the window discards the queue, unless [DiffEngineTray](/docs/tray.md) is running, in which case the tray still has it and can reopen a window on it. ## Context menus Every row of the pending column answers a right-click: - * An inline snapshot offers **Accept**, **Discard** and **Open source file**, plus - **Show next variant** when frameworks disagree about it. - * A move offers **Accept move**, **Discard** and **Open target directory**; a delete offers - **Accept delete**, **Discard** and **Open directory**. - * A solution header offers **Accept all in ...** and **Discard all in ...** for that solution - only, and a test sub-header the same for that test's changes. Bulk accepts skip conflicted - snapshots, the way accept-all does. + * An inline snapshot offers **Accept**, **Discard** and **Open source file**, plus **Show next variant** when frameworks disagree about it. + * A move offers **Accept move**, **Discard** and **Open target directory**; a delete offers **Accept delete**, **Discard** and **Open directory**. + * A solution header offers **Accept all in ...** and **Discard all in ...** for that solution only, and a test sub-header the same for that test's changes. Bulk accepts skip conflicted snapshots, the way accept-all does. -Right-clicking an entry selects it first, so the menu acts on what is highlighted. Opening a file -manager is always local — the files are on this machine, wherever the queue lives. +Right-clicking an entry selects it first, so the menu acts on what is highlighted. Opening a file manager is always local — the files are on this machine, wherever the queue lives. -On Windows and macOS this is the real OS menu, so it also takes the arrow keys, Enter, Escape and -type-to-select, flips rather than clips near the edge of a screen, and is readable by a screen -reader. A click that dismisses it is consumed doing so, which is why right-clicking a different row -while a menu is open takes two clicks. On Linux it is drawn by the viewer, and any other click or -key closes it. +On Windows and macOS this is the real OS menu, so it also takes the arrow keys, Enter, Escape and type-to-select, flips rather than clips near the edge of a screen, and is readable by a screen reader. A click that dismisses it is consumed doing so, which is why right-clicking a different row while a menu is open takes two clicks. On Linux it is drawn by the viewer, and any other click or key closes it. macOS also carries a menu bar, listing the same commands as the keys below. ## Grouping -When the pending items span more than one solution, the list groups them under solution headers -with counts. The solution is found by walking up from each source file; items with no discoverable -solution trail at the end, ungrouped. A queue from one solution stays flat. +When the pending items span more than one solution, the list groups them under solution headers with counts. The solution is found by walking up from each source file; items with no discoverable solution trail at the end, ungrouped. A queue from one solution stays flat. -When one test produced more than one change, those changes gather under a sub-header carrying the -test name. Test names come from the caller (Verify) and are optional; without them, items are -labeled by call site. Two items that would read identically — the same file name and line in two -projects — grow the shortest distinguishing directory prefix. +When one test produced more than one change, those changes gather under a sub-header carrying the test name. Test names come from the caller (Verify) and are optional; without them, items are labeled by call site. Two items that would read identically — the same file name and line in two projects — grow the shortest distinguishing directory prefix. -Every header carries a marker: `-` when open, `+` when folded. Clicking a header folds its group, -and the header's right-click menu offers the same. A fold is only a view — what it hides is still -pending, still counted by the header hiding it, and still taken by **Accept all**. `Tab` steps over -folded items rather than into them, and anything that selects an item from outside the window -unfolds whatever was hiding it. +Every header carries a marker: `-` when open, `+` when folded. Clicking a header folds its group, and the header's right-click menu offers the same. A fold is only a view — what it hides is still pending, still counted by the header hiding it, and still taken by **Accept all**. `Tab` steps over folded items rather than into them, and anything that selects an item from outside the window unfolds whatever was hiding it. ## Conflicting snapshots -A test run under several target frameworks can produce different content for the same call site. -The queue keeps each distinct content as a labeled variant of one entry — `net8.0`, `net9.0` — -rather than letting the last writer win. Identical content from several frameworks merges into one -variant carrying all their labels. +A test run under several target frameworks can produce different content for the same call site. The queue keeps each distinct content as a labeled variant of one entry — `net8.0`, `net9.0` — rather than letting the last writer win. Identical content from several frameworks merges into one variant carrying all their labels. -A conflicted entry is marked `*` in the list, the pane header names the framework on screen -(`received (net8.0)`), and a **Variant** button (or `v`) cycles through the disagreeing contents. -Accepting applies exactly the variant on screen and resolves the whole call site; a framework that -still disagrees will re-report on its next run. **Accept all** never picks sides: it skips -conflicted entries and says how many still need review. A framework whose test starts passing -settles only its own variant, so the other framework's still-failing content stays reviewable. +A conflicted entry is marked `*` in the list, the pane header names the framework on screen (`received (net8.0)`), and a **Variant** button (or `v`) cycles through the disagreeing contents. Accepting applies exactly the variant on screen and resolves the whole call site; a framework that still disagrees will re-report on its next run. **Accept all** never picks sides: it skips conflicted entries and says how many still need review. A framework whose test starts passing settles only its own variant, so the other framework's still-failing content stays reviewable. ## Moves and deletes -When [DiffEngineTray](/docs/tray.md) owns the queue, the viewer also lists the tray's pending file -moves and deletes beside the snapshots, grouped by solution like everything else. A move shows the -received file against the committed one; a delete shows the file's content against nothing. The -files are read locally — the protocol never leaves the machine — and accept and discard are -forwarded to the tray, which is why the buttons name the act: **Accept move**, **Accept delete**. +When [DiffEngineTray](/docs/tray.md) owns the queue, the viewer also lists the tray's pending file moves and deletes beside the snapshots, grouped by solution like everything else. A move shows the received file against the committed one; a delete shows the file's content against nothing. The files are read locally — the protocol never leaves the machine — and accept and discard are forwarded to the tray, which is why the buttons name the act: **Accept move**, **Accept delete**. -**Accept all** on a tray-owned queue sweeps everything the window shows: deletes, moves and -snapshots, with conflicted snapshots skipped and anything locked kept pending and counted. +**Accept all** on a tray-owned queue sweeps everything the window shows: deletes, moves and snapshots, with conflicted snapshots skipped and anything locked kept pending and counted. -A viewer that owns the queue itself never shows moves or deletes, because DiffEngine only sends -them to a running tray. +A viewer that owns the queue itself never shows moves or deletes, because DiffEngine only sends them to a running tray. ## Images -`.png`, `.jpg`, `.jpeg`, `.gif`, `.bmp`, `.webp` and `.ico` are compared as pictures rather than as -text, wherever they turn up: a pair passed on the command line, or a move or delete the tray is -holding. +`.png`, `.jpg`, `.jpeg`, `.gif`, `.bmp`, `.webp` and `.ico` are compared as pictures rather than as text, wherever they turn up: a pair passed on the command line, or a move or delete the tray is holding. -Each pane lists what its own side is — format, pixel dimensions and byte count — with a property -that matches the other side reading as unchanged and one that does not reading as modified, the same -colouring a line of text gets. Whether the two are the same picture belongs to the pair rather than -to either side, so it is stated in the status line: **images are identical**, **images differ**, or -**only \ exists** when one side has nothing yet, which is the normal state of a brand new -image snapshot. +Each pane lists what its own side is — format, pixel dimensions and byte count — with a property that matches the other side reading as unchanged and one that does not reading as modified, the same colouring a line of text gets. Whether the two are the same picture belongs to the pair rather than to either side, so it is stated in the status line: **images are identical**, **images differ**, or **only \ exists** when one side has nothing yet, which is the normal state of a brand new image snapshot. -The extension decides, not the content. A `.png` holding something that is not one is still an -image side, and says its format was not recognized instead of rendering the bytes as text. +The extension decides, not the content. A `.png` holding something that is not one is still an image side, and says its format was not recognized instead of rendering the bytes as text. -Each pane also draws the picture itself, one blank line under those rows: fitted to the space, -never enlarged past its own size, on a checkerboard so transparency reads as transparent. All three -heads place it identically, from the size the file's own header gave rather than from whatever -their decoder reported. +Each pane also draws the picture itself, one blank line under those rows: fitted to the space, never enlarged past its own size, on a checkerboard so transparency reads as transparent. All three heads place it identically, from the size the file's own header gave rather than from whatever their decoder reported. -Which formats can be drawn is the platform's answer rather than the viewer's, because each head -uses the decoder its toolkit ships with: +Which formats can be drawn is the platform's answer rather than the viewer's, because each head uses the decoder its toolkit ships with: | Head | Drawn | | --- | --- | @@ -211,58 +151,36 @@ uses the decoder its toolkit ships with: | macOS (ImageIO) | all seven | | Linux (raylib) | `.png` `.jpg` `.jpeg` `.gif` `.bmp` | -A format a head cannot decode draws nothing, and the comparison is still there in the rows above -it. That is why those rows are the description and the picture is an addition to it. +A format a head cannot decode draws nothing, and the comparison is still there in the rows above it. That is why those rows are the description and the picture is an addition to it. -Accepting is the same act it is for text — copy the received file over the expected one, or forward -the move to the tray — so nothing about reviewing an image changes what accepting one does. +Accepting is the same act it is for text — copy the received file over the expected one, or forward the move to the tray — so nothing about reviewing an image changes what accepting one does. ## With DiffEngineTray -The tray starts at login, so it normally binds the port first and holds the queue. The viewer then -displays it: it reads the pending snapshots back over the socket and forwards accept and discard -rather than applying them. That is what `--attach` is for, and the tray starts one whenever a -snapshot arrives with no window open. +The tray starts at login, so it normally binds the port first and holds the queue. The viewer then displays it: it reads the pending snapshots back over the socket and forwards accept and discard rather than applying them. That is what `--attach` is for, and the tray starts one whenever a snapshot arrives with no window open. -The point of that arrangement is that the queue outlives the window. A viewer that is closed, -killed or crashes takes nothing with it, and there is no 52 MB process kept resident purely to hold -a list. +The point of that arrangement is that the queue outlives the window. A viewer that is closed, killed or crashes takes nothing with it, and there is no 52 MB process kept resident purely to hold a list. -If a viewer was already running when the tray started, the viewer keeps the queue for as long as it -lives and the tray drives it remotely instead. Ownership is decided once and never moves. Either -way both surfaces run the same queue implementation, so they cannot disagree on what accepting or -settling means, and the tray's **Pending Snapshots** group can accept, discard, open the viewer on -a particular snapshot, and close the viewer. +If a viewer was already running when the tray started, the viewer keeps the queue for as long as it lives and the tray drives it remotely instead. Ownership is decided once and never moves. Either way both surfaces run the same queue implementation, so they cannot disagree on what accepting or settling means, and the tray's **Pending Snapshots** group can accept, discard, open the viewer on a particular snapshot, and close the viewer. A tray restart loses the queue, as it loses pending file moves and deletes. Re-run the tests. ## With no tray -Pending file moves and deletes go to the tray when one is running. When one is not, they go to the -viewer, which holds and applies them itself — so a received file waiting to be promoted, or a -verified file a passing test no longer produces, is reviewable rather than invisible. +Pending file moves and deletes go to the tray when one is running. When one is not, they go to the viewer, which holds and applies them itself — so a received file waiting to be promoted, or a verified file a passing test no longer produces, is reviewable rather than invisible. -A pending delete starts a viewer if none is running. It is the one change with no second file to -compare against, so no diff tool ever opens for it, and a window is the only surface it can have. A pending move does not start one: DiffEngine has already opened a diff tool for that file -pair, and a second window competing with it is not an improvement. A move joins a window that is -already open. +A pending delete starts a viewer if none is running. It is the one change with no second file to compare against, so no diff tool ever opens for it, and a window is the only surface it can have. A pending move does not start one: DiffEngine has already opened a diff tool for that file pair, and a second window competing with it is not an improvement. A move joins a window that is already open. -Both look and behave exactly as they do when the tray owns them — same rows, same context menu, -same **Accept all** — because which process is holding a pending file depends only on whether a -tray happened to be running. +Both look and behave exactly as they do when the tray owns them — same rows, same context menu, same **Accept all** — because which process is holding a pending file depends only on whether a tray happened to be running. ## Disabling -Set `DiffEngine_InlineViewer` to `false` to stop inline snapshots opening a window. The viewer also -never launches when [DiffEngine is disabled](/docs/#disabled), which covers build servers, -continuous testing and AI CLIs. +Set `DiffEngine_InlineViewer` to `false` to stop inline snapshots opening a window. The viewer also never launches when [DiffEngine is disabled](/docs/#disabled), which covers build servers, continuous testing and AI CLIs. ## Platforms -Ships for `win-x64`, `win-arm64`, `linux-x64`, `linux-arm64`, `osx-x64` and `osx-arm64`. On a -platform with no matching build, resolution falls through to a globally installed -DiffEngineViewer tool, and then to whatever other diff tool is available. +Ships for `win-x64`, `win-arm64`, `linux-x64`, `linux-arm64`, `osx-x64` and `osx-arm64`. On a platform with no matching build, resolution falls through to a globally installed DiffEngineViewer tool, and then to whatever other diff tool is available. diff --git a/readme.md b/readme.md index 91212d86..26b66af5 100644 --- a/readme.md +++ b/readme.md @@ -7,13 +7,7 @@ To change this file edit the source file and then run MarkdownSnippets. # DiffEngine -[![Discussions](https://img.shields.io/badge/Verify-Discussions-yellow?svg=true&label=)](https://github.com/orgs/VerifyTests/discussions) -[![Build status](https://github.com/VerifyTests/DiffEngine/actions/workflows/test.yml/badge.svg?branch=main)](https://github.com/VerifyTests/DiffEngine/actions/workflows/test.yml) -[![NuGet Status](https://img.shields.io/nuget/v/DiffEngine.svg?label=DiffEngine)](https://www.nuget.org/packages/DiffEngine/) -[![NuGet Status](https://img.shields.io/nuget/v/DiffEngineTray.svg?label=DiffEngineTray)](https://www.nuget.org/packages/DiffEngineTray/) -[![NuGet Status](https://img.shields.io/nuget/v/DiffEngineViewer.Windows.svg?label=DiffEngineViewer.Windows)](https://www.nuget.org/packages/DiffEngineViewer.Windows/) -[![NuGet Status](https://img.shields.io/nuget/v/DiffEngineViewer.Mac.svg?label=DiffEngineViewer.Mac)](https://www.nuget.org/packages/DiffEngineViewer.Mac/) -[![NuGet Status](https://img.shields.io/nuget/v/DiffEngineViewer.Linux.svg?label=DiffEngineViewer.Linux)](https://www.nuget.org/packages/DiffEngineViewer.Linux/) +[![Discussions](https://img.shields.io/badge/Verify-Discussions-yellow?svg=true&label=)](https://github.com/orgs/VerifyTests/discussions) [![Build status](https://github.com/VerifyTests/DiffEngine/actions/workflows/test.yml/badge.svg?branch=main)](https://github.com/VerifyTests/DiffEngine/actions/workflows/test.yml) [![NuGet Status](https://img.shields.io/nuget/v/DiffEngine.svg?label=DiffEngine)](https://www.nuget.org/packages/DiffEngine/) [![NuGet Status](https://img.shields.io/nuget/v/DiffEngineTray.svg?label=DiffEngineTray)](https://www.nuget.org/packages/DiffEngineTray/) [![NuGet Status](https://img.shields.io/nuget/v/DiffEngineViewer.Windows.svg?label=DiffEngineViewer.Windows)](https://www.nuget.org/packages/DiffEngineViewer.Windows/) [![NuGet Status](https://img.shields.io/nuget/v/DiffEngineViewer.Mac.svg?label=DiffEngineViewer.Mac)](https://www.nuget.org/packages/DiffEngineViewer.Mac/) [![NuGet Status](https://img.shields.io/nuget/v/DiffEngineViewer.Linux.svg?label=DiffEngineViewer.Linux)](https://www.nuget.org/packages/DiffEngineViewer.Linux/) DiffEngine manages launching and cleanup of diff tools. It is designed to be used by any Snapshot/Approval testing library. diff --git a/readme.source.md b/readme.source.md index 1743646d..52413062 100644 --- a/readme.source.md +++ b/readme.source.md @@ -1,12 +1,6 @@ # DiffEngine -[![Discussions](https://img.shields.io/badge/Verify-Discussions-yellow?svg=true&label=)](https://github.com/orgs/VerifyTests/discussions) -[![Build status](https://github.com/VerifyTests/DiffEngine/actions/workflows/test.yml/badge.svg?branch=main)](https://github.com/VerifyTests/DiffEngine/actions/workflows/test.yml) -[![NuGet Status](https://img.shields.io/nuget/v/DiffEngine.svg?label=DiffEngine)](https://www.nuget.org/packages/DiffEngine/) -[![NuGet Status](https://img.shields.io/nuget/v/DiffEngineTray.svg?label=DiffEngineTray)](https://www.nuget.org/packages/DiffEngineTray/) -[![NuGet Status](https://img.shields.io/nuget/v/DiffEngineViewer.Windows.svg?label=DiffEngineViewer.Windows)](https://www.nuget.org/packages/DiffEngineViewer.Windows/) -[![NuGet Status](https://img.shields.io/nuget/v/DiffEngineViewer.Mac.svg?label=DiffEngineViewer.Mac)](https://www.nuget.org/packages/DiffEngineViewer.Mac/) -[![NuGet Status](https://img.shields.io/nuget/v/DiffEngineViewer.Linux.svg?label=DiffEngineViewer.Linux)](https://www.nuget.org/packages/DiffEngineViewer.Linux/) +[![Discussions](https://img.shields.io/badge/Verify-Discussions-yellow?svg=true&label=)](https://github.com/orgs/VerifyTests/discussions) [![Build status](https://github.com/VerifyTests/DiffEngine/actions/workflows/test.yml/badge.svg?branch=main)](https://github.com/VerifyTests/DiffEngine/actions/workflows/test.yml) [![NuGet Status](https://img.shields.io/nuget/v/DiffEngine.svg?label=DiffEngine)](https://www.nuget.org/packages/DiffEngine/) [![NuGet Status](https://img.shields.io/nuget/v/DiffEngineTray.svg?label=DiffEngineTray)](https://www.nuget.org/packages/DiffEngineTray/) [![NuGet Status](https://img.shields.io/nuget/v/DiffEngineViewer.Windows.svg?label=DiffEngineViewer.Windows)](https://www.nuget.org/packages/DiffEngineViewer.Windows/) [![NuGet Status](https://img.shields.io/nuget/v/DiffEngineViewer.Mac.svg?label=DiffEngineViewer.Mac)](https://www.nuget.org/packages/DiffEngineViewer.Mac/) [![NuGet Status](https://img.shields.io/nuget/v/DiffEngineViewer.Linux.svg?label=DiffEngineViewer.Linux)](https://www.nuget.org/packages/DiffEngineViewer.Linux/) include: intro diff --git a/src/DiffEngine.Tests/CsStringLiteralTests.cs b/src/DiffEngine.Tests/CsStringLiteralTests.cs index 96c6252c..f5af28cf 100644 --- a/src/DiffEngine.Tests/CsStringLiteralTests.cs +++ b/src/DiffEngine.Tests/CsStringLiteralTests.cs @@ -213,4 +213,16 @@ public async Task ParseRejectsMalformedRawIndent() var parsed = CsStringLiteral.TryParse(expression, out _); await Assert.That(parsed).IsFalse(); } + + // The indent is stripped by ordinal prefix, so a tab and the spaces it displays as + // are not interchangeable, whichever side each is on + [Test] + [Arguments("\"\"\"\n\ta\n \"\"\"")] + [Arguments("\"\"\"\n a\n\t\"\"\"")] + [Arguments("\"\"\"\n\t a\n \t\"\"\"")] + public async Task ParseRejectsMixedIndentCharacters(string expression) + { + var parsed = CsStringLiteral.TryParse(expression, out _); + await Assert.That(parsed).IsFalse(); + } } diff --git a/src/DiffEngine.Tests/InlinePatcherTests.cs b/src/DiffEngine.Tests/InlinePatcherTests.cs index 74bfabde..8af4f9cc 100644 --- a/src/DiffEngine.Tests/InlinePatcherTests.cs +++ b/src/DiffEngine.Tests/InlinePatcherTests.cs @@ -542,6 +542,251 @@ public async Task TabIndentedFileUsesTabUnit() await Assert.That(newSource).Contains("Snapshot(\n\t\t\t\"\"\"\n\t\t\ta\n\t\t\tb\n\t\t\t\"\"\");"); } + static string TabMethod(string body) => + $"class Tests\n{{\n\tasync Task Test()\n\t{{\n{body}\n\t}}\n}}"; + + // Append works out its own indent unit, separately from the replace path + [Test] + 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 _); + + await Assert.That(status).IsEqualTo(PatchStatus.Applied); + await Assert.That(newSource).IsEqualTo( + TabMethod( + "\t\tawait Verify(value)\n" + + "\t\t\t.Snapshot(\n" + + "\t\t\t\t\"\"\"\n" + + "\t\t\t\ta\n" + + "\t\t\t\tb\n" + + "\t\t\t\t\"\"\");")); + } + + // The chain already sets the call indent, so only the content level comes from the unit + [Test] + public async Task AppendToATabIndentedChain() + { + var source = TabMethod( + "\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 _); + + await Assert.That(status).IsEqualTo(PatchStatus.Applied); + await Assert.That(newSource).IsEqualTo( + TabMethod( + "\t\tawait Verify(value)\n" + + "\t\t\t.UseDirectory(\"snapshots\")\n" + + "\t\t\t.Snapshot(\n" + + "\t\t\t\t\"\"\"\n" + + "\t\t\t\ta\n" + + "\t\t\t\tb\n" + + "\t\t\t\t\"\"\");")); + } + + [Test] + public async Task RemoveFromATabIndentedChain() + { + var source = TabMethod( + "\t\tawait Verify(value)\n" + + "\t\t\t.Snapshot(\"\"\"\n" + + "\t\t\t\told\n" + + "\t\t\t\t\"\"\");"); + + var status = InlinePatcher.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);")); + } + + static string MixedIndentSites() => + string.Join( + "\n", + "class Tests", + "{", + " async Task Spaces()", + " {", + " await Verify(a).Snapshot(\"a\");", + " }", + "", + "\tasync Task Tabs()", + "\t{", + "\t\tawait Verify(b).Snapshot(\"b\");", + "\t}", + "}"); + + // The unit comes from the call site's own line, not from the file, so a file that + // indents inconsistently keeps each site consistent with itself + [Test] + public async Task MixedIndentFileUsesTheSiteIndent() + { + var source = MixedIndentSites(); + + var spaces = InlinePatcher.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 _); + + await Assert.That(spaces).IsEqualTo(PatchStatus.Applied); + await Assert.That(tabs).IsEqualTo(PatchStatus.Applied); + // Neither site picked up the other's whitespace + await Assert.That(afterTabs).IsEqualTo( + string.Join( + "\n", + "class Tests", + "{", + " async Task Spaces()", + " {", + " await Verify(a).Snapshot(", + " \"\"\"", + " a1", + " a2", + " \"\"\");", + " }", + "", + "\tasync Task Tabs()", + "\t{", + "\t\tawait Verify(b).Snapshot(", + "\t\t\t\"\"\"", + "\t\t\tb1", + "\t\t\tb2", + "\t\t\t\"\"\");", + "\t}", + "}")); + } + + // Tabs for indentation, spaces for alignment. The site's indentation ends in spaces, so + // the level added continues in spaces: a tab would advance to the next tab stop from + // wherever the alignment left off, a different width in every editor + [Test] + 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 _); + + await Assert.That(status).IsEqualTo(PatchStatus.Applied); + await Assert.That(newSource).Contains( + "Snapshot(\n" + + "\t \"\"\"\n" + + "\t a\n" + + "\t b\n" + + "\t \"\"\");"); + } + + static string TwoSpaceMethod(string body) => + $"class Tests\n{{\n async Task Test()\n {{\n{body}\n }}\n}}"; + + // A level is whatever the file makes it, not four spaces + [Test] + 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 _); + + await Assert.That(status).IsEqualTo(PatchStatus.Applied); + await Assert.That(newSource).IsEqualTo( + TwoSpaceMethod( + " await Snapshot(\n" + + " \"\"\"\n" + + " a\n" + + " b\n" + + " \"\"\");")); + } + + [Test] + 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 _); + + await Assert.That(status).IsEqualTo(PatchStatus.Applied); + await Assert.That(newSource).IsEqualTo( + TwoSpaceMethod( + " await Verify(value)\n" + + " .Snapshot(\n" + + " \"\"\"\n" + + " a\n" + + " b\n" + + " \"\"\");")); + } + + // The snapshot's own content lines are text, not indentation. Here they step by four and + // outnumber the one real step of two, so counting them would measure the snapshot + [Test] + public async Task LiteralContentDoesNotSetTheUnit() + { + var expression = string.Join( + "\n", + "\"\"\"", + " a", + " b", + " c", + " \"\"\""); + var source = string.Join( + "\n", + "class Tests", + "{", + $" Task T() => Snapshot({expression});", + "}"); + + var status = InlinePatcher.TryApply(source, 3, InlinePatchMode.Set, expression, "x\ny", out var newSource, out _); + + await Assert.That(status).IsEqualTo(PatchStatus.Applied); + await Assert.That(newSource).IsEqualTo( + string.Join( + "\n", + "class Tests", + "{", + " Task T() => Snapshot(", + " \"\"\"", + " x", + " y", + " \"\"\");", + "}")); + } + + // Nothing in the file indents, so there is no step to read and the default stands + [Test] + 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 _); + + await Assert.That(status).IsEqualTo(PatchStatus.Applied); + await Assert.That(newSource).Contains( + "Snapshot(\n" + + " \"\"\"\n" + + " a\n" + + " b\n" + + " \"\"\");"); + } + + // A literal whose content lines and closing delimiter disagree on tabs versus spaces + // is not something the parser can strip an indent from, so it is left alone + [Test] + public async Task LiteralWithMismatchedIndentCharactersIsNotPatched() + { + var source = string.Join( + "\n", + "class Tests", + "{", + " async Task Test() =>", + " Snapshot(\"\"\"", + "\t old", + " \"\"\");", + "}"); + + var status = InlinePatcher.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"); + } + [Test] public async Task HintBeyondEndOfFile() { diff --git a/src/DiffEngine/Inline/InlinePatcher.cs b/src/DiffEngine/Inline/InlinePatcher.cs index 1c5d23b0..bb5be7e2 100644 --- a/src/DiffEngine/Inline/InlinePatcher.cs +++ b/src/DiffEngine/Inline/InlinePatcher.cs @@ -54,9 +54,11 @@ public static PatchStatus TryApply( return TryRemove(source, scan, lineStarts, lineHint, ref newSource, ref failReason); } + var fileUnit = DetectIndentUnit(source, scan, lineStarts); + if (mode == InlinePatchMode.Append) { - return TryAppend(source, scan, lineStarts, lineHint, newContent, eol, ref newSource, ref failReason); + return TryAppend(source, scan, lineStarts, lineHint, newContent, eol, fileUnit, ref newSource, ref failReason); } if (!string.IsNullOrEmpty(originalExpression)) @@ -83,16 +85,16 @@ public static PatchStatus TryApply( return PatchStatus.AlreadyApplied; } - var rendered = RenderArgument(source, lineStarts, expected.Start, newContent, eol); + var rendered = RenderArgument(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, alreadyOnly: true, ref newSource, ref failReason); + return InsertOrCheck(source, scan, lineStarts, lineHint, newContent, eol, fileUnit, alreadyOnly: true, ref newSource, ref failReason); } - return InsertOrCheck(source, scan, lineStarts, lineHint, newContent, eol, alreadyOnly: false, ref newSource, ref failReason); + return InsertOrCheck(source, scan, lineStarts, lineHint, newContent, eol, fileUnit, alreadyOnly: false, ref newSource, ref failReason); } static PatchStatus InsertOrCheck( @@ -102,6 +104,7 @@ static PatchStatus InsertOrCheck( int lineHint, string newContent, string eol, + string fileUnit, bool alreadyOnly, ref string newSource, ref string failReason) @@ -127,7 +130,7 @@ static PatchStatus InsertOrCheck( return PatchStatus.NotFound; } - var emptyRendered = RenderArgument(source, lineStarts, expected.Start, newContent, eol); + var emptyRendered = RenderArgument(source, lineStarts, expected.Start, newContent, eol, fileUnit); newSource = Splice(source, expected.Start, expected.Start, emptyRendered); return PatchStatus.Applied; } @@ -142,7 +145,7 @@ static PatchStatus InsertOrCheck( return PatchStatus.NotFound; } - var namedIndent = IndentForSpan(source, lineStarts, expected.ListStart); + var namedIndent = IndentForSpan(source, lineStarts, expected.ListStart, fileUnit); var namedRendered = CsStringLiteral.Render(newContent, namedIndent, eol); newSource = Splice(source, expected.ListStart, expected.ListStart, $"{parameterName}: {namedRendered}, "); return PatchStatus.Applied; @@ -157,7 +160,7 @@ static PatchStatus InsertOrCheck( return PatchStatus.NotFound; } - var rendered = RenderArgument(source, lineStarts, expected.Start, newContent, eol); + var rendered = RenderArgument(source, lineStarts, expected.Start, newContent, eol, fileUnit); newSource = Splice(source, expected.Start, expected.End, rendered); return PatchStatus.Applied; } @@ -254,6 +257,7 @@ static PatchStatus TryAppend( int lineHint, string newContent, string eol, + string fileUnit, ref string newSource, ref string failReason) { @@ -278,7 +282,7 @@ static PatchStatus TryAppend( } var statementIndent = LeadingWhitespace(source, lineStarts, nameStart); - var unit = statementIndent.Contains('\t') ? "\t" : " "; + var unit = UnitFor(fileUnit, statementIndent); // Line up with the existing chain when there is one, otherwise start it one level in var callIndent = LineOf(lineStarts, insertAt - 1) == LineOf(lineStarts, nameStart) ? statementIndent + unit @@ -731,9 +735,9 @@ 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) + static string RenderArgument(string source, List lineStarts, int spanStart, string newContent, string eol, string fileUnit) { - var indent = IndentForSpan(source, lineStarts, spanStart); + var indent = IndentForSpan(source, lineStarts, spanStart, fileUnit); var rendered = CsStringLiteral.Render(newContent, indent, eol); if (StartsLine(source, lineStarts, spanStart)) { @@ -809,6 +813,119 @@ static string DetectEol(string source) return Environment.NewLine; } + /// + /// What one level of indentation is made of in this file: the most common run of whitespace a + /// line adds to the one above it. + /// + /// Read off the source rather than taken from a convention, because a splice has to match the + /// code it lands in, and files disagree with their repo's settings often enough - vendored, + /// generated, or last edited by someone configured differently - that following the convention + /// would make the patch look more out of place, not less. It answers the one question a single + /// call site cannot: a line shows which characters it is indented with, but not how wide a + /// level is, and hard coding four spaces is wrong in every two space repo. + /// + /// 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) + { + Dictionary counts = new(StringComparer.Ordinal); + var previous = ""; + foreach (var lineStart in lineStarts) + { + // Inside a comment or a literal the leading whitespace is content, not indentation. + // A snapshot literal in particular is arbitrary text, and counting its lines would + // measure the snapshot rather than the file + if (!scan.IsCode(lineStart)) + { + continue; + } + + var index = lineStart; + while (index < source.Length && + (source[index] == ' ' || source[index] == '\t')) + { + index++; + } + + // A blank line has no indentation of its own, and must not break the run either + if (index >= source.Length || + source[index] == '\r' || + source[index] == '\n') + { + continue; + } + + var lead = source.Substring(lineStart, index - lineStart); + // Only a line that indents further than the one above, by adding to what it already + // had. Anything else is a dedent, or whitespace of a different kind, and neither + // measures a step + if (lead.Length > previous.Length && + lead.StartsWith(previous, StringComparison.Ordinal)) + { + var step = lead.Substring(previous.Length); + counts.TryGetValue(step, out var count); + counts[step] = count + 1; + } + + previous = lead; + } + + var best = ""; + var bestCount = 0; + foreach (var pair in counts) + { + if (bestCount == 0 || + pair.Value > bestCount || + pair.Value == bestCount && Closer(pair.Key, best)) + { + best = pair.Key; + bestCount = pair.Value; + } + } + + return best; + + // A tie goes to the shorter step, since a longer one is two levels taken at once, and + // then to ordinal order so the answer cannot depend on enumeration order + static bool Closer(string candidate, string current) => + candidate.Length == current.Length + ? string.CompareOrdinal(candidate, current) < 0 + : candidate.Length < current.Length; + } + + /// + /// One level of indentation for a splice at a site indented with . + /// + /// The character comes from the site and the width from the file, so a file that indents + /// inconsistently still gets a splice consistent with its own surroundings, while a file that + /// indents by something other than four spaces gets that. + /// + /// + static string UnitFor(string fileUnit, string lead) + { + var fileUsesTabs = fileUnit.Length > 0 && fileUnit[0] == '\t'; + // The character the site's own indentation ends in decides, so tabs for depth followed by + // spaces for alignment continues in spaces: a tab there would advance to the next tab stop + // from wherever the alignment left off, which is a different width in every editor. With + // no indentation to read, follow the file + var tabs = lead.Length > 0 ? lead[lead.Length - 1] == '\t' : fileUsesTabs; + if (tabs) + { + return "\t"; + } + + // The file's step is tabs, or there was none to find, so it says nothing about how wide a + // space indent should be + if (fileUsesTabs || + fileUnit.Length == 0) + { + return " "; + } + + return fileUnit; + } + static string NormalizeTo(string value, string eol) => value .Replace("\r\n", "\n") @@ -849,7 +966,7 @@ static int LineOf(List lineStarts, int offset) return low + 1; } - static string IndentForSpan(string source, List lineStarts, int spanStart) + static string IndentForSpan(string source, List lineStarts, int spanStart, string fileUnit) { var line = LineOf(lineStarts, spanStart); var lineStart = lineStarts[line - 1]; @@ -869,7 +986,6 @@ static string IndentForSpan(string source, List lineStarts, int spanStart) } var leadText = lead.ToString(); - var unit = leadText.Contains('\t') ? "\t" : " "; - return leadText + unit; + return leadText + UnitFor(fileUnit, leadText); } }