diff --git a/packages/diffs/test/README.md b/packages/diffs/test/README.md index fe2bcd606..1d715b1bb 100644 --- a/packages/diffs/test/README.md +++ b/packages/diffs/test/README.md @@ -18,6 +18,27 @@ AGENT=1 bun test be a compact projection of just the behavior its test owns, small enough to review line by line. When a snapshot fails, read the diff — do not reflexively `bun test -u`. +- `test.failing(...)` marks a **known bug**: the test encodes the _correct_ + expected behavior and currently fails. When the bug is fixed, `bun test` will + report the test as unexpectedly passing — remove the `.failing` modifier then. + `DIVERGENCE:` comments pin places where this package intentionally (or at + least knowingly) behaves differently from an alternative it was compared + against; those tests pin _our_ behavior and document the difference — they are + decisions, not bugs. `KNOWN BUG:` comments accompany every `test.failing` with + the root cause. + +## Provenance + +A set of behavioral scenarios in this suite was derived by auditing the test +suites of other open-source editors — microsoft/vscode @ +`86f5a62f058e3905f74a9fa65d04b2f3b533408e`, CodeMirror 6 +(`state@9c801279cb83011e6f92af778f4443406e8f1200`, +`commands@5b9bac974f2c4af3e20b045adef949667872ecad`), and +atom/text-buffer@`b1f093269b175ce6cc9728c7a4d50ca75bb031b6` + +atom/superstring@`6732087fac04cd68d14e93d4f83f246879200ab5` (all MIT) — as +behavioral rewrites with original names, fixtures, helpers, and granularity; no +code was copied. Future derivations must use permissively-licensed sources only +and follow the same rewrite discipline. ## Known coverage gaps (confirmed by the 2026-06 test audit) @@ -51,3 +72,105 @@ order. If you touch one of these areas, consider adding the missing coverage: branches. - **DOM virtualization buffers** (`data-virtualizer-buffer`): created by File/FileDiff `applyBuffers` on live DOM; no test asserts them anywhere. +- **Forward word-delete family** (delete-word-right, delete-word-start-right) + and **deleteInsideWord** — no equivalent commands exist yet. +- **Line-join whitespace collapsing** — a "join lines" command that collapses + the whitespace at the join point is not implemented. +- **Forward transpose at end-of-line** — transposing characters across a line + break in the forward direction has no implementation. +- **CJK visual-column vertical movement** — vertical caret movement does not + preserve the visual column across full-width CJK characters (requires canvas + text-measure stubbing to test properly). +- **Preferred line-ending override** — no setter/option lets a host force an + LF/CRLF policy for inserted text; line ending is a derived getter only. +- **Range-scoped search** — search params have no range field; selection-scoped + find/replace is not possible today. +- **Multi-line pattern search** — the piece-table search rejects patterns + containing line breaks; matching across line breaks is unsupported. +- **Transactions, checkpoints, and retroactive change grouping** — no + `transact()`/nested-transaction/abort API, no checkpoint/revert-to-checkpoint + concept, and no public API to retroactively merge the last N history entries; + history grouping today is purely geometric typing-coalescing plus an + undo-boundary marker. +- **Edit-tracking markers with invalidation strategies** — markers are + render-only today; there is no remapping of marker positions through edits + with configurable invalidation strategies. +- **Soft-wrap continuation-row hanging indent** — wrapped continuation rows do + not support a hanging indent. +- **Time-based undo grouping** — undo coalescing is geometry-based with no clock + input; a `newGroupDelay`-style time window is a policy decision, not + implemented. +- **IME composition deferrals** — IME/composition interaction with editing and + undo-coalescing is deferred and not covered. + +## Known bugs pinned as `test.failing` (32) + +- **EditStack coalescing** (3) — coalescing decisions compare a new edit against + whatever sits on top of the undo stack purely by geometry, with no state reset + after undo/redo: an undo can expose a stale top entry that new typing then + fuses into; an undo-boundary marker stops blocking merges once it is undone; + and backspace followed by forward-delete at the same pivot coalesces into a + single undo step instead of getting an undo stop when the delete direction + flips. +- **Surrogate-pair edit boundaries** (3) — edit range endpoints landing strictly + inside a surrogate pair split the pair and corrupt the buffer instead of + snapping to pair boundaries; affects insert-inside-a-pair and replaces + starting or ending inside one. +- **PieceTable CRLF line metadata** (5, spanning piece-table and search + coverage) — line-break bookkeeping goes stale when a CRLF pair is split or + assembled across edits (deleting exactly the `\n` of a pair, inserting between + the `\r` and `\n`, assembling CRLF from two separate inserts, plus a + CRLF-biased fuzz oracle), and the same stale metadata drives search astray + (shifted or missing match ranges) even though `getText()` stays correct. +- **Batch edit ordering sensitivity** (1) — accepting a batch containing a + delete and an insert at the same offset depends on the caller's array order: + delete-first throws an overlap error, insert-first succeeds. +- **Line indent/outdent multi-selection dedupe** (3) — indent dispatch + concatenates per-selection edits with no shared-line dedupe, so a line under + two carets/ranges indents twice; the outdent variant emits two identical + deletes that fail overlap validation and the whole command throws. +- **Block indent dirties blank lines** (1) — a multi-line block indent inserts + the indent unit on empty and whitespace-only lines inside the selection, + injecting trailing whitespace on lines the user never touched (outdent + round-trips it away, bounding the damage). +- **History equivalence across non-history edits** (7) — edits applied with + `updateHistory=false` are an implementation detail of how an edit reaches + `applyEdits`, not a separate semantic class: a mixed programmatic/local + sequence must leave history equivalent to the same sequence applied all-local, + so undo-to-exhaustion restores the original byte-exact text and + redo-to-exhaustion the final text. Today untracked edits bypass the edit stack + and existing entries apply at stale offsets, so exhaustion corrupts instead: a + stale-offset undo deletes the wrong characters, a replacement batch breaks + across an interleaved untracked insert, an untracked interior insert is erased + while tracked text is stranded, an untracked whole-document replace produces + spliced states that never existed on any timeline, typing coalesced around an + untracked insert unwinds to the untracked remainder instead of the original + text, the unwind that should restore recorded selections verbatim never + reaches the original text, and a pending redo survives an untracked edit + (instead of being cleared like any new edit) and replays at stale offsets. +- **Position round-trip losing a column** (1) — a position-from-offset + computation can return a position strictly inside a CRLF pair (a character + beyond the line's own length) that the inverse offset-from-position + computation clamps away, so the round trip silently loses a column. +- **Inverted selection after normalization** (1) — setting selections normalizes + positions but never reorders a start-after-end pair, storing an inverted + selection that violates the start-before-or-equal-end invariant downstream + code assumes. +- **Search-replace capture expansion with lookaround** (3) — replacement-text + expansion re-executes the pattern against only the matched slice, so + lookaround context outside the slice is lost: a lookbehind whose context sits + before the slice, a lookahead whose context sits after the slice, and a + lookahead that re-matches shorter on the slice all fall back to inserting the + literal (unexpanded) replacement text. +- **Soft-wrap vertical motion splits surrogate pairs** (2) — vertical motion + into a wrapped continuation row computes the landing spot as raw UTF-16 units + with no grapheme/surrogate snapping, so moving down into a continuation row + (or up across a logical-line boundary) can park the caret between the halves + of an astral character; a subsequent insert there splits the pair into lone + surrogates. +- **Malformed position components destroy the document** (2) — position + normalization has no finiteness/integer guard, so a `NaN` (or fractional) + line/character flows through min/max clamping into the offset computation, the + resolved offset becomes `NaN`, and the degenerate range resolves to a + whole-document replace — an insert with one malformed position component + silently erases everything else. diff --git a/packages/diffs/test/editorApplyEdits.test.ts b/packages/diffs/test/editorApplyEdits.test.ts index 29c62bab8..39fd6b056 100644 --- a/packages/diffs/test/editorApplyEdits.test.ts +++ b/packages/diffs/test/editorApplyEdits.test.ts @@ -3,8 +3,17 @@ import { afterAll, describe, expect, mock, spyOn, test } from 'bun:test'; import { File, type FileOptions } from '../src/components/File'; import { DEFAULT_THEMES } from '../src/constants'; import { Editor, type EditorOptions, type IStateStorage } from '../src/editor'; +import { + applyTextChangeToSelections, + DirectionForward, + DirectionNone, + getSelectedLineBlocks, + resolveIndentEdits, + shiftSelectionLines, +} from '../src/editor/selection'; +import { TextDocument } from '../src/editor/textDocument'; import { disposeHighlighter } from '../src/highlighter/shared_highlighter'; -import type { FileContents } from '../src/types'; +import type { EditorSelection, FileContents, TextEdit } from '../src/types'; import { installDom, wait, waitFor } from './domHarness'; afterAll(async () => { @@ -1524,3 +1533,856 @@ describe('Editor undo/redo API', () => { } }); }); + +// --------------------------------------------------------------------------- +// The suites below drive applyEdits at the TextDocument level (no DOM +// fixture): batch ordering, selection-aware undo/redo, change records, and +// the line-based commands composed from the document primitives. +// --------------------------------------------------------------------------- + +function doc(text: string) { + return new TextDocument('inmemory://1', text, 'plain'); +} + +function edit( + startLine: number, + startCharacter: number, + endLine: number, + endCharacter: number, + newText: string +): TextEdit { + return { + range: { + start: { line: startLine, character: startCharacter }, + end: { line: endLine, character: endCharacter }, + }, + newText, + }; +} + +function caret(line: number, character: number): EditorSelection { + const position = { line, character }; + return { + start: position, + end: position, + direction: DirectionNone, + } satisfies EditorSelection; +} + +// A direction-less range selection; contrast with range() below, which +// builds a forward selection. +function select( + startLine: number, + startCharacter: number, + endLine: number, + endCharacter: number +): EditorSelection { + return { + start: { line: startLine, character: startCharacter }, + end: { line: endLine, character: endCharacter }, + direction: DirectionNone, + }; +} + +function range( + startLine: number, + startCharacter: number, + endLine: number, + endCharacter: number +): EditorSelection { + return { + start: { line: startLine, character: startCharacter }, + end: { line: endLine, character: endCharacter }, + direction: DirectionForward, + } satisfies EditorSelection; +} + +// True when `text` contains a high surrogate without its low half (or vice +// versa) — the corruption signature these tests guard against. +function hasLoneSurrogate(text: string): boolean { + for (let i = 0; i < text.length; i++) { + const unit = text.charCodeAt(i); + if (unit >= 0xd800 && unit <= 0xdbff) { + const next = i + 1 < text.length ? text.charCodeAt(i + 1) : 0; + if (next >= 0xdc00 && next <= 0xdfff) { + i++; // well-formed pair + continue; + } + return true; + } + if (unit >= 0xdc00 && unit <= 0xdfff) { + return true; + } + } + return false; +} + +// Deterministic pseudo-random source (32-bit LCG) so the randomized round-trip +// test replays the exact same edit sequence on every run. +function makeRandom(seed: number): () => number { + let state = seed >>> 0; + return () => { + state = (Math.imul(state, 1664525) + 1013904223) >>> 0; + return state / 4294967296; + }; +} + +// Mirrors the Editor's line-based indent dispatch (src/editor/editor.ts, case +// 'indentMore'/'indentLess' at ~line 1891): resolveIndentEdits runs once per +// selection and the resulting edits are concatenated into a single applyEdits +// batch, with no dedupe when two selections touch the same line. +function dispatchLineIndent( + d: TextDocument, + selections: EditorSelection[], + tabSize: number, + outdent: boolean +): EditorSelection[] { + const edits: TextEdit[] = []; + const nextSelections: EditorSelection[] = []; + for (const selection of selections) { + const [selectionEdits, nextSelection] = resolveIndentEdits( + d, + selection, + tabSize, + outdent + ); + edits.push(...selectionEdits); + nextSelections.push(nextSelection); + } + d.applyEdits(edits, true, selections); + return nextSelections; +} + +// Mirrors Editor#moveSelectedLines (src/editor/editor.ts ~line 2267): +// getSelectedLineBlocks merges the selections into line blocks, each block is +// rotated with its neighbor line in one edit, and every selection is remapped +// with shiftSelectionLines — the same composition the keyboard command runs, +// minus the DOM. The 'Editor move line commands' suite above covers the +// single-block and separate-block cases through the real Editor; the tests +// here add the merged/interleaved and same-line multi-caret behaviors. +function moveLines( + d: TextDocument, + selections: EditorSelection[], + direction: -1 | 1 +): EditorSelection[] { + const blocks = getSelectedLineBlocks(selections); + if ( + blocks.length === 0 || + (direction < 0 && blocks[0].startLine === 0) || + (direction > 0 && blocks[blocks.length - 1].endLine >= d.lineCount - 1) + ) { + return selections; + } + const lineCount = d.lineCount; + const lineRangeEnd = (line: number) => + line < lineCount - 1 + ? { line: line + 1, character: 0 } + : { line, character: d.getLineLength(line) }; + const getLinesText = (lines: number[], appendFinalLineBreak: boolean) => { + const text = lines.map((line) => d.getLineText(line)).join(d.eol); + return appendFinalLineBreak ? text + d.eol : text; + }; + const edits: TextEdit[] = []; + if (direction < 0) { + for (const block of blocks) { + const previousLine = block.startLine - 1; + const blockLines: number[] = []; + for (let line = block.startLine; line <= block.endLine; line++) { + blockLines.push(line); + } + edits.push({ + range: { + start: { line: previousLine, character: 0 }, + end: lineRangeEnd(block.endLine), + }, + newText: getLinesText( + [...blockLines, previousLine], + block.endLine < lineCount - 1 + ), + }); + } + } else { + for (let index = blocks.length - 1; index >= 0; index--) { + const block = blocks[index]; + const nextLine = block.endLine + 1; + const blockLines: number[] = []; + for (let line = block.startLine; line <= block.endLine; line++) { + blockLines.push(line); + } + edits.push({ + range: { + start: { line: block.startLine, character: 0 }, + end: lineRangeEnd(nextLine), + }, + newText: getLinesText( + [nextLine, ...blockLines], + nextLine < lineCount - 1 + ), + }); + } + } + const lastBlock = blocks[blocks.length - 1]; + const lastLineLengthAfterMove = + direction > 0 && lastBlock.endLine === lineCount - 2 + ? d.getLineLength(lastBlock.endLine) + : d.getLineLength(lineCount - 1); + const nextSelections = selections.map((selection) => + shiftSelectionLines(selection, direction, lineCount, (line) => + line === lineCount - 1 ? lastLineLengthAfterMove : d.getLineLength(line) + ) + ); + d.applyEdits(edits, true, selections, nextSelections, true); + return nextSelections; +} + +// Types a lone Enter at the primary selection, the way the Editor feeds a +// newline keystroke through applyTextChangeToSelections (which expands it via +// expandSingleNewlineInsert to carry the current line's indentation). +function pressEnter(d: TextDocument, selection: EditorSelection) { + const start = d.offsetAt(selection.start); + const end = d.offsetAt(selection.end); + return applyTextChangeToSelections(d, [selection], { + start, + end, + text: '\n', + }); +} + +describe('applyEdits: surrogate pair boundaries', () => { + // The document starts with 📚, a two-UTF-16-unit astral character occupying + // characters 0 and 1 of line 0. Character 1 therefore sits strictly between + // the high and low surrogate — an invalid caller position that the + // conventional behavior auto-corrects so the pair is never split. + + // KNOWN BUG: an insert position strictly inside a surrogate pair is not + // snapped to the pair boundary; the inserted text lands between the two + // units and getText() returns lone surrogates. + test.failing( + 'insert strictly inside a surrogate pair snaps to before the pair', + () => { + const d = doc('📚plans\nfor the\nweekend'); + d.applyEdits([edit(0, 1, 0, 1, 'a')]); + expect(hasLoneSurrogate(d.getText())).toBe(false); + expect(d.getLineText(0)).toBe('a📚plans'); + } + ); + + // KNOWN BUG: a replace range starting strictly inside a surrogate pair is + // not widened to the pair start; the high surrogate is left behind alone. + test.failing( + 'replace starting inside a surrogate pair widens to cover the whole pair', + () => { + const d = doc('📚plans\nfor the\nweekend'); + d.applyEdits([edit(0, 1, 0, 2, 'a')]); + expect(hasLoneSurrogate(d.getText())).toBe(false); + expect(d.getLineText(0)).toBe('aplans'); + } + ); + + // KNOWN BUG: a replace range ending strictly inside a surrogate pair is not + // widened to the pair end; the low surrogate is left behind alone. + test.failing( + 'replace ending inside a surrogate pair widens to cover the whole pair', + () => { + const d = doc('📚plans\nfor the\nweekend'); + d.applyEdits([edit(0, 0, 0, 1, 'a')]); + expect(hasLoneSurrogate(d.getText())).toBe(false); + expect(d.getLineText(0)).toBe('aplans'); + } + ); + + test('replace spanning exactly the whole surrogate pair replaces it cleanly', () => { + const d = doc('📚plans\nfor the\nweekend'); + d.applyEdits([edit(0, 0, 0, 2, 'a')]); + expect(d.getLineText(0)).toBe('aplans'); + expect(d.getText()).toBe('aplans\nfor the\nweekend'); + }); +}); + +describe('applyEdits: touching edits', () => { + test('two zero-width inserts at the identical position apply in input order', () => { + const d = doc('mole'); + d.applyEdits([edit(0, 1, 0, 1, 'a'), edit(0, 1, 0, 1, 'b')]); + expect(d.getText()).toBe('mabole'); + + // Swapping the input order swaps the output order: ordering comes from + // the caller's array, not from any property of the edits themselves. + const d2 = doc('mole'); + d2.applyEdits([edit(0, 1, 0, 1, 'b'), edit(0, 1, 0, 1, 'a')]); + expect(d2.getText()).toBe('mbaole'); + }); +}); + +describe('applyEdits: compound multi-cursor batches and undo', () => { + test('undo exactly restores the original text after a compound batch of touching edits', () => { + // Simulates a two-cursor move-line-up: one edit deletes a line body, + // another deletes an adjacent line including its break, and two + // zero-width inserts re-create the moved lines — all ranges touching at + // their endpoints, applied as one history transaction. + const original = 'alpha\nbravo\ncharlie\n'; + const d = doc(original); + d.applyEdits( + [ + edit(3, 0, 3, 0, 'charlie'), + edit(2, 0, 2, 7, ''), + edit(1, 0, 2, 0, ''), + edit(2, 7, 2, 7, '\nbravo'), + ], + true + ); + expect(d.getText()).toBe('alpha\n\nbravo\ncharlie'); + + expect(d.canUndo).toBe(true); + d.undo(); + expect(d.getText()).toBe(original); + + // The transaction stays reversible in both directions. + d.redo(); + expect(d.getText()).toBe('alpha\n\nbravo\ncharlie'); + d.undo(); + expect(d.getText()).toBe(original); + }); + + test('undo restores a batch that replaced two equal-size selections with shorter text', () => { + const original = 'green apples\ngreen apples'; + const d = doc(original); + // Both cursors have "apples" selected (given bottom-first, as an editor + // would after adding a selection above) and type the shorter "figs". + const selectionsBefore = [select(1, 6, 1, 12), select(0, 6, 0, 12)]; + d.applyEdits( + [edit(1, 6, 1, 12, 'figs'), edit(0, 6, 0, 12, 'figs')], + true, + selectionsBefore, + [select(1, 10, 1, 10), select(0, 10, 0, 10)] + ); + expect(d.getText()).toBe('green figs\ngreen figs'); + + const undone = d.undo(); + expect(d.getText()).toBe(original); + expect(undone?.[1]).toEqual(selectionsBefore); + }); +}); + +describe('applyEdits: astral characters and undo history', () => { + test('manually applied inverse edits round-trip inserts on both sides of an astral character', () => { + const original = 'x👁y'; + const d = doc(original); + + // Two separate applyEdits calls: one insert immediately before the + // surrogate pair, one immediately after it (positions in UTF-16 units). + d.applyEdits([edit(0, 1, 0, 1, '(')]); + d.applyEdits([edit(0, 4, 0, 4, ')')]); + expect(d.getText()).toBe('x(👁)y'); + + // Ranges near the pair still resolve correctly after the inserts. + expect( + d.getText({ + start: { line: 0, character: 2 }, + end: { line: 0, character: 4 }, + }) + ).toBe('👁'); + expect( + d.getText({ + start: { line: 0, character: 4 }, + end: { line: 0, character: 5 }, + }) + ).toBe(')'); + + // The exact inverse of both inserts, applied as one batch. + d.applyEdits([edit(0, 1, 0, 2, ''), edit(0, 4, 0, 5, '')]); + expect(d.getText()).toBe(original); + expect(hasLoneSurrogate(d.getText())).toBe(false); + }); + + test('undo and redo round-trip a history entry whose edits touch a surrogate pair', () => { + // Auto-surround of the leading quote in '👁' with %: two zero-width + // inserts in one transaction, the second landing immediately before the + // surrogate pair. + const original = "'👁'"; + const d = doc(original); + d.applyEdits([edit(0, 0, 0, 0, '%'), edit(0, 1, 0, 1, '%')], true, [ + select(0, 0, 0, 1), + ]); + expect(d.getText()).toBe("%'%👁'"); + + d.undo(); + expect(d.getText()).toBe(original); + expect(hasLoneSurrogate(d.getText())).toBe(false); + + d.redo(); + expect(d.getText()).toBe("%'%👁'"); + expect(hasLoneSurrogate(d.getText())).toBe(false); + + d.undo(); + expect(d.getText()).toBe(original); + }); +}); + +describe('applyEdits batch: insert at a deletion boundary', () => { + test('an insert listed before a delete starting at the same offset applies, keeping the insertion', () => { + // Zero-width insert at offset 5 plus delete of [5,7): the inserted text + // survives in front of the deleted span — the conventional deterministic + // handling of an insertion at a deletion's start boundary. + const d = doc('grapevines'); + d.applyEdits([edit(0, 5, 0, 5, 'XY'), edit(0, 5, 0, 7, '')]); + expect(d.getText()).toBe('grapeXYnes'); + }); + + // KNOWN BUG: acceptance of the batch {delete [5,7), insert at 5} depends on + // the caller's array order. Validation stable-sorts by start offset and + // rejects any pair where prev.end > next.start, so with the delete listed + // first the (5,7) delete stays ahead of the (5,5) insert and the same + // logical batch throws 'Overlapping text edits are not supported', while + // the insert-first order succeeds. A deterministic batch model would accept + // the batch regardless of input order. + test.failing( + 'the same logical batch with the delete listed first behaves identically', + () => { + const d = doc('grapevines'); + d.applyEdits([edit(0, 5, 0, 7, ''), edit(0, 5, 0, 5, 'XY')]); + expect(d.getText()).toBe('grapeXYnes'); + } + ); +}); + +describe('applyEdits: randomized single-edit invert round-trip', () => { + test('50 random history edits undo to the byte-identical original and redo to the final text', () => { + const insertAlphabet = 'twilight harbor\nquiet mooring\n'; + for (const seed of [11, 29, 173]) { + const rand = makeRandom(seed); + const original = 'signal flags\nover the pier\n'; + const d = doc(original); + let mirror = original; + + for (let step = 0; step < 50; step++) { + const length = mirror.length; + const from = Math.floor(rand() * (length + 1)); + const to = Math.min(length, from + Math.floor(rand() * 6)); + let insert = ''; + const insertLength = Math.floor(rand() * 5); + for (let k = 0; k < insertLength; k++) { + insert += insertAlphabet[Math.floor(rand() * insertAlphabet.length)]; + } + if (from === to && insert === '') { + insert = '+'; // keep every step a real edit + } + // Each edit records its own history entry: undoBoundary=true defeats + // typing/backspace coalescing so the undo stack holds all 50 steps. + d.applyEdits( + [ + { + range: { start: d.positionAt(from), end: d.positionAt(to) }, + newText: insert, + }, + ], + true, + undefined, + undefined, + true + ); + mirror = mirror.slice(0, from) + insert + mirror.slice(to); + expect(d.getText()).toBe(mirror); + } + + expect(d.version).toBe(50); + let undoCount = 0; + while (d.canUndo) { + d.undo(); + undoCount++; + } + expect(undoCount).toBe(50); + expect(d.getText()).toBe(original); + expect(d.version).toBe(0); + + let redoCount = 0; + while (d.canRedo) { + d.redo(); + redoCount++; + } + expect(redoCount).toBe(50); + expect(d.getText()).toBe(mirror); + expect(d.version).toBe(50); + } + }); +}); + +describe('applyEdits batch: changed line ranges across line-count changes', () => { + test('a line-adding first edit shifts the second edit into post-edit line numbers', () => { + // Edit 1 splits line 0 in two (+1 line); edit 2 rewrites old line 3, which + // is line 4 after the split. The reported ranges must be ascending and in + // post-edit coordinates, one range per disjoint edit. + const d = doc('ada\nbabbage\ncurie\ndarwin'); + const change = d.applyEdits([ + edit(0, 3, 0, 3, '\nhopper'), + edit(3, 0, 3, 6, 'lovelace'), + ]); + expect(d.getText()).toBe('ada\nhopper\nbabbage\ncurie\nlovelace'); + expect(change).toEqual({ + startLine: 0, + startCharacter: 3, + endCharacter: 6, + endLine: 4, + endedAtDocumentEnd: true, + previousLineCount: 4, + lineCount: 5, + lineDelta: 1, + changedLineRanges: [ + [0, 1], + [4, 4], + ], + changedLineChanges: [ + [0, 1, 1, 3, 3, false], + [4, 4, 0, 0, 6, true], + ], + }); + }); + + test('a line-removing first edit shifts the second edit down in post-edit line numbers', () => { + // Edit 1 joins lines 0 and 1 (-1 line); edit 2 rewrites old line 3, which + // is line 2 after the join. + const d = doc('ada\nbabbage\ncurie\ndarwin'); + const change = d.applyEdits([ + edit(0, 3, 1, 0, ' '), + edit(3, 0, 3, 6, 'lovelace'), + ]); + expect(d.getText()).toBe('ada babbage\ncurie\nlovelace'); + expect(change).toEqual({ + startLine: 0, + startCharacter: 3, + endCharacter: 6, + endLine: 2, + endedAtDocumentEnd: true, + previousLineCount: 4, + lineCount: 3, + lineDelta: -1, + changedLineRanges: [ + [0, 0], + [2, 2], + ], + changedLineChanges: [ + [0, 0, -1, 3, 0, false], + [2, 2, 0, 0, 6, true], + ], + }); + }); +}); + +describe('applyEdits: no-op edits recorded in history', () => { + test('a zero-width empty edit bumps the version and its history entry undoes/redoes harmlessly', () => { + const d = doc('anchor'); + const change = d.applyEdits([edit(0, 3, 0, 3, '')], true, [caret(0, 3)]); + + // The degenerate edit still produces a change record and a version bump, + // but the buffer is untouched. + expect(change?.lineDelta).toBe(0); + expect(d.getText()).toBe('anchor'); + expect(d.version).toBe(1); + expect(d.canUndo).toBe(true); + + // Undoing the identity entry is a real history step that changes nothing. + const undone = d.undo(); + expect(undone).toBeDefined(); + expect(d.getText()).toBe('anchor'); + expect(d.version).toBe(0); + expect(d.canRedo).toBe(true); + + const redone = d.redo(); + expect(redone).toBeDefined(); + expect(d.getText()).toBe('anchor'); + expect(d.version).toBe(1); + expect(d.canUndo).toBe(true); + expect(d.canRedo).toBe(false); + }); + + test('a no-op entry does not coalesce with real typing on either side', () => { + const d = doc(''); + d.applyEdits([edit(0, 0, 0, 0, 'a')], true, [caret(0, 0)]); + d.applyEdits([edit(0, 1, 0, 1, '')], true, [caret(0, 1)]); // no-op + d.applyEdits([edit(0, 1, 0, 1, 'b')], true, [caret(0, 1)]); + expect(d.getText()).toBe('ab'); + + // Three separate undo steps: the identity entry neither merges into the + // preceding keystroke nor lets the following keystroke merge across it. + d.undo(); + expect(d.getText()).toBe('a'); + d.undo(); + expect(d.getText()).toBe('a'); + d.undo(); + expect(d.getText()).toBe(''); + expect(d.canUndo).toBe(false); + + d.redo(); + d.redo(); + d.redo(); + expect(d.getText()).toBe('ab'); + expect(d.canRedo).toBe(false); + }); +}); + +describe('applyEdits: out-of-bounds edit ranges clamp instead of throwing', () => { + // DIVERGENCE: a stricter contract would reject a change whose start is + // negative outright; pierre normalizes every edit position through + // normalizePosition, so a negative character (or negative line) clamps to + // the document start and the edit applies to the clamped range. + test('negative coordinates clamp to the document start', () => { + const d = doc('kelp'); + d.applyEdits([edit(0, -1, 0, 1, '')]); + expect(d.getText()).toBe('elp'); // delete resolved as [0,1) + + const d2 = doc('ab\ncd'); + d2.applyEdits([edit(-5, -5, -5, -5, '!')]); + expect(d2.getText()).toBe('!ab\ncd'); // insert resolved at offset 0 + }); + + // DIVERGENCE: a stricter contract would reject an end of 10 on a 4-char + // document; pierre clamps the end character to the line length, so the + // replacement absorbs exactly the real tail of the line. + test('an end character past the line length clamps to the line end', () => { + const d = doc('kelp'); + d.applyEdits([edit(0, 2, 0, 10, 'x')]); + expect(d.getText()).toBe('kex'); // range resolved as [2,4) + }); + + // DIVERGENCE: a stricter contract would throw for any position beyond the + // document; pierre clamps the line to the last line but keeps a character + // that is still in range on that line — an edit addressed to line 9 lands + // mid-way through the final line, not at the document end. + test('a line beyond EOF clamps to the last line, preserving an in-range character', () => { + const d = doc('ab\ncd'); + d.applyEdits([edit(9, 1, 9, 1, '!')]); + expect(d.getText()).toBe('ab\nc!d'); // resolved to (line 1, character 1) + + // Only when the character also overshoots does the edit land at doc end. + const d2 = doc('ab\ncd'); + d2.applyEdits([edit(9, 99, 9, 99, '!')]); + expect(d2.getText()).toBe('ab\ncd!'); + }); + + // DIVERGENCE: a stricter contract would throw; pierre clamps a range end + // whose line overshoots to (last line, in-range character), so the + // replacement runs from the start position to that clamped point rather + // than to EOF. + test('a range end on a line beyond EOF clamps into the last line', () => { + const d = doc('ab\ncd'); + d.applyEdits([edit(0, 1, 7, 7, 'Z')]); + // End (7,7) clamps to (line 1, character 2) — the document end — so the + // replacement covers [1, 5). + expect(d.getText()).toBe('aZ'); + }); +}); + +describe('line-based indent commands with selections sharing a line', () => { + // KNOWN BUG: the editor's indent dispatch concatenates each selection's + // resolveIndentEdits output with no shared-line dedupe, so two carets on one + // line emit two identical zero-length inserts at column 0. Both pass the + // overlap validation (equal start offsets never compare as overlapping) and + // the line is indented twice. + test.failing('two carets on one line indent that line exactly once', () => { + const d = doc('quartz vein'); + const next = dispatchLineIndent(d, [caret(0, 2), caret(0, 6)], 2, false); + expect(d.getText()).toBe(' quartz vein'); + expect(next).toEqual([caret(0, 4), caret(0, 8)]); + }); + + // KNOWN BUG: same missing dedupe with range selections — the line shared by + // both ranges receives one indent edit per selection and ends up indented + // twice while its neighbors indent once. + test.failing( + 'two ranges sharing a line indent every line exactly once', + () => { + const d = doc('ada\nberyl\ncobalt'); + dispatchLineIndent(d, [range(0, 1, 1, 2), range(1, 3, 2, 1)], 2, false); + expect(d.getText()).toBe(' ada\n beryl\n cobalt'); + } + ); + + // KNOWN BUG: the outdent variant of the same composition produces two + // identical single-character delete edits for the shared line; unlike the + // zero-length inserts these DO fail overlap validation, so the whole command + // throws 'Overlapping text edits are not supported' and nothing is applied. + test.failing( + 'two carets on one tab-indented line outdent it exactly once', + () => { + const d = doc('\tquartz vein'); + const next = dispatchLineIndent(d, [caret(0, 3), caret(0, 7)], 2, true); + expect(d.getText()).toBe('quartz vein'); + expect(next).toEqual([caret(0, 2), caret(0, 6)]); + } + ); +}); + +describe('indentLess on tab and mixed indentation', () => { + // Pierre-fe has a single tabSize knob that acts as both the tab's visual + // width and the indent unit, and its outdent removes raw characters (one + // whole tab, or up to tabSize leading spaces). An alternative model + // separates tab size (4) from indent unit (2) and rewrites the leading + // whitespace by column arithmetic. Under pierre-fe's own model each outdent + // below removes exactly one visual unit, so this is a coherent policy + // difference, not corruption — the residual whitespace is just never + // normalized to spaces. + + test('outdenting a tab-indented line removes the whole tab', () => { + // DIVERGENCE: the column-arithmetic model splits the tab ('\tone' -> + // ' one' with a 2-space unit under a 4-column tab); pierre-fe deletes the + // tab character itself, which at tabSize 2 is exactly one indent unit of + // visual width. + const d = doc('\tnode'); + const [edits, next] = resolveIndentEdits(d, caret(0, 5), 2, true); + expect(edits).toEqual([ + { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 1 }, + }, + newText: '', + }, + ]); + d.applyEdits(edits); + expect(d.getText()).toBe('node'); + expect(next).toEqual(caret(0, 4)); + }); + + test('outdenting space-then-tab indentation trims leading spaces only', () => { + // DIVERGENCE: the column-arithmetic model normalizes ' \tone' to + // ' one' (column count minus one unit, re-written as spaces); pierre-fe + // deletes tabSize leading space characters and leaves the denormalized + // ' \t' run in place. At tabSize 2 the visual width still drops by exactly + // one unit (4 -> 2 columns), so the indentation is not broken, merely + // unnormalized. + const d = doc(' \tnode'); + const [edits, next] = resolveIndentEdits(d, caret(0, 8), 2, true); + d.applyEdits(edits); + expect(d.getText()).toBe(' \tnode'); + expect(next).toEqual(caret(0, 6)); + }); + + test('outdenting spaces before a tab leaves the tab as the full indent', () => { + // DIVERGENCE: the column-arithmetic model rewrites ' \ttwo' to ' two'; + // pierre-fe removes the two spaces and keeps the tab ('\tpair'), which + // again renders at one indent unit under its own tabSize-2 metrics. + const d = doc(' \tpair'); + const [edits, next] = resolveIndentEdits(d, caret(0, 7), 2, true); + d.applyEdits(edits); + expect(d.getText()).toBe('\tpair'); + expect(next).toEqual(caret(0, 5)); + }); +}); + +describe('block indent over blank and whitespace-only lines', () => { + // KNOWN BUG: resolveIndentEdits emits an insert at column 0 of every line + // the selection touches, including empty and whitespace-only lines, so a + // block indent injects trailing whitespace on lines the user never typed + // on. The conventional behavior is to skip lines with no content. Outdent + // strips the injected unit back off, so a full indent/outdent round trip + // self-heals — the damage is bounded to the indented state. + test.failing( + 'block indent leaves blank and whitespace-only lines untouched', + () => { + const d = doc('alpha\n\n \nbeta'); + const [edits] = resolveIndentEdits( + d, + { + start: { line: 0, character: 0 }, + end: { line: 3, character: 4 }, + direction: DirectionForward, + }, + 2, + false + ); + d.applyEdits(edits); + expect(d.getText()).toBe(' alpha\n\n \n beta'); + } + ); +}); + +describe('move line commands with merged and same-line multi-cursor blocks', () => { + test('interleaved ranges and a caret merge into one block moving up', () => { + // The three selections touch lines 1-2, 3, and 3-4; the merged block is + // lines 1-4 and must rotate above line 0 as a unit, with every selection + // shifted up one line at its original columns. + const d = doc('red\ngreen\nblue\ncyan\npink'); + const next = moveLines( + d, + [range(1, 0, 2, 2), caret(3, 2), range(3, 3, 4, 4)], + -1 + ); + expect(d.getText()).toBe('green\nblue\ncyan\npink\nred'); + expect(next).toEqual([range(0, 0, 1, 2), caret(2, 2), range(2, 3, 3, 4)]); + }); + + test('interleaved ranges and a caret merge into one block moving down', () => { + const d = doc('red\ngreen\nblue\ncyan\npink\ngray'); + const next = moveLines( + d, + [range(1, 0, 2, 2), caret(3, 2), range(3, 3, 4, 4)], + 1 + ); + expect(d.getText()).toBe('red\ngray\ngreen\nblue\ncyan\npink'); + expect(next).toEqual([range(2, 0, 3, 2), caret(4, 2), range(4, 3, 5, 4)]); + }); + + test('multiple carets on one line all survive a move down at their columns', () => { + const d = doc('alpha\nbravo\ncharlie'); + const next = moveLines(d, [caret(1, 1), caret(1, 4)], 1); + expect(d.getText()).toBe('alpha\ncharlie\nbravo'); + expect(next).toEqual([caret(2, 1), caret(2, 4)]); + }); + + test('multiple carets on one line all survive a move up at their columns', () => { + const d = doc('alpha\nbravo\n'); + const next = moveLines(d, [caret(1, 1), caret(1, 3), caret(1, 5)], -1); + expect(d.getText()).toBe('bravo\nalpha\n'); + expect(next).toEqual([caret(0, 1), caret(0, 3), caret(0, 5)]); + }); + + test('a range ending at column 0 does not drag the line below into the move', () => { + // The selection ends at (3,0), so line 3 carries no selected content; + // getSelectedLineBlocks must exclude it and only lines 1-2 move. + const d = doc('ash\nbay\ncedar\ndune'); + const next = moveLines(d, [range(1, 0, 3, 0)], -1); + expect(d.getText()).toBe('bay\ncedar\nash\ndune'); + expect(next).toEqual([range(0, 0, 2, 0)]); + }); +}); + +describe('Enter and indentation carry-over', () => { + test('Enter copies the current line leading whitespace onto the new line', () => { + const d = doc(' tune'); + const { nextSelections } = pressEnter(d, caret(0, 6)); + expect(d.getText()).toBe(' tune\n '); + expect(nextSelections).toEqual([caret(1, 2)]); + }); + + test('Enter on a whitespace-only line duplicates that whitespace', () => { + // DIVERGENCE: a language-aware newline-and-indent command replaces a + // whitespace-only line with '\n', clearing the stale indentation. + // Pierre-fe's Enter is keep-indent semantics: expandSingleNewlineInsert + // copies the current line's leading whitespace unconditionally, so + // ' ' + Enter leaves ' \n ' — trailing whitespace stays behind + // and the indent is duplicated. Judged a policy, not a bug: nothing is + // lost or corrupted, the result is deterministic and undoable, and + // clearing would require a language-aware indent pass pierre-fe + // deliberately does not run. + const d = doc(' '); + const { nextSelections } = pressEnter(d, caret(0, 4)); + expect(d.getText()).toBe(' \n '); + expect(nextSelections).toEqual([caret(1, 4)]); + }); + + test('Enter replacing a multi-line selection indents from the selection-start line', () => { + // The replaced range spans two indented lines; the inserted break must + // copy the indentation of the line the selection STARTS on, and the caret + // lands between that indent and the surviving tail text. + const d = doc('fn a:\n leftgone\n deadright'); + const { nextSelections } = pressEnter(d, range(1, 6, 2, 6)); + expect(d.getText()).toBe('fn a:\n left\n right'); + expect(nextSelections).toEqual([caret(2, 2)]); + }); + + test('Enter after an unindented line inserts a bare break', () => { + const d = doc('onemore'); + const { nextSelections } = pressEnter(d, caret(0, 3)); + expect(d.getText()).toBe('one\nmore'); + expect(nextSelections).toEqual([caret(1, 0)]); + }); +}); diff --git a/packages/diffs/test/editorEditStack.test.ts b/packages/diffs/test/editorEditStack.test.ts index 955779f5a..08b583f40 100644 --- a/packages/diffs/test/editorEditStack.test.ts +++ b/packages/diffs/test/editorEditStack.test.ts @@ -7,7 +7,11 @@ import { } from '../src/editor/editStack'; import { DirectionNone } from '../src/editor/selection'; import { TextDocument } from '../src/editor/textDocument'; -import type { EditorSelection, SelectionDirection } from '../src/types'; +import type { + EditorSelection, + SelectionDirection, + TextEdit, +} from '../src/types'; function createSelection( startLine: number, @@ -247,3 +251,1238 @@ describe('shouldCoalesceEditStackEntry', () => { expect(shouldCoalesceEditStackEntry(previous, next)).toBe(true); }); }); + +// --- Shared helpers for the undo/redo coalescing and history scenarios below --- + +function doc(text: string, editStack?: EditStack) { + return new TextDocument('inmemory://1', text, 'plain', 0, editStack); +} + +function caretAt(line: number, character: number) { + const position = { line, character }; + return { + start: position, + end: position, + direction: DirectionNone, + } satisfies EditorSelection; +} + +// Inserts single-line `text` at the caret with history recording enabled, like +// one keystroke (or a paste when `undoBoundary` is set). Records both the +// pre-keystroke caret and the caret sitting after the inserted text, the way +// the editor does for typing. +function typeAt( + d: ReturnType, + line: number, + character: number, + text: string, + undoBoundary = false +) { + d.applyEdits( + [ + { + range: { + start: { line, character }, + end: { line, character }, + }, + newText: text, + }, + ], + true, + [caretAt(line, character)], + [caretAt(line, character + text.length)], + undoBoundary + ); +} + +// Deletes the character before the caret, like a single Backspace keystroke. +// The caret sits at the right edge of the deleted range. +function backspaceAt( + d: ReturnType, + line: number, + caretChar: number +) { + d.applyEdits( + [ + { + range: { + start: { line, character: caretChar - 1 }, + end: { line, character: caretChar }, + }, + newText: '', + }, + ], + true, + [caretAt(line, caretChar)] + ); +} + +// Deletes the character after the caret, like a single forward Delete +// keystroke. The caret sits at the left edge of the deleted range. +function forwardDeleteAt( + d: ReturnType, + line: number, + caretChar: number +) { + d.applyEdits( + [ + { + range: { + start: { line, character: caretChar }, + end: { line, character: caretChar + 1 }, + }, + newText: '', + }, + ], + true, + [caretAt(line, caretChar)] + ); +} + +// Runs undo (or redo) to exhaustion and returns how many steps it took, so +// traversal tests can assert the step count stays stable across cycles. +function undoAll(d: ReturnType) { + let steps = 0; + while (d.canUndo) { + d.undo(); + steps++; + } + return steps; +} + +function redoAll(d: ReturnType) { + let steps = 0; + while (d.canRedo) { + d.redo(); + steps++; + } + return steps; +} + +// Undo/redo coalescing scenarios. The three test.failing entries here are +// known bugs: coalescing is decided purely by comparing edit geometry against +// whatever entry sits on top of the undo stack, with no state reset after +// undo()/redo() and no sticky typing-mode tracking. +describe('EditStack coalescing across undo and redo', () => { + // KNOWN BUG: after undo() pops the top entry, new typing that happens to sit + // adjacent to the newly exposed entry coalesces into it, so one undo wipes out + // committed pre-undo history along with the fresh keystroke. + test.failing( + 'typing after an undo never merges into pre-undo history', + () => { + const d = doc('hello\nworld'); + typeAt(d, 0, 0, 'a'); // entry 1: "ahello\nworld" + typeAt(d, 1, 0, 'Z'); // entry 2 (different line, no coalesce): "ahello\nZworld" + d.undo(); // pops entry 2, entry 1 is now on top + expect(d.getText()).toBe('ahello\nworld'); + + typeAt(d, 0, 1, 'b'); // brand-new keystroke, adjacent to entry 1's insert + expect(d.getText()).toBe('abhello\nworld'); + + // One undo must remove only the new 'b', not entry 1's 'a' with it. + d.undo(); + expect(d.getText()).toBe('ahello\nworld'); + + // Redo direction: the 'b' keystroke comes back on its own. + d.redo(); + expect(d.getText()).toBe('abhello\nworld'); + + // The full history unwinds one keystroke at a time. + d.undo(); + d.undo(); + expect(d.getText()).toBe('hello\nworld'); + expect(d.canUndo).toBe(false); + } + ); + + // KNOWN BUG: an undoBoundary entry blocks merging only while it sits on the + // undo stack; once it is undone, the entry beneath it is exposed and new + // typing merges straight through into it as if the boundary never existed. + test.failing( + 'an undone boundary entry still shields the entry beneath it from coalescing', + () => { + const d = doc('hello'); + typeAt(d, 0, 0, 'a'); // entry 1: "ahello" + typeAt(d, 0, 1, 'XYZ', true); // paste with boundary: "aXYZhello" + d.undo(); // pops the paste, entry 1 is on top again + expect(d.getText()).toBe('ahello'); + + typeAt(d, 0, 1, 'b'); // ordinary keystroke adjacent to entry 1's insert + expect(d.getText()).toBe('abhello'); + + // One undo must remove only 'b'; 'a' predates the paste boundary. + d.undo(); + expect(d.getText()).toBe('ahello'); + + // Redo direction: only the 'b' keystroke replays. + d.redo(); + expect(d.getText()).toBe('abhello'); + + d.undo(); + d.undo(); + expect(d.getText()).toBe('hello'); + expect(d.canUndo).toBe(false); + } + ); + + // KNOWN BUG: a Backspace followed by a forward Delete at the same pivot + // coalesces into one undo step; the pivot offset maps ambiguously onto the end + // of the just-deleted range, so the pair passes the 'delete'-mode check. + test.failing( + 'switching from backspace to forward delete creates a new undo stop', + () => { + const d = doc('abc'); + backspaceAt(d, 0, 2); // removes 'b' -> "ac", caret lands at (0,1) + forwardDeleteAt(d, 0, 1); // removes 'c' -> "a" + expect(d.getText()).toBe('a'); + + // First undo restores only the forward-deleted character. + d.undo(); + expect(d.getText()).toBe('ac'); + + // Second undo restores the backspaced character. + d.undo(); + expect(d.getText()).toBe('abc'); + expect(d.canUndo).toBe(false); + + // Redo direction: the two deletes replay as separate steps. + d.redo(); + expect(d.getText()).toBe('ac'); + d.redo(); + expect(d.getText()).toBe('a'); + expect(d.canRedo).toBe(false); + } + ); + + // DIVERGENCE: the conventional behavior breaks typed runs at whitespace (a + // lone space merges with the word before it, but typing after the space + // starts a new undo stop), so typing "ab cd" yields 2 undo steps + // ("ab cd" -> "ab" -> ""). Pierre's coalescing is purely adjacency-based + // with no character-content awareness, so the whole typed sentence + // collapses into a single undo step. Pinned as a design choice; the + // conventional space-boundary rule is the alternative if product ever + // wants word-granular undo. + test('a typed sentence with a single space coalesces into one undo step', () => { + const d = doc(''); + typeAt(d, 0, 0, 'a'); + typeAt(d, 0, 1, 'b'); + typeAt(d, 0, 2, ' '); + typeAt(d, 0, 3, 'c'); + typeAt(d, 0, 4, 'd'); + expect(d.getText()).toBe('ab cd'); + + // One undo clears the entire sentence, space included. + d.undo(); + expect(d.getText()).toBe(''); + expect(d.canUndo).toBe(false); + + // Redo direction: one redo restores the entire sentence. + d.redo(); + expect(d.getText()).toBe('ab cd'); + expect(d.canRedo).toBe(false); + }); + + // DIVERGENCE: the conventional behavior isolates a run of two-or-more + // consecutive spaces into its own undo step, so typing "ab cd" yields 3 + // undo steps ("ab cd" -> "ab " -> "ab" -> ""). Pierre coalesces the + // entire run of keystrokes, spaces and all, into one undo step. Pinned as + // the same adjacency-only design choice as the single-space case above. + test('consecutive typed spaces coalesce into the surrounding typing', () => { + const d = doc(''); + typeAt(d, 0, 0, 'a'); + typeAt(d, 0, 1, 'b'); + typeAt(d, 0, 2, ' '); + typeAt(d, 0, 3, ' '); + typeAt(d, 0, 4, 'c'); + typeAt(d, 0, 5, 'd'); + expect(d.getText()).toBe('ab cd'); + + d.undo(); + expect(d.getText()).toBe(''); + expect(d.canUndo).toBe(false); + + d.redo(); + expect(d.getText()).toBe('ab cd'); + expect(d.canRedo).toBe(false); + }); +}); + +// Undo/redo selection restore and history traversal scenarios. Selections +// restored by undo()/redo() are the ones recorded in the edit stack entry at +// edit time: undo() returns the entry's selectionsBefore and redo() its +// selectionsAfter as the second tuple element. Selection-only movement +// between edits never reaches TextDocument (the Editor only calls +// setLastUndoSelectionsAfter when it applies edits), so a caret that wanders +// off after an edit cannot overwrite the recorded restore points — but it +// also cannot break a coalescing group, which is the one pinned divergence +// below. +describe('TextDocument history selection restore', () => { + // After the edit, the user moves the caret to the end of the document. That + // is a selection-only step that never reaches TextDocument, so redo must + // hand back the caret recorded when the edit was made, not the undo-time one. + test('redo restores the caret recorded at edit time, not the undo-time caret', () => { + const d = doc('red\n\nblue'); + d.applyEdits( + [ + { + range: { + start: { line: 0, character: 3 }, + end: { line: 0, character: 3 }, + }, + newText: '!', + }, + ], + true, + [caretAt(0, 3)], + [caretAt(0, 4)] + ); + expect(d.getText()).toBe('red!\n\nblue'); + // (caret wanders to the end of the document — invisible to the document) + + const undoResult = d.undo(); + expect(d.getText()).toBe('red\n\nblue'); + expect(undoResult?.[1]).toEqual([caretAt(0, 3)]); + + const redoResult = d.redo(); + expect(d.getText()).toBe('red!\n\nblue'); + expect(redoResult?.[1]).toEqual([caretAt(0, 4)]); + }); + + // Before every traversal step the caret jumps to the top of the document + // (selection-only, unrecorded). Each undo/redo must keep returning the + // selections stored in the entry, unchanged by the traversal itself. + test('undo-redo-undo keeps returning the recorded before/after selections', () => { + const d = doc('ash\noak\nelm'); + d.applyEdits( + [ + { + range: { + start: { line: 1, character: 3 }, + end: { line: 1, character: 3 }, + }, + newText: '.', + }, + ], + true, + [caretAt(1, 3)], + [caretAt(1, 4)] + ); + expect(d.getText()).toBe('ash\noak.\nelm'); + + // (caret jumps to (0,0) before every step — invisible to the document) + expect(d.undo()?.[1]).toEqual([caretAt(1, 3)]); + expect(d.getText()).toBe('ash\noak\nelm'); + + expect(d.redo()?.[1]).toEqual([caretAt(1, 4)]); + expect(d.getText()).toBe('ash\noak.\nelm'); + + expect(d.undo()?.[1]).toEqual([caretAt(1, 3)]); + expect(d.getText()).toBe('ash\noak\nelm'); + + // A second full cycle still reads the same stored selections: traversal + // moves entries between stacks without mutating them. + expect(d.redo()?.[1]).toEqual([caretAt(1, 4)]); + expect(d.undo()?.[1]).toEqual([caretAt(1, 3)]); + }); + + // Three keystrokes coalesce into one undo entry; the merged entry must keep + // the selectionsBefore of the FIRST keystroke (keeping an intermediate + // keystroke's caret instead is a known regression class). + test('undoing a coalesced typing run restores the caret from before the first keystroke', () => { + const d = doc(''); + typeAt(d, 0, 0, 'q'); + typeAt(d, 0, 1, 'r'); + typeAt(d, 0, 2, 's'); + expect(d.getText()).toBe('qrs'); + + const undoResult = d.undo(); + expect(d.getText()).toBe(''); + // The whole run was a single entry... + expect(d.canUndo).toBe(false); + // ...and it restores the pre-first-keystroke caret, not (0,1) or (0,2). + expect(undoResult?.[1]).toEqual([caretAt(0, 0)]); + + // The redo side of the merged entry keeps the LAST keystroke's after-caret. + const redoResult = d.redo(); + expect(d.getText()).toBe('qrs'); + expect(redoResult?.[1]).toEqual([caretAt(0, 3)]); + }); + + // DIVERGENCE: the conventional behavior tracks selection-only transactions + // and starts a new undo group whenever one lands between two document + // changes, so this flow yields two undo steps there. Pierre's coalescing is + // purely geometric over edit offsets (shouldCoalesceEditStackEntry) and + // TextDocument has no channel for selection-only transactions, so a caret + // that moves away and comes back between keystrokes leaves no trace and the + // run still merges. The coalescing tests above ("EditStack coalescing + // across undo and redo") pin the related content-based whitespace-grouping + // divergence; this pins the missing selection-movement heuristic. + test('a caret round-trip between two keystrokes does not break the undo group', () => { + const d = doc(''); + typeAt(d, 0, 0, 'a'); + // (caret moves to (0,0), then back to (0,1) — selection-only, unrecorded) + typeAt(d, 0, 1, 'b'); + expect(d.getText()).toBe('ab'); + + const undoResult = d.undo(); + // One undo removes both characters: the round trip did not split the group. + expect(d.getText()).toBe(''); + expect(d.canUndo).toBe(false); + expect(undoResult?.[1]).toEqual([caretAt(0, 0)]); + }); + + // The reference fixture's actual edit sequence — type, select the typed + // word, replace the selection — lands on the same two-step outcome here, + // but via edit geometry (a ranged replacement never merges into a typing + // run), not via a selection-change heuristic. + test('replacing a selection right after typing starts a new undo step', () => { + const d = doc(''); + typeAt(d, 0, 0, 'h'); + typeAt(d, 0, 1, 'i'); + d.applyEdits( + [ + { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 2 }, + }, + newText: 'howdy', + }, + ], + true, + [ + { + start: { line: 0, character: 0 }, + end: { line: 0, character: 2 }, + direction: DirectionNone, + }, + ], + [caretAt(0, 5)] + ); + expect(d.getText()).toBe('howdy'); + + d.undo(); + expect(d.getText()).toBe('hi'); + d.undo(); + expect(d.getText()).toBe(''); + expect(d.canUndo).toBe(false); + + d.redo(); + expect(d.getText()).toBe('hi'); + d.redo(); + expect(d.getText()).toBe('howdy'); + expect(d.canRedo).toBe(false); + }); + + // Mixed history: a coalesced typing run, a paste guarded by an undoBoundary, + // a multi-line insert (never merges into typing), and a trailing keystroke. + // Four full undo-to-exhaustion / redo-to-exhaustion cycles must be + // idempotent: identical text and version at both extremes, stable step + // counts, and stable canUndo/canRedo flags — traversal must not mutate stack + // entries or drift the document version. + test('full undo/redo traversal over mixed history is idempotent across repeated cycles', () => { + const d = doc(''); + // Typing run: "log jam", one keystroke at a time (coalesces). + typeAt(d, 0, 0, 'l'); + typeAt(d, 0, 1, 'o'); + typeAt(d, 0, 2, 'g'); + typeAt(d, 0, 3, ' '); + typeAt(d, 0, 4, 'j'); + typeAt(d, 0, 5, 'a'); + typeAt(d, 0, 6, 'm'); + // Paste at the start of the line (undo boundary, its own step). + typeAt(d, 0, 0, 'pine ', true); + // Multi-line insert at the end of the line (its own step). + d.applyEdits( + [ + { + range: { + start: { line: 0, character: 12 }, + end: { line: 0, character: 12 }, + }, + newText: '\nfern\nmoss', + }, + ], + true, + [caretAt(0, 12)], + [caretAt(2, 4)] + ); + // One more keystroke after the multi-line insert (its own step). + typeAt(d, 2, 4, '!'); + + const fullText = d.getText(); + expect(fullText).toBe('pine log jam\nfern\nmoss!'); + const fullVersion = d.version; + + const firstUndoSteps = undoAll(d); + expect(d.getText()).toBe(''); + const emptyVersion = d.version; + const firstRedoSteps = redoAll(d); + expect(d.getText()).toBe(fullText); + expect(firstUndoSteps).toBe(firstRedoSteps); + + for (let cycle = 0; cycle < 4; cycle++) { + expect(undoAll(d)).toBe(firstUndoSteps); + expect(d.getText()).toBe(''); + expect(d.version).toBe(emptyVersion); + expect(d.canUndo).toBe(false); + expect(d.canRedo).toBe(true); + + expect(redoAll(d)).toBe(firstRedoSteps); + expect(d.getText()).toBe(fullText); + expect(d.version).toBe(fullVersion); + expect(d.canUndo).toBe(true); + expect(d.canRedo).toBe(false); + } + }); + + // Three carets insert one character each in a single batch. Between the undo + // and the redo the selection collapses to a single caret at the top of the + // document (selection-only, unrecorded); redo must still restore all three + // recorded after-carets, and undo all three before-carets. + test('multi-cursor batch restores every caret on undo and redo', () => { + const d = doc('ox\nelk\nbee\n'); + const selectionsBefore = [caretAt(0, 2), caretAt(1, 3), caretAt(2, 3)]; + const selectionsAfter = [caretAt(0, 3), caretAt(1, 4), caretAt(2, 4)]; + d.applyEdits( + [ + { + range: { + start: { line: 0, character: 2 }, + end: { line: 0, character: 2 }, + }, + newText: '*', + }, + { + range: { + start: { line: 1, character: 3 }, + end: { line: 1, character: 3 }, + }, + newText: '*', + }, + { + range: { + start: { line: 2, character: 3 }, + end: { line: 2, character: 3 }, + }, + newText: '*', + }, + ], + true, + selectionsBefore, + selectionsAfter + ); + expect(d.getText()).toBe('ox*\nelk*\nbee*\n'); + + // (selection collapses to a caret at (0,0) — invisible to the document) + const undoResult = d.undo(); + expect(d.getText()).toBe('ox\nelk\nbee\n'); + expect(undoResult?.[1]).toEqual(selectionsBefore); + + // (selection collapses again before redo) + const redoResult = d.redo(); + expect(d.getText()).toBe('ox*\nelk*\nbee*\n'); + expect(redoResult?.[1]).toEqual(selectionsAfter); + }); +}); + +// Every fixture in the non-history-edit scenarios below is single-line, so a +// character index on line 0 is also the flat document offset. +function lineEdit( + startCharacter: number, + endCharacter: number, + newText: string +): TextEdit { + return { + range: { + start: { line: 0, character: startCharacter }, + end: { line: 0, character: endCharacter }, + }, + newText, + }; +} + +// A history-recorded local edit, like typing or a command. The caret defaults +// to the edit start. +function localEdit( + d: ReturnType, + startCharacter: number, + endCharacter: number, + newText: string, + selectionsBefore?: EditorSelection[] +) { + d.applyEdits( + [lineEdit(startCharacter, endCharacter, newText)], + true, + selectionsBefore ?? [caretAt(0, startCharacter)] + ); +} + +// A non-history edit: updateHistory=false, exactly what Editor.applyEdits +// defaults to for programmatic/remote changes. +function remoteEdit( + d: ReturnType, + startCharacter: number, + endCharacter: number, + newText: string +) { + d.applyEdits([lineEdit(startCharacter, endCharacter, newText)]); +} + +// Undo/redo interacting with non-history edits (updateHistory=false). +// +// DESIGN MODEL (equivalence): applying an edit with updateHistory=false is an +// implementation detail of how the edit reaches applyEdits (the default for +// Editor.applyEdits — collaborative patches, programmatic fixes, etc.), not a +// separate semantic class of edit. A mixed tracked/untracked sequence must +// leave the document and its history in a state equivalent to the same +// sequence applied all-tracked. The observable contract — chosen because it +// sidesteps per-step grouping ambiguity — is exhaustion: after any edit +// sequence, undo-to-exhaustion restores the ORIGINAL byte-exact text and +// redo-to-exhaustion restores the FINAL text, no matter which edits skipped +// history tracking. Where a per-step value is asserted, it is the value the +// all-tracked reference run of the same script produces (probed with +// undoBoundary=true per edit to defeat coalescing noise); the literals below +// are embedded from those reference runs. The previously pinned rebasing +// model — untracked edits survive undo while frozen history entries remap +// around them — is rejected. +// +// KNOWN BUG shared by every test.failing below: untracked edits bypass the +// edit stack entirely and existing entries are never reconciled with them, so +// traversal strands or erases untracked text and applies inverse/forward +// edits at stale offsets, corrupting the buffer instead of reproducing the +// all-tracked timeline. Two passing tests in this describe pin today's +// stopgap behavior in the geometries where it happens not to corrupt (the +// after-the-tracked-range baseline and the stack-liveness test); they predate +// the equivalence model and will need flipping when it lands. +describe('undo/redo across non-history edits', () => { + // TODAY'S BEHAVIOR, not an equivalence pin: an untracked edit strictly + // AFTER the tracked range leaves the entry's stored offsets valid, so the + // single undo happens to restore exactly the tracked change without + // corrupting anything. Under the equivalence model even this geometry + // changes — the all-tracked reference unwinds the suffix first + // ("sour lemon", then "lemon") — so this pin documents the one arrangement + // where today's frozen entries stay coherent, and will need flipping when + // equivalence lands. + test('undo works when the non-history edit sits after the tracked range', () => { + const d = doc('lemon'); + localEdit(d, 0, 0, 'sour '); // tracked: "sour lemon" + remoteEdit(d, 10, 10, ' tart'); // remote suffix: "sour lemon tart" + d.undo(); + expect(d.getText()).toBe('lemon tart'); + expect(d.canUndo).toBe(false); + expect(d.canRedo).toBe(true); + d.redo(); + expect(d.getText()).toBe('sour lemon tart'); + }); + + // KNOWN BUG: the equivalence expectation is that the two untracked inserts + // are part of the timeline, so exhaustion reproduces the all-tracked + // reference ("syncpilot?" -> "syncpilot" -> "pilot" -> "" and back). + // Actual today: the untracked inserts never enter history and the lone + // tracked entry's inverse applies at its stale offsets, deleting the + // untracked prefix plus part of the shifted typed text — undo-to-exhaustion + // yields "ilot?" and redo-to-exhaustion compounds it to "pilotilot?". + test.failing( + 'a mixed tracked/untracked insert sequence unwinds and replays like the all-tracked timeline', + () => { + const d = doc(''); + localEdit(d, 0, 0, 'pilot'); // tracked typing + remoteEdit(d, 0, 0, 'sync'); // untracked prefix: "syncpilot" + remoteEdit(d, 9, 9, '?'); // untracked suffix: "syncpilot?" + expect(d.getText()).toBe('syncpilot?'); + + // Undo-to-exhaustion restores the original byte-exact text... + undoAll(d); + expect(d.getText()).toBe(''); + + // ...and redo-to-exhaustion restores the final text. + redoAll(d); + expect(d.getText()).toBe('syncpilot?'); + } + ); + + // KNOWN BUG: the equivalence expectation is that the untracked insert is + // one more logical step, so exhaustion reproduces the all-tracked reference + // ("UV####WXYZ" -> "UVWXYZ" -> "pqr" and back). Actual today: the batch + // entry's chained inverse offsets ignore the untracked insert that landed + // between its sub-edits, so undo treats the untracked text as if it were + // the tracked replacements — undo-to-exhaustion yields "pqrWXYZ" and + // redo-to-exhaustion "UVWXYZWXYZ". + test.failing( + 'a replacement batch with an interleaved untracked insert unwinds and replays like the all-tracked timeline', + () => { + const d = doc('pqr'); + // One tracked batch of three adjacent replacements. + d.applyEdits( + [lineEdit(0, 1, 'UV'), lineEdit(1, 2, 'WX'), lineEdit(2, 3, 'YZ')], + true, + [caretAt(0, 3)] + ); + expect(d.getText()).toBe('UVWXYZ'); + + // Untracked insert exactly between the first and second replacements. + remoteEdit(d, 2, 2, '####'); + expect(d.getText()).toBe('UV####WXYZ'); + + // Undo-to-exhaustion restores the original byte-exact text — the + // untracked insert is unwound too, exactly as if it had been tracked. + undoAll(d); + expect(d.getText()).toBe('pqr'); + + // Redo-to-exhaustion restores the final text. + redoAll(d); + expect(d.getText()).toBe('UV####WXYZ'); + } + ); + + // KNOWN BUG: under the equivalence model the untracked interior insert + // does NOT survive undo — it is one of the undo steps like any other edit, + // and exhaustion reproduces the all-tracked reference ("WXjYZ" -> "WXYZ" -> + // "" and back). Actual today: the tracked insertion's stale inverse [0,4) + // is deleted from "WXjYZ" wholesale, erasing the untracked "j" and + // stranding a tracked "Z" — undo-to-exhaustion yields "Z" and + // redo-to-exhaustion "WXYZZ". + test.failing( + 'an untracked insert inside a tracked insertion unwinds and replays like the all-tracked timeline', + () => { + const d = doc(''); + localEdit(d, 0, 0, 'WXYZ'); // tracked insertion + remoteEdit(d, 2, 2, 'j'); // untracked insert in the middle of it + expect(d.getText()).toBe('WXjYZ'); + + undoAll(d); + expect(d.getText()).toBe(''); + + redoAll(d); + expect(d.getText()).toBe('WXjYZ'); + } + ); + + // KNOWN BUG: an untracked replace that wipes the region a tracked insertion + // lives in must read as one more logical step: the all-tracked reference + // unwinds "core" -> "oGHk" -> "ok" and replays "oGHk" -> "core", and no + // state along the way mixes the two texts. Actual today: the frozen entry + // points at text that no longer exists, so undo deletes unrelated + // characters ("core" -> "ce") and redo splices the tracked insert into the + // replacement text ("cGHe") — a corruption artifact that never existed on + // any timeline. + test.failing( + 'an untracked whole-document replace over a tracked insertion unwinds and replays like the all-tracked timeline', + () => { + const d = doc('ok'); + localEdit(d, 1, 1, 'GH'); // tracked insert: "oGHk" + remoteEdit(d, 0, 4, 'core'); // untracked replace of the whole doc + expect(d.getText()).toBe('core'); + + const visited: string[] = []; + while (d.canUndo) { + d.undo(); + visited.push(d.getText()); + } + expect(d.getText()).toBe('ok'); + + while (d.canRedo) { + d.redo(); + visited.push(d.getText()); + } + expect(d.getText()).toBe('core'); + + // Every state the traversal visits must be one the all-tracked + // timeline visits — no mixtures like "cGHe". + for (const state of visited) { + expect(['ok', 'oGHk', 'core']).toContain(state); + } + } + ); + + // KNOWN BUG: under the equivalence model stored selections need no + // remapping at all — by the time the tracked delete's entry is undone, the + // untracked insert has itself been unwound, so the document is back in the + // exact coordinate space the selections were recorded in and they restore + // verbatim. The all-tracked reference unwinds " worXYld" -> " world" -> + // "hello world" with the final undo returning carets [6, 11] unchanged. + // Actual today: the selections half already matches (they come back + // verbatim), but the text half fails — the untracked insert is never + // unwound, so the single undo reinserts "hello" at a stale offset and + // exhaustion ends at "hello worXYld" instead of "hello world". + test.failing( + 'undo exhaustion across an untracked insert restores the original text and the verbatim recorded selections', + () => { + const d = doc('hello world'); + // Tracked delete of the leading word, with two carets recorded. + d.applyEdits([lineEdit(0, 5, '')], true, [caretAt(0, 6), caretAt(0, 11)]); + expect(d.getText()).toBe(' world'); + + // Untracked insert; in the original coordinates this lands at offset 9, + // between the two stored carets. + remoteEdit(d, 4, 4, 'XY'); + expect(d.getText()).toBe(' worXYld'); + + let lastUndo: ReturnType; + while (d.canUndo) { + lastUndo = d.undo(); + } + expect(d.getText()).toBe('hello world'); + // Mirrors the all-tracked reference: the final undo hands back the + // entry's stored selectionsBefore untouched. + expect(lastUndo?.[1]?.map((s) => s.start.character)).toEqual([6, 11]); + + redoAll(d); + expect(d.getText()).toBe(' worXYld'); + } + ); + + // KNOWN BUG (was a passing pin under the old rebasing model, where undo + // "keeping the untracked text" looked correct by geometric accident): + // under the equivalence model the untracked "b" is part of the timeline + // like any other edit, so undo-to-exhaustion restores the original (empty) + // text — not an untracked remainder. The all-tracked run of this script + // unwinds "acb" -> "ab" -> "" and replays "" -> "ab" -> "acb" (the "b" + // coalesces into the "a" keystroke; "c" lands inside the merged insert and + // starts a new step); however the mixed run groups its steps, exhaustion + // must hit the same endpoints. Actual today: the two tracked keystrokes + // coalesce into one entry whose single undo leaves exactly "b", and + // canUndo goes false with the untracked text stranded as if it were + // original. + test.failing( + 'typing around an untracked insert unwinds to the original text, not to an untracked remainder', + () => { + const d = doc(''); + localEdit(d, 0, 0, 'a'); // tracked keystroke: "a" + remoteEdit(d, 1, 1, 'b'); // untracked: "ab" + localEdit(d, 1, 1, 'c'); // tracked keystroke right after the "a": "acb" + expect(d.getText()).toBe('acb'); + + // Undo-to-exhaustion restores the original byte-exact text... + undoAll(d); + expect(d.getText()).toBe(''); + + // ...and redo-to-exhaustion restores the final text. + redoAll(d); + expect(d.getText()).toBe('acb'); + } + ); + + // TODAY'S BEHAVIOR, not an equivalence pin: untracked edits currently touch + // neither stack, so undo entries survive and a pending redo is not cleared. + // Geometry keeps every untracked edit after the tracked offsets so the + // surviving entries also APPLY cleanly today. Under the equivalence model + // an untracked edit is a new edit like any other — it joins the undo + // timeline and clears a pending redo (the failing pin below) — so this pin + // documents the stopgap and will need flipping when equivalence lands. + test('a non-history edit neither consumes undo entries nor clears the redo stack', () => { + const d = doc('alpha'); + localEdit(d, 5, 5, '!'); // tracked: "alpha!" + expect(d.canUndo).toBe(true); + expect(d.canRedo).toBe(false); + + remoteEdit(d, 6, 6, ' beta'); // "alpha! beta" + expect(d.canUndo).toBe(true); + expect(d.canRedo).toBe(false); + + d.undo(); + expect(d.getText()).toBe('alpha beta'); + expect(d.canUndo).toBe(false); + expect(d.canRedo).toBe(true); + + // A non-history edit while a redo entry is pending keeps it alive. + remoteEdit(d, 10, 10, '?'); // "alpha beta?" + expect(d.canRedo).toBe(true); + + d.redo(); + expect(d.getText()).toBe('alpha! beta?'); + expect(d.canUndo).toBe(true); + expect(d.canRedo).toBe(false); + }); + + // KNOWN BUG: under the equivalence model the untracked ">> " insert is an + // ordinary new edit, and a new edit while a redo is pending clears the redo + // stack (the same rule tracked edits already follow). The all-tracked + // reference run: canRedo goes false after the ">> " edit, redo() is a + // no-op at ">> note", undo-to-exhaustion yields "note", redo-to-exhaustion + // ">> note". Actual today: the pending redo entry survives the untracked + // edit and replays its forward edit at a stale offset, landing the "!" + // mid-word (">> n!ote"), and undo-to-exhaustion from there strands the + // untracked prefix at ">> note" instead of returning to "note". + test.failing( + 'an untracked edit while a redo is pending behaves like a tracked edit and clears the redo', + () => { + const d = doc('note'); + localEdit(d, 4, 4, '!'); // tracked: "note!" + d.undo(); + expect(d.getText()).toBe('note'); + expect(d.canRedo).toBe(true); + + remoteEdit(d, 0, 0, '>> '); // untracked prefix while redo is pending + expect(d.getText()).toBe('>> note'); + + // Mirrors the all-tracked reference: pushing a new edit clears redo, + // so the pending "!" never replays anywhere. + expect(d.canRedo).toBe(false); + d.redo(); + expect(d.getText()).toBe('>> note'); + + // Exhaustion in both directions matches the reference timeline. + undoAll(d); + expect(d.getText()).toBe('note'); + redoAll(d); + expect(d.getText()).toBe('>> note'); + } + ); +}); + +function insertEdit( + line: number, + character: number, + newText: string +): TextEdit { + return { + range: { + start: { line, character }, + end: { line, character }, + }, + newText, + }; +} + +// One history-recorded keystroke: inserts `text` at the caret position, with +// the pre-keystroke caret recorded, the way the editor drives typing. +function keystroke( + d: ReturnType, + line: number, + character: number, + text: string +) { + d.applyEdits([insertEdit(line, character, text)], true, [ + caretAt(line, character), + ]); +} + +// History/coalescing scenarios. Two areas: (1) degenerate history batches +// around undo — the conventional empty-transaction contract says a batch +// containing no edits must leave history untouched, and in particular must +// not destroy a pending redo; (2) a seeded randomized keystroke-run oracle +// asserting the typing/backspace/forward-delete coalescing pipeline never +// corrupts undo/redo round-trips. Deterministic coalescing cases are already +// pinned in test/editorTextDocument.test.ts and the sibling describes above +// in this file; nothing here repeats those. +describe('degenerate history batches around undo', () => { + // The post-undo variant of the empty-transaction contract: the main suite + // only checks an empty batch on a fresh document (canUndo stays false), so + // the redo-stack half of the contract is pinned here. + test('an empty history batch after undo leaves the redo stack ready to fire', () => { + const d = doc('tide'); + keystroke(d, 0, 4, 'pool'); + expect(d.getText()).toBe('tidepool'); + d.undo(); + expect(d.getText()).toBe('tide'); + expect(d.version).toBe(0); + expect(d.canRedo).toBe(true); + + // An empty batch with history requested pushes no entry, bumps no + // version, and — critically — does not clear the pending redo. + expect(d.applyEdits([], true, [caretAt(0, 0)])).toBeUndefined(); + expect(d.canUndo).toBe(false); + expect(d.canRedo).toBe(true); + expect(d.version).toBe(0); + + // Even flagged as an undo boundary, the empty batch stays inert. + expect( + d.applyEdits([], true, [caretAt(0, 2)], [caretAt(0, 2)], true) + ).toBeUndefined(); + expect(d.canRedo).toBe(true); + expect(d.version).toBe(0); + + // The surviving redo replays the undone insert exactly. + expect(d.redo()).toBeDefined(); + expect(d.getText()).toBe('tidepool'); + expect(d.version).toBe(1); + expect(d.canUndo).toBe(true); + expect(d.canRedo).toBe(false); + }); + + // An empty batch between two keystrokes leaves no trace at all: it must not + // sever the typing coalescing group the way a real zero-width entry does + // (that contrast is pinned in editorApplyEdits.test.ts). + test('an empty history batch between keystrokes leaves the coalescing group intact', () => { + const d = doc(''); + keystroke(d, 0, 0, 'w'); + d.applyEdits([], true, [caretAt(0, 1)], undefined, true); + keystroke(d, 0, 1, 'o'); + expect(d.getText()).toBe('wo'); + + // One undo step clears both characters: the empty batch was invisible. + d.undo(); + expect(d.getText()).toBe(''); + expect(d.canUndo).toBe(false); + + d.redo(); + expect(d.getText()).toBe('wo'); + expect(d.canRedo).toBe(false); + }); + + // DIVERGENCE: the conventional behavior drops a transaction that made no + // changes on the floor, so any no-op after undo preserves the redo stack. + // Pierre records every non-empty edits array passed with updateHistory as a + // real history step — identity entries on a fresh document are pinned that + // way in editorApplyEdits.test.ts — and every recorded step clears redo, + // the same rule as a real edit (pinned in test/editorTextDocument.test.ts + // 'new edit after undo clears redo stack'). Coherent policy; pinned here at + // the post-undo boundary where it costs the pending redo. + test('a batch of zero-width empty edits after undo records a real step and drops redo', () => { + const d = doc('fern'); + keystroke(d, 0, 4, '!'); + d.undo(); + expect(d.canRedo).toBe(true); + + const change = d.applyEdits([insertEdit(0, 2, '')], true, [caretAt(0, 2)]); + expect(change).toBeDefined(); + expect(change?.lineDelta).toBe(0); + expect(d.getText()).toBe('fern'); + expect(d.version).toBe(1); + expect(d.canUndo).toBe(true); + // The pending redo (the undone '!') is gone. + expect(d.canRedo).toBe(false); + + // The identity entry is one clean history step: undo/redo round-trip the + // version without touching the text, and nothing older sits beneath it. + expect(d.undo()).toBeDefined(); + expect(d.getText()).toBe('fern'); + expect(d.version).toBe(0); + expect(d.canUndo).toBe(false); + expect(d.redo()).toBeDefined(); + expect(d.getText()).toBe('fern'); + expect(d.version).toBe(1); + + // A multi-edit degenerate batch behaves the same way: one entry, redo gone. + const d2 = doc('reef\nkelp'); + keystroke(d2, 1, 4, 's'); + d2.undo(); + expect(d2.canRedo).toBe(true); + d2.applyEdits([insertEdit(0, 1, ''), insertEdit(1, 2, '')], true, [ + caretAt(0, 1), + caretAt(1, 2), + ]); + expect(d2.getText()).toBe('reef\nkelp'); + expect(d2.canRedo).toBe(false); + expect(d2.canUndo).toBe(true); + d2.undo(); + expect(d2.canUndo).toBe(false); + }); +}); + +// Deterministic pseudo-random source (mulberry32) so every fuzz run replays +// the identical operation stream for a given seed. +function seededRandom(seed: number): () => number { + let state = seed >>> 0; + return () => { + state = (state + 0x6d2b79f5) | 0; + let mixed = Math.imul(state ^ (state >>> 15), 1 | state); + mixed = (mixed + Math.imul(mixed ^ (mixed >>> 7), 61 | mixed)) ^ mixed; + return ((mixed ^ (mixed >>> 14)) >>> 0) / 4294967296; + }; +} + +// Maps an offset in the reference string to a {line, character} position, +// independent of the document under test. +function positionInMirror(text: string, offset: number) { + let line = 0; + let lineStart = 0; + for (let i = 0; i < offset; i++) { + if (text[i] === '\n') { + line++; + lineStart = i + 1; + } + } + return { line, character: offset - lineStart }; +} + +const FUZZ_STEPS = 150; + +// Drives one seeded run of FUZZ_STEPS random operations — multi-caret typing, +// backspace, forward delete, boundary-flagged pastes, and selection-only caret +// jumps — through history-tracked applyEdits while maintaining a reference +// string, then unwinds and replays the whole history. +function runSeededKeystrokeRun(seed: number) { + const rand = seededRandom(seed); + const baseText = 'harbor lights\ndim the quay\n'; + // A roomy injected stack keeps this a pure coalescing test: the run may + // produce more entries than the default 100-entry cap, and entry eviction + // (covered by the "maxEntries drops oldest undo history first" test above) + // would break exhaustion. + const d = doc(baseText, new EditStack({ maxEntries: 1000 })); + let mirror = baseText; + // Caret offsets into `mirror`, ascending and distinct; multi-caret steps + // apply one sub-edit per caret in a single applyEdits batch. + let carets: number[] = [Math.floor(rand() * (mirror.length + 1))]; + const typeAlphabet = 'esketch mont\nblue'; + const pasteAlphabet = 'veranda '; + + const jumpCarets = () => { + const count = 1 + Math.floor(rand() * 3); + const landed = new Set(); + for (let i = 0; i < count; i++) { + landed.add(Math.floor(rand() * (mirror.length + 1))); + } + carets = [...landed].sort((a, b) => a - b); + }; + + // Applies one batch (offsets resolved against the current text, ascending + // and non-overlapping), mirrors it onto the reference string, and re-seats + // the carets. Selections passed to applyEdits are the pre-edit carets, the + // same shape the editor records for typing. + const applyBatch = ( + splices: { start: number; end: number; text: string }[], + caretsAfter: number[], + undoBoundary: boolean + ) => { + const edits = splices.map((splice) => ({ + range: { + start: d.positionAt(splice.start), + end: d.positionAt(splice.end), + }, + newText: splice.text, + })); + const selections = carets.map((offset) => { + const p = positionInMirror(mirror, offset); + return caretAt(p.line, p.character); + }); + d.applyEdits(edits, true, selections, undefined, undoBoundary); + for (const splice of [...splices].reverse()) { + mirror = + mirror.slice(0, splice.start) + splice.text + mirror.slice(splice.end); + } + carets = [...new Set(caretsAfter)].sort((a, b) => a - b); + }; + + for (let step = 0; step < FUZZ_STEPS; step++) { + const roll = rand(); + if (roll < 0.4) { + // Type one character at every caret, like multi-cursor typing. + const ch = typeAlphabet[Math.floor(rand() * typeAlphabet.length)] ?? 'e'; + let delta = 0; + const caretsAfter = carets.map((offset) => { + const seated = offset + delta + 1; + delta += 1; + return seated; + }); + applyBatch( + carets.map((offset) => ({ start: offset, end: offset, text: ch })), + caretsAfter, + false + ); + } else if (roll < 0.55) { + // Backspace at every caret that has a character to its left. + const eligible = carets.filter((offset) => offset > 0); + if (eligible.length === 0) { + jumpCarets(); + continue; + } + let delta = 0; + const caretsAfter = carets.map((offset) => { + if (offset > 0) { + const seated = offset - 1 + delta; + delta -= 1; + return seated; + } + return offset + delta; + }); + applyBatch( + eligible.map((offset) => ({ + start: offset - 1, + end: offset, + text: '', + })), + caretsAfter, + false + ); + } else if (roll < 0.7) { + // Forward-delete at every caret that has a character to its right. + const eligible = carets.filter((offset) => offset < mirror.length); + if (eligible.length === 0) { + jumpCarets(); + continue; + } + let delta = 0; + const caretsAfter = carets.map((offset) => { + const seated = offset + delta; + if (offset < mirror.length) { + delta -= 1; + } + return seated; + }); + applyBatch( + eligible.map((offset) => ({ + start: offset, + end: offset + 1, + text: '', + })), + caretsAfter, + false + ); + } else if (roll < 0.82) { + // Paste a short string at every caret, flagged as an undo boundary the + // way the editor's paste handler does. + let pasted = ''; + const length = 2 + Math.floor(rand() * 5); + for (let i = 0; i < length; i++) { + pasted += + pasteAlphabet[Math.floor(rand() * pasteAlphabet.length)] ?? ' '; + } + let delta = 0; + const caretsAfter = carets.map((offset) => { + const seated = offset + delta + pasted.length; + delta += pasted.length; + return seated; + }); + applyBatch( + carets.map((offset) => ({ start: offset, end: offset, text: pasted })), + caretsAfter, + true + ); + } else { + // Selection-only caret jump: no edit, so nothing to assert this step. + jumpCarets(); + continue; + } + // Per-step invariants: the document tracks the reference byte-for-byte. + expect(d.getText()).toBe(mirror); + expect(d.lineCount).toBe(mirror.split('\n').length); + } + + // Exhaustion phase: however the keystrokes coalesced, unwinding the whole + // history restores the original text and replaying it restores the final + // text, byte-exact, with the version tracking both endpoints. + const finalText = mirror; + const finalVersion = d.version; + const undoSteps = undoAll(d); + expect(undoSteps).toBeGreaterThan(0); + expect(d.getText()).toBe(baseText); + expect(d.version).toBe(0); + expect(d.canRedo).toBe(true); + + const redoSteps = redoAll(d); + expect(redoSteps).toBe(undoSteps); + expect(d.getText()).toBe(finalText); + expect(d.version).toBe(finalVersion); + expect(d.canUndo).toBe(true); +} + +describe('randomized keystroke-run history oracle', () => { + // Where a randomized splice oracle replays random splices against a mirror + // document and checks the recorded patch in both directions, this drives + // seeded keystroke runs through history-tracked applyEdits and checks the + // coalesced history in both directions via exhaustion. CONSTRAINTS: this + // must stay a passing invariant test, so the run never undoes mid-stream + // (coalescing across undo/redo is the known-bug family in the "EditStack + // coalescing across undo and redo" describe above) and never applies + // history-skipping edits (the frozen-entry known bugs live in the + // "undo/redo across non-history edits" describe above); undo/redo run only + // in the final exhaustion phase. + test('seeded keystroke runs keep per-step text fidelity and byte-exact undo/redo exhaustion', () => { + for (const seed of [7, 19, 33]) { + runSeededKeystrokeRun(seed); + } + }); +}); diff --git a/packages/diffs/test/editorPieceTable.test.ts b/packages/diffs/test/editorPieceTable.test.ts index 825cfe95c..017f974fe 100644 --- a/packages/diffs/test/editorPieceTable.test.ts +++ b/packages/diffs/test/editorPieceTable.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'bun:test'; import { PieceTable } from '../src/editor/pieceTable'; +import { TextDocument } from '../src/editor/textDocument'; import type { Position } from '../src/types'; function lineTexts(text: string): string[] { @@ -569,3 +570,653 @@ describe('PieceTable', () => { } }); }); + +function doc(text: string) { + return new TextDocument('inmemory://1', text, 'plain'); +} + +// Independent line-splitting oracle. Line breaks are `\n`, lone `\r`, and +// `\r\n` counted as ONE break — the same policy as computeLineOffsets, which +// is the buffer-level source of truth the PieceTable is supposed to agree +// with at the document level. Returns the start offset of every line. +function oracleLineStarts(text: string): number[] { + const starts = [0]; + for (let i = 0; i < text.length; i++) { + const code = text.charCodeAt(i); + if (code === 10) { + starts.push(i + 1); + } else if (code === 13) { + if (i + 1 < text.length && text.charCodeAt(i + 1) === 10) { + i++; // \r\n is a single break + } + starts.push(i + 1); + } + } + return starts; +} + +// Offset-faithful position mapping (PieceTable semantics): the line containing +// the offset, with the raw column — even when that column points into the +// line's terminating break. +function oraclePositionAt(text: string, offset: number): Position { + const clamped = Math.min(Math.max(offset, 0), text.length); + const starts = oracleLineStarts(text); + let line = 0; + while (line + 1 < starts.length && starts[line + 1] <= clamped) { + line++; + } + return { line, character: clamped - starts[line] }; +} + +function oracleOffsetAt(text: string, position: Position): number { + if (position.line < 0 || text.length === 0) { + return 0; + } + const starts = oracleLineStarts(text); + const lineStart = starts[position.line]; + const lineEnd = + position.line + 1 < starts.length ? starts[position.line + 1] : text.length; + const character = Math.min( + Math.max(position.character, 0), + lineEnd - lineStart + ); + return lineStart + character; +} + +// Full line-metadata cross-check: content, line count, and both directions of +// the offset<->position mapping over the entire document. +function expectLineMetadataToMatch(table: PieceTable, text: string): void { + expect(table.getText()).toBe(text); + + const starts = oracleLineStarts(text); + expect(table.lineCount).toBe(starts.length); + + for (let offset = 0; offset <= text.length; offset++) { + expect(table.positionAt(offset)).toEqual(oraclePositionAt(text, offset)); + } + + for (let line = 0; line < starts.length; line++) { + const lineEnd = line + 1 < starts.length ? starts[line + 1] : text.length; + for (let character = 0; character <= lineEnd - starts[line]; character++) { + expect(table.offsetAt({ line, character })).toBe( + oracleOffsetAt(text, { line, character }) + ); + } + } +} + +// End offset of a line's visible content (trailing CR/LF trimmed), matching +// getLineLength(line) === contentEnd - starts[line]. +function oracleContentEnd( + text: string, + starts: number[], + line: number +): number { + const spanEnd = line + 1 < starts.length ? starts[line + 1] : text.length; + let end = spanEnd; + while (end > starts[line]) { + const code = text.charCodeAt(end - 1); + if (code !== 10 && code !== 13) { + break; + } + end--; + } + return end; +} + +// Full line-metadata cross-check against the oracle: line count, positionAt at +// every offset, and offsetAt at every line's boundaries (including the +// past-the-span clamp). +function expectLinePositionsToMatchOracle( + table: PieceTable, + text: string +): void { + const starts = oracleLineStarts(text); + expect(table.lineCount).toBe(starts.length); + for (let offset = 0; offset <= text.length; offset++) { + expect(table.positionAt(offset)).toEqual(oraclePositionAt(text, offset)); + } + for (let line = 0; line < starts.length; line++) { + const spanEnd = line + 1 < starts.length ? starts[line + 1] : text.length; + const span = spanEnd - starts[line]; + expect(table.offsetAt({ line, character: 0 })).toBe(starts[line]); + expect(table.offsetAt({ line, character: span })).toBe(spanEnd); + expect(table.offsetAt({ line, character: span + 9 })).toBe(spanEnd); + } +} + +function buildRandomSingleLine(length: number, seed: number): string { + const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789 '; + const random = createRandom(seed); + const chars: string[] = []; + for (let i = 0; i < length; i++) { + chars.push(alphabet[Math.floor(random() * alphabet.length)]); + } + return chars.join(''); +} + +// Mixed line endings: a \r\n pair, lone \r breaks, a \r\r\n run (lone \r +// followed by a \r\n pair), a bare \n, and a second \r\n pair. +// Lines: 'ivy\r\n' 'oak\r' 'elm\r' '\r\n' 'fig\r' '\r' 'ash\n' '\r\n' 'end' +// Line starts: [0, 5, 9, 13, 15, 19, 20, 24, 26]; length 29. +// Offsets 4, 14, and 25 sit between the \r and \n of a CRLF pair. +const MIXED = 'ivy\r\noak\relm\r\r\nfig\r\rash\n\r\nend'; + +describe('PieceTable CRLF and lone-CR line breaks', () => { + test('lone \\r mixed with \\r\\n breaks reads back byte-for-byte untouched', () => { + // DIVERGENCE: a model that normalizes line endings would rewrite a lone + // \r to the document's dominant EOL on read. pierre-fe is diff-oriented + // and must preserve the original bytes exactly, so getText() returns the + // lone \r untouched. + const original = 'north\r\nsouth east\rwest\r\ncenter'; + const table = new PieceTable(original); + + expect(table.getText()).toBe(original); + }); + + test('lone \\r mixed with \\r\\n breaks still counts as its own line break', () => { + // The other half of the contract: even though the lone \r byte is + // preserved (see the divergence above), it is still a line break for line + // counting and position mapping. + const original = 'north\r\nsouth east\rwest\r\ncenter'; + const table = new PieceTable(original); + + expect(table.lineCount).toBe(4); + expect(table.getLineText(0)).toBe('north'); + expect(table.getLineText(1)).toBe('south east'); + expect(table.getLineText(2)).toBe('west'); + expect(table.getLineText(3)).toBe('center'); + // Offset 18 sits right after the lone \r: the start of line 2. + expect(table.positionAt(18)).toEqual({ line: 2, character: 0 }); + expect(table.offsetAt({ line: 2, character: 0 })).toBe(18); + }); + + test('deleting exactly the \\r of a \\r\\n pair leaves a valid \\n break', () => { + const table = new PieceTable('cat\r\ndog'); + + table.delete(3, 1); + + expect(table.getText()).toBe('cat\ndog'); + expect(table.lineCount).toBe(2); + }); + + // KNOWN BUG: deleting the \n out of \r\n leaves a lone \r that the piece's + // buffer-based line metadata no longer counts as a break (the buffer counted + // \r\n as one break ending after the \n, which is now outside the piece), so + // lineCount collapses to 1 even though the text still has two lines. + test.failing( + 'deleting exactly the \\n of a \\r\\n pair leaves a lone \\r break', + () => { + const table = new PieceTable('cat\r\ndog'); + + table.delete(4, 1); + + expect(table.getText()).toBe('cat\rdog'); + expect(table.lineCount).toBe(2); + } + ); + + // KNOWN BUG: a \r and a \n inserted separately land as distinct chunks in + // the add buffer, each counted as its own line break, so the \r\n pair they + // form in the document is double-counted and lineCount reads 3 instead of 2. + test.failing( + '\\r\\n assembled from two separate inserts counts as one break', + () => { + const table = new PieceTable('ab'); + + table.insert('\r', 1); + table.insert('\n', 2); + + expect(table.getText()).toBe('a\r\nb'); + expect(table.lineCount).toBe(2); + } + ); + + // KNOWN BUG: inserting between the \r and \n of an existing pair splits the + // piece, but the buffer's line metadata still records one break ending after + // the \n, so the now-lone \r is not counted and lineCount reads 2 instead of 3. + test.failing( + 'inserting between \\r and \\n promotes the \\r to its own break', + () => { + const table = new PieceTable('a\r\nb'); + + table.insert('X', 2); + + expect(table.getText()).toBe('a\rX\nb'); + expect(table.lineCount).toBe(3); + } + ); + + // KNOWN BUG: CRLF pairs split or formed across piece boundaries corrupt the + // piece-level line-break counts (see the two directed repros above), so + // lineCount and positionAt/offsetAt drift from the string oracle under + // CR/LF-biased editing even while getText() stays correct. + test.failing( + 'line metadata matches a string oracle across CRLF-biased random edits', + () => { + const random = createRandom(20260713); + const inserts = ['\r', '\n', '\r\n', '\n\r', '\r\nq', 'j\r', 'zz', '']; + let text = 'aa\r\nbb\ncc\r\ndd'; + const table = new PieceTable(text); + + for (let i = 0; i < 200; i++) { + if (random() < 0.65) { + const insert = inserts[Math.floor(random() * inserts.length)]; + const offset = Math.floor(random() * (text.length + 1)); + table.insert(insert, offset); + text = text.slice(0, offset) + insert + text.slice(offset); + } else { + const offset = Math.floor(random() * (text.length + 1)); + const length = Math.floor(random() * 4); + table.delete(offset, length); + text = text.slice(0, offset) + text.slice(offset + length); + } + + expectLineMetadataToMatch(table, text); + } + } + ); +}); + +describe('mixed-EOL positionAt/offsetAt round trip', () => { + test('PieceTable: offsetAt of positionAt is the identity at every offset, even inside CRLF pairs', () => { + // DIVERGENCE: conventional editor buffers never let a line lookup land + // between the \r and \n of a CRLF pair — positions snap to a break + // boundary. PieceTable is offset-faithful instead: positionAt reports the + // raw split point and its own offsetAt maps it straight back, so the + // round trip is the identity and trivially idempotent. Self-consistent at + // this layer; the clamp policy lives one layer up in + // TextDocument.normalizePosition. + const table = new PieceTable(MIXED); + + expectLinePositionsToMatchOracle(table, MIXED); + for (let offset = 0; offset <= MIXED.length; offset++) { + expect(table.offsetAt(table.positionAt(offset))).toBe(offset); + } + }); + + test('PieceTable.positionAt reports offsets between \\r and \\n faithfully', () => { + // DIVERGENCE: these positions point strictly inside a CRLF pair — their + // character exceeds the line's visible content length. A boundary-snapping + // mapping cannot produce them; PieceTable does, by design (see the + // identity round trip above). + const table = new PieceTable(MIXED); + + expect(table.positionAt(4)).toEqual({ line: 0, character: 4 }); + expect(table.getLineLength(0)).toBe(3); + expect(table.positionAt(14)).toEqual({ line: 3, character: 1 }); + expect(table.getLineLength(3)).toBe(0); + expect(table.positionAt(25)).toEqual({ line: 7, character: 1 }); + expect(table.getLineLength(7)).toBe(0); + }); + + // KNOWN BUG: TextDocument.positionAt delegates to the raw piece-table + // mapping and can return a position strictly inside a CRLF pair (character + // beyond getLineLength) that its own offsetAt refuses to map back — + // normalizePosition clamps it to the line's content end, so + // offsetAt(positionAt(o)) silently loses a column. The commented-out clamp + // tests at editorTextDocument.test.ts:150 and :169 record the intended + // contract (positions clamp to visible content at this layer). + test.failing( + 'TextDocument.positionAt never lands between the \\r and \\n of a CRLF pair', + () => { + const d = doc(MIXED); + + for (let offset = 0; offset <= MIXED.length; offset++) { + const position = d.positionAt(offset); + expect(position.character).toBeLessThanOrEqual( + d.getLineLength(position.line) + ); + } + } + ); + + test('TextDocument: offsetAt of positionAt is idempotent and snaps break-interior offsets to content end', () => { + // Unlike the PieceTable layer, the TextDocument round trip is not the + // identity: offsets between the \r and \n of a CRLF pair snap back to the + // end of the line's visible content (offsetAt normalizes what positionAt + // emitted). The mapping settles after one application — a second round + // trip is a fixpoint — which is the conventional stability guarantee for + // line/offset conversions. + const d = doc(MIXED); + const starts = oracleLineStarts(MIXED); + const roundTrip = (offset: number) => d.offsetAt(d.positionAt(offset)); + + for (let offset = 0; offset <= MIXED.length; offset++) { + const { line } = oraclePositionAt(MIXED, offset); + const expected = Math.min(offset, oracleContentEnd(MIXED, starts, line)); + const once = roundTrip(offset); + expect(once).toBe(expected); + expect(roundTrip(once)).toBe(once); + } + // The three break-interior offsets are the only ones that move. + expect(roundTrip(4)).toBe(3); + expect(roundTrip(14)).toBe(13); + expect(roundTrip(25)).toBe(24); + }); +}); + +describe('out-of-range line and position contract', () => { + test('PieceTable.offsetAt: negative line maps to 0, line at or past lineCount throws', () => { + // DIVERGENCE: a stricter contract would reject out-of-range lines on BOTH + // sides. PieceTable is asymmetric on a non-empty document: a negative + // line silently returns offset 0, while a line at or past lineCount + // throws. + const table = new PieceTable('fern\nmoss\nreed'); + + expect(table.lineCount).toBe(3); + expect(table.offsetAt({ line: -1, character: 0 })).toBe(0); + expect(table.offsetAt({ line: -5, character: 7 })).toBe(0); + expect(() => table.offsetAt({ line: 3, character: 0 })).toThrow( + 'Line index out of range: 3' + ); + expect(() => table.offsetAt({ line: 99, character: 0 })).toThrow( + 'Line index out of range: 99' + ); + }); + + test('TextDocument.offsetAt silently clamps every out-of-range position', () => { + // DIVERGENCE: a stricter contract would throw on out-of-range positions. + // TextDocument never throws: normalizePosition clamps the line into + // [0, lineCount) and the character into the line's visible content before + // resolving, so any position resolves to a valid offset. + const d = doc('fern\nmoss\nreed'); + + // Negative line clamps to line 0; the character still applies (clamped + // to line 0's content length of 4). + expect(d.offsetAt({ line: -5, character: 2 })).toBe(2); + expect(d.offsetAt({ line: -5, character: 99 })).toBe(4); + // Line past the end clamps to the last line; huge character clamps to + // document end. + expect(d.offsetAt({ line: 99, character: 99 })).toBe(14); + expect(d.offsetAt({ line: 99, character: 0 })).toBe(10); + // Negative character clamps to the line start. + expect(d.offsetAt({ line: 1, character: -7 })).toBe(5); + }); + + test('oversized character: PieceTable clamps into the break span, TextDocument to line content', () => { + // DIVERGENCE (layer contrast): for a character past the end of a line, + // PieceTable clamps to the line's full span INCLUDING its line break — + // landing on the next line's start offset — while TextDocument clamps to + // the visible content end before the break. A stricter contract would + // throw instead. + const table = new PieceTable('fern\nmoss\nreed'); + const d = doc('fern\nmoss\nreed'); + + expect(table.offsetAt({ line: 0, character: 99 })).toBe(5); + expect(d.offsetAt({ line: 0, character: 99 })).toBe(4); + expect(table.offsetAt({ line: 1, character: 99 })).toBe(10); + expect(d.offsetAt({ line: 1, character: 99 })).toBe(9); + // Last line (no break): both layers agree on document end. + expect(table.offsetAt({ line: 2, character: 99 })).toBe(14); + expect(d.offsetAt({ line: 2, character: 99 })).toBe(14); + }); +}); + +describe('arbitrary-range slices on a fragmented table', () => { + test('random getTextSlice ranges match a plain-string oracle', () => { + // 250 scattered single edits fragment the table into many pieces, then + // 300 random slices — half short mid-tree spans, half arbitrary spans — + // must match String.prototype.slice on the oracle. Slices starting + // mid-tree exercise findPieceAtOffset plus the parent-pointer walk across + // piece seams. + const random = createRandom(0x5eed); + let str = 'harbor lights\nquiet mole\ndrifting boats\nsalt air\n'; + const table = new PieceTable(str); + const inserts = ['k', 'wz', '\n', '###', 'north\nsouth', '']; + + for (let i = 0; i < 250; i++) { + if (random() < 0.6) { + const insert = inserts[Math.floor(random() * inserts.length)]; + const offset = Math.floor(random() * (str.length + 1)); + table.insert(insert, offset); + str = str.slice(0, offset) + insert + str.slice(offset); + } else { + const offset = Math.floor(random() * (str.length + 1)); + const length = Math.floor(random() * 4); + table.delete(offset, length); + str = str.slice(0, offset) + str.slice(offset + length); + } + } + expect(table.getText()).toBe(str); + + for (let i = 0; i < 300; i++) { + const from = Math.floor(random() * (str.length + 1)); + const to = + i % 2 === 0 + ? Math.min(from + 2 + Math.floor(random() * 6), str.length) + : from + Math.floor(random() * (str.length - from + 1)); + expect(table.getTextSlice(from, to)).toBe(str.slice(from, to)); + } + + expect(table.getTextSlice(0, str.length)).toBe(str); + expect(table.getTextSlice(0, 0)).toBe(''); + expect(table.getTextSlice(str.length, str.length)).toBe(''); + }); +}); + +describe('building a document by repeated appends', () => { + test('600 newline-bearing appends at end of document keep content and line metadata', () => { + // Every chunk lands at the current end of the document, so sequential + // appends coalesce into a long run — this exercises TextBuffer.append's + // offset-shifted lineOffsets bookkeeping through coalesceTwoPieces. + const table = new PieceTable(''); + let expected = ''; + const lineStartOffsets: number[] = []; + + for (let i = 0; i < 600; i++) { + const chunk = `item ${i} ok\n`; + lineStartOffsets.push(expected.length); + table.insert(chunk, expected.length); + expected += chunk; + } + + expect(table.getText()).toBe(expected); + expect(table.lineCount).toBe(601); + + for (const line of [0, 1, 59, 300, 427, 599]) { + expect(table.getLineText(line)).toBe(`item ${line} ok`); + expect(table.offsetAt({ line, character: 0 })).toBe( + lineStartOffsets[line] + ); + expect(table.positionAt(lineStartOffsets[line])).toEqual({ + line, + character: 0, + }); + } + expect(table.getLineText(600)).toBe(''); + + // Boundary positions: document start, document end (the empty final + // line), the last break, and the seam just before a sampled line start. + expect(table.positionAt(0)).toEqual({ line: 0, character: 0 }); + expect(table.positionAt(expected.length)).toEqual({ + line: 600, + character: 0, + }); + expect(table.positionAt(expected.length - 1)).toEqual({ + line: 599, + character: 'item 599 ok'.length, + }); + expect(table.positionAt(lineStartOffsets[300] - 1)).toEqual({ + line: 299, + character: 'item 299 ok'.length, + }); + expect(table.offsetAt({ line: 600, character: 0 })).toBe(expected.length); + }); +}); + +describe('repeated block inserts at one fixed offset', () => { + test('12 multi-line ~600-char blocks at a fixed middle offset keep content and positions', () => { + // Each insert lands at the same document offset, so every new block sits + // BEFORE the previously inserted one and nothing can coalesce — + // worst-case single-seam fragmentation. Blocks carry their iteration + // number so the expected ordering (newest first) is actually verified. + const baseLines: string[] = []; + for (let i = 0; i < 30; i++) { + baseLines.push(`ln${String(i).padStart(2, '0')}:` + 'x'.repeat(12)); + } + const base = baseLines.join('\n'); + const mid = Math.floor(base.length / 2); + const block = (i: number) => + `qrstuvwxyz\n`.repeat(38); + + const table = new PieceTable(base); + let middle = ''; + for (let i = 0; i < 12; i++) { + table.insert(block(i), mid); + middle = block(i) + middle; + } + const expected = base.slice(0, mid) + middle + base.slice(mid); + + expect(table.getText()).toBe(expected); + expectLinePositionsToMatchOracle(table, expected); + }); +}); + +describe('slice bound clipping', () => { + test('reversed, negative, and out-of-range slice bounds return the empty string', () => { + const table = new PieceTable('grape\nmelon'); + + expect(table.getTextSlice(5, 0)).toBe(''); + expect(table.getTextSlice(0, -10)).toBe(''); + expect(table.getTextSlice(-5, 0)).toBe(''); + expect(table.getTextSlice(1000, 1100)).toBe(''); + expect(table.getTextSlice(11, 11)).toBe(''); + // Partially out-of-range bounds clamp instead of clearing. + expect(table.getTextSlice(3, 999)).toBe('pe\nmelon'); + expect(table.getTextSlice(-4, 4)).toBe('grap'); + + const empty = new PieceTable(''); + expect(empty.getTextSlice(0, 10)).toBe(''); + expect(empty.getTextSlice(1000, 1100)).toBe(''); + expect(empty.getTextSlice(-5, 5)).toBe(''); + expect(empty.getTextSlice(5, 0)).toBe(''); + + // TextDocument.getTextSlice delegates without extra clamping. + const d = doc('grape\nmelon'); + expect(d.getTextSlice(5, 0)).toBe(''); + expect(d.getTextSlice(1000, 1100)).toBe(''); + expect(doc('').getTextSlice(1000, 1100)).toBe(''); + }); + + test('TextDocument.getText returns empty for an inverted range while applyEdits swaps it', () => { + // DIVERGENCE (layer contrast): a stricter contract would clip inverted + // slice bounds to empty and reject inverted change ranges outright. + // pierre-fe reads and writes disagree with each other by design: getText + // with an inverted range resolves to a reversed offset pair and yields '', + // but applyEdits swaps the inverted start/end (#resolveEdit) and applies + // the replacement. + const d = doc('plum\npear'); + + expect( + d.getText({ + start: { line: 1, character: 3 }, + end: { line: 0, character: 2 }, + }) + ).toBe(''); + + d.applyEdits([ + { + range: { + start: { line: 0, character: 4 }, + end: { line: 0, character: 1 }, + }, + newText: 'LUM', + }, + ]); + expect(d.getText()).toBe('pLUM\npear'); + }); +}); + +describe('very long single line', () => { + test('PieceTable: a fragmented 50k-char line keeps single-line metadata until the first \\n', () => { + let str = buildRandomSingleLine(50_000, 0xfab1e); + const table = new PieceTable(str); + const random = createRandom(0x10c); + + // Fragment the line with newline-free edits; the tree ends up with many + // pieces whose subtreeLineBreakCount is 0 everywhere. + for (let i = 0; i < 40; i++) { + const insertOffset = Math.floor(random() * (str.length + 1)); + table.insert('QZJ', insertOffset); + str = str.slice(0, insertOffset) + 'QZJ' + str.slice(insertOffset); + const deleteOffset = Math.floor(random() * str.length); + table.delete(deleteOffset, 2); + str = str.slice(0, deleteOffset) + str.slice(deleteOffset + 2); + } + + expect(table.getText()).toBe(str); + expect(table.lineCount).toBe(1); + expect(table.getLineLength(0)).toBe(str.length); + expect(table.getLineLength(0, true)).toBe(str.length); + + for (const offset of [0, 1, 7, 4_999, 25_000, str.length - 1, str.length]) { + expect(table.positionAt(offset)).toEqual({ line: 0, character: offset }); + expect(table.offsetAt({ line: 0, character: offset })).toBe(offset); + } + // Huge values clamp to the line/document end. + expect(table.positionAt(str.length + 12_345)).toEqual({ + line: 0, + character: str.length, + }); + expect(table.offsetAt({ line: 0, character: 2 ** 31 })).toBe(str.length); + + // The FIRST line break splits the document into exactly two lines. + const splitAt = Math.floor(str.length / 2); + table.insert('\n', splitAt); + expect(table.lineCount).toBe(2); + expect(table.getLineLength(0)).toBe(splitAt); + expect(table.getLineText(0)).toBe(str.slice(0, splitAt)); + expect(table.getLineText(1)).toBe(str.slice(splitAt)); + expect(table.positionAt(splitAt)).toEqual({ line: 0, character: splitAt }); + expect(table.positionAt(splitAt + 1)).toEqual({ line: 1, character: 0 }); + expect(table.offsetAt({ line: 1, character: 0 })).toBe(splitAt + 1); + expect(table.positionAt(str.length + 1)).toEqual({ + line: 1, + character: str.length - splitAt, + }); + }); + + test('TextDocument: huge positions clamp on the long line and the first \\n splits it', () => { + let str = buildRandomSingleLine(50_000, 0xace1); + const d = doc(str); + const insertPlain = (offset: number, text: string) => { + d.applyEdits([ + { + range: { + start: { line: 0, character: offset }, + end: { line: 0, character: offset }, + }, + newText: text, + }, + ]); + str = str.slice(0, offset) + text + str.slice(offset); + }; + + insertPlain(41_000, 'PQ'); + insertPlain(17, 'RS'); + insertPlain(23_456, 'TU'); + + expect(d.lineCount).toBe(1); + expect(d.getLineLength(0)).toBe(str.length); + expect(d.positionAt(10 ** 9)).toEqual({ + line: 0, + character: str.length, + }); + expect(d.offsetAt({ line: 0, character: 10 ** 9 })).toBe(str.length); + // An out-of-range line clamps to the single line 0, where character 99 is + // perfectly valid on a 50k-char line. + expect(d.offsetAt({ line: 99, character: 99 })).toBe(99); + expect(d.offsetAt({ line: 99, character: 10 ** 9 })).toBe(str.length); + + const splitAt = 30_000; + insertPlain(splitAt, '\n'); + expect(d.lineCount).toBe(2); + expect(d.getLineLength(0)).toBe(splitAt); + expect(d.getLineText(0)).toBe(str.slice(0, splitAt)); + expect(d.getLineText(1)).toBe(str.slice(splitAt + 1)); + expect(d.positionAt(splitAt + 1)).toEqual({ line: 1, character: 0 }); + expect(d.offsetAt({ line: 1, character: 0 })).toBe(splitAt + 1); + expect(d.getText()).toBe(str); + }); +}); diff --git a/packages/diffs/test/editorSearchPanel.test.ts b/packages/diffs/test/editorSearchPanel.test.ts index 88e47e22e..54ea793bb 100644 --- a/packages/diffs/test/editorSearchPanel.test.ts +++ b/packages/diffs/test/editorSearchPanel.test.ts @@ -1,9 +1,14 @@ import { describe, expect, test } from 'bun:test'; +import { + buildSearchReplacementText, + PieceTable, +} from '../src/editor/pieceTable'; import { type MatchRange, type SearchPanelOptions, SearchPanelWidget, + type SearchParams, } from '../src/editor/searchPanel'; import type { ResolvedTextEdit } from '../src/editor/textDocument'; import { TextDocument } from '../src/editor/textDocument'; @@ -291,3 +296,729 @@ describe('SearchPanelWidget', () => { } }); }); + +// --------------------------------------------------------------------------- +// Search/replace semantics exercised against PieceTable.search, +// buildSearchReplacementText, and SearchPanelWidget. pierre's search returns +// flat [start, end) document offsets (rather than row/column point ranges), so +// expectations are stated in offsets and round-tripped through positionAt +// where line geometry matters. +// --------------------------------------------------------------------------- + +function searchParams( + text: string, + overrides: Partial = {} +): SearchParams { + return { + text, + replaceText: '', + caseSensitive: true, + wholeWord: false, + regex: true, + ...overrides, + }; +} + +function findAll( + docText: string, + pattern: string, + overrides: Partial = {} +): [number, number][] { + return new PieceTable(docText).search(searchParams(pattern, overrides)); +} + +// Builds the per-match replacement text the panel would insert for every match +// of `params` in `docText`, through the same positionAt/offsetAt/getLineText +// plumbing searchPanel.ts wires up. +function replacementsFor(docText: string, params: SearchParams): string[] { + const table = new PieceTable(docText); + return table.search(params).map(([start, end]) => + buildSearchReplacementText( + (offset) => table.positionAt(offset), + (position) => table.offsetAt(position), + (line) => table.getLineText(line), + params, + start, + end + ) + ); +} + +// Reference model for replace-all: one forward pass over the raw string, +// resuming AFTER each replacement so inserted text is never re-examined — +// the contract a scan-driven replace-all provides. +function forwardScanReplaceAll( + text: string, + query: string, + replacement: string +): string { + let out = ''; + let i = 0; + while (i < text.length) { + if (text.startsWith(query, i)) { + out += replacement; + i += query.length; + } else { + out += text[i]; + i++; + } + } + return out; +} + +interface ReplaceHostHarness { + textDocument: TextDocument; + queryInput: HTMLInputElement; + replaceInput: HTMLInputElement; + regexToggle: HTMLButtonElement; + replaceButton: HTMLButtonElement; + replaceAllButton: HTMLButtonElement; + scrolled: MatchRange[]; + appliedBatches: ResolvedTextEdit[][]; + matchesLabel(): string | null; + dispose(): void; +} + +// Mounts a SearchPanelWidget over a live document with a host wired the way +// editor.ts wires it: scrollToMatch moves a host-side selection (an offset +// pair), applyReplace actually writes the edits into the document, and +// onUpdate picks the first match at or after the selection start and scrolls +// to it. This is deliberately richer than the recording-only createWidget +// harness above — replace-progression semantics only show up when the +// document really changes underneath the panel. +function mountReplaceHost(contents: string): ReplaceHostHarness { + const dom = installDom(); + const textDocument = new TextDocument( + 'inmemory://replace-host', + contents + ); + const containerElement = document.createElement('div'); + document.body.appendChild(containerElement); + + let selection: MatchRange = [0, 0]; + const scrolled: MatchRange[] = []; + const appliedBatches: ResolvedTextEdit[][] = []; + + const scrollToMatch = (nextMatch: MatchRange) => { + selection = [nextMatch[0], nextMatch[1]]; + scrolled.push([nextMatch[0], nextMatch[1]]); + }; + + const widget = new SearchPanelWidget({ + textDocument, + containerElement, + defaultQuery: '', + mode: 'replace', + scrollToMatch, + applyReplace: (edits) => { + appliedBatches.push(edits); + textDocument.applyEdits( + edits.map((edit) => ({ + range: { + start: textDocument.positionAt(edit.start), + end: textDocument.positionAt(edit.end), + }, + newText: edit.text, + })) + ); + }, + onUpdate: (matches) => { + for (const match of matches) { + if (match[0] >= selection[0]) { + scrollToMatch(match); + return match; + } + } + return undefined; + }, + onClose: () => {}, + }); + + const panel = (selector: string) => + document.querySelector(`[data-search-panel] ${selector}`); + const buttons = (container: string) => + document.querySelectorAll( + `[data-search-panel] [data-${container}] button` + ); + + return { + textDocument, + queryInput: panel('input[data-search]') as HTMLInputElement, + replaceInput: panel('input[data-replace]') as HTMLInputElement, + regexToggle: buttons('search-toggles')[2], + replaceButton: buttons('replace-actions')[0], + replaceAllButton: buttons('replace-actions')[1], + scrolled, + appliedBatches, + matchesLabel: () => panel('[data-matches]')?.textContent ?? null, + dispose: () => { + widget.cleanup(); + containerElement.remove(); + dom.cleanup(); + }, + }; +} + +describe('regex replace capture references', () => { + test('numbered group references pull the captured text into the replacement', () => { + expect( + replacementsFor( + 'lily, fern', + searchParams('(\\w+), (\\w+)', { replaceText: '$2 & $1' }) + ) + ).toEqual(['fern & lily']); + }); + + test('$& injects the whole match and $$ escapes to one literal dollar', () => { + expect( + replacementsFor( + 'total 88 units', + searchParams('\\d+', { replaceText: '($&)$$' }) + ) + ).toEqual(['(88)$']); + // $$1 consumes the doubled dollar first, leaving a literal "$1" behind. + expect( + replacementsFor('apex', searchParams('(ap)(ex)', { replaceText: '$$1' })) + ).toEqual(['$1']); + }); + + test('group references the pattern never captured collapse to empty text', () => { + // DIVERGENCE: an implementation routing replacements through JS + // String.replace would leave "$0" and out-of-range references like "$9" + // as literal text. pierre's expandReplaceString resolves every $ + // token through match[n] ?? '', so $0 aliases the whole match and $9 + // becomes empty. + expect( + replacementsFor( + 'apex', + searchParams('(ap)(ex)', { replaceText: '$0|$9|$2' }) + ) + ).toEqual(['apex||ex']); + }); + + test('anchors and word boundaries inside the match expand normally', () => { + // ^, $, and \b context that coincides with the match edges survives the + // slice re-execution, so expansion works for these patterns. + expect( + replacementsFor( + 'stem', + searchParams('^(st)(em)$', { replaceText: '$2$1' }) + ) + ).toEqual(['emst']); + expect( + replacementsFor( + 'go north', + searchParams('\\b(n\\w+)', { replaceText: '[$1]' }) + ) + ).toEqual(['[north]']); + }); + + test('case-insensitive expansion reflects the document casing, not the pattern', () => { + expect( + replacementsFor( + 'Reef', + searchParams('(r)(eef)', { replaceText: '$1+$2', caseSensitive: false }) + ) + ).toEqual(['R+eef']); + }); + + test('literal (non-regex) mode passes dollar tokens through untouched', () => { + expect( + replacementsFor( + 'k1 k2', + searchParams('k1', { replaceText: '$&-$1', regex: false }) + ) + ).toEqual(['$&-$1']); + }); + + // KNOWN BUG: buildSearchReplacementText re-executes the pattern against only + // the matched slice; a lookbehind's context sits before the slice, so the + // re-execution finds nothing, falls back to the raw replaceText, and the + // literal "$1" is inserted into the document. + test.failing( + 'lookbehind context before the match still expands its captures', + () => { + expect( + replacementsFor( + 'k77', + searchParams('(?<=k)(\\d+)', { replaceText: 'n$1' }) + ) + ).toEqual(['n77']); + } + ); + + // KNOWN BUG: a lookahead's context sits after the matched slice, so the + // slice-only re-execution fails and the unexpanded replaceText is inserted. + test.failing( + 'lookahead context after the match still expands its captures', + () => { + expect( + replacementsFor( + 'run!', + searchParams('(\\w+)(?=!)', { replaceText: '<$1>' }) + ) + ).toEqual(['']); + } + ); + + // KNOWN BUG: on the matched slice the lookahead re-matches SHORTER than the + // original match (the trailing context character is part of the slice), the + // full-length guard rejects it, and the literal "[$&]" is inserted. + test.failing( + 'a lookahead that re-matches shorter on the slice still expands', + () => { + expect( + replacementsFor('ooo', searchParams('o+(?=o)', { replaceText: '[$&]' })) + ).toEqual(['[oo]']); + } + ); + + test('a pattern that matches nowhere leaves the document untouched', async () => { + const host = mountReplaceHost('gray goose'); + try { + await wait(0); + host.regexToggle.click(); + setInputValue(host.queryInput, 'swan(\\w)'); + setInputValue(host.replaceInput, '$1'); + + host.replaceButton.click(); + host.replaceAllButton.click(); + + expect(host.appliedBatches).toEqual([]); + expect(host.textDocument.getText()).toBe('gray goose'); + expect(host.matchesLabel()).toBe('No results'); + } finally { + host.dispose(); + } + }); +}); + +describe('zero-width-capable patterns terminate and skip empty matches', () => { + test('a starred pattern reports only its non-empty matches', () => { + // DIVERGENCE: a search that reported zero-length ranges would surface + // hits on lines without a match; pierre suppresses every empty match and + // only advances the scan past them, so bare-empty lines contribute + // nothing. + expect(findAll('brook\n\nmoon', 'o*')).toEqual([ + [2, 4], + [8, 10], + ]); + }); + + test('bare ^ and $ anchors report no matches at all', () => { + // DIVERGENCE: every match of a bare anchor is zero-length, and pierre + // never reports zero-length matches; the alternative convention is one + // empty range per row. + expect(findAll('ivy\nelm', '^')).toEqual([]); + expect(findAll('ivy\nelm', '$')).toEqual([]); + }); + + test('an empty alternation arm only ever surfaces the non-empty arm', () => { + expect(findAll('ame', 'm|')).toEqual([[1, 2]]); + }); + + test('an optional pattern skips empty positions across empty lines and doc edges', () => { + // Non-empty hits at the very first and very last offset are still found; + // the empty middle line and every empty match in between stay silent. + expect(findAll('d\n\nd', 'd?')).toEqual([ + [0, 1], + [3, 4], + ]); + }); + + test('^.*$ returns whole-line ranges and stays silent on empty lines', () => { + // Trailing newline: the empty final line yields no match either. + expect(findAll('fig\n\nrye\n', '^.*$')).toEqual([ + [0, 3], + [5, 8], + ]); + }); + + test('a starred group terminates on lines where it can only match empty', () => { + expect(findAll('axax\nbb\nax', '(?:ax)*')).toEqual([ + [0, 4], + [8, 10], + ]); + }); + + test('the empty-match advance steps over whole surrogate pairs', () => { + // advancePastEmptyMatch moves the scan by one code POINT. A pattern whose + // only non-empty alternative is a lone low surrogate can therefore never + // fire mid-pair: the scan lands on 0, 2, 4, ... never on offset 1 or 3. + expect(findAll('\u{1F600}\u{1F600}', '\uDE00|w*')).toEqual([]); + // The same alternation still finds a real character after an astral one. + expect(findAll('\u{1F600}w', '\uDE00|w*')).toEqual([[2, 3]]); + }); + + test('matches after astral characters land at UTF-16 offsets', () => { + expect(findAll('\u{1F600}w\u{1F600}ww', 'w+')).toEqual([ + [2, 3], + [5, 7], + ]); + }); + + test('an invalid pattern reports zero matches instead of throwing', () => { + // DIVERGENCE: a stricter contract would reject/throw on an unparseable + // pattern; pierre compiles inside try/catch and treats it as "no matches". + expect(findAll('text', '([')).toEqual([]); + }); +}); + +describe('anchored patterns on CRLF and mixed-EOL documents', () => { + test('$-anchored matches end before the \\r of each CRLF pair', () => { + const crlfDoc = 'oak\r\nelm\r\nfir'; + const table = new PieceTable(crlfDoc); + const hits = table.search(searchParams('\\w$')); + + expect(hits).toEqual([ + [2, 3], + [7, 8], + [12, 13], + ]); + for (const [start, end] of hits) { + // The matched range never covers EOL bytes... + expect(crlfDoc.slice(start, end)).not.toMatch(/[\r\n]/); + // ...and the end offset round-trips to the line's content end. + const endPosition = table.positionAt(end); + expect(endPosition.character).toBe(table.getLineLength(endPosition.line)); + expect(table.offsetAt(endPosition)).toBe(end); + } + }); + + test('^ matches at column 0 after CRLF, lone \\r, and \\n breaks alike', () => { + const mixedDoc = 'ash\r\nbay\rcedar\ndate'; + const table = new PieceTable(mixedDoc); + + expect(table.search(searchParams('^\\w+'))).toEqual([ + [0, 3], + [5, 8], + [9, 14], + [15, 19], + ]); + for (const [start] of table.search(searchParams('^\\w+'))) { + expect(table.positionAt(start).character).toBe(0); + } + // $ on the same document: every end offset stops short of its line break. + expect(table.search(searchParams('\\w$'))).toEqual([ + [2, 3], + [7, 8], + [13, 14], + [18, 19], + ]); + }); + + test('a trailing CRLF adds no phantom match on the empty final line', () => { + expect(findAll('app\r\n', 'p+$')).toEqual([[1, 3]]); + expect(findAll('app\r\n', '.$')).toEqual([[2, 3]]); + expect(findAll('app\r\n', '^')).toEqual([]); + }); +}); + +describe('replace progression through the search panel', () => { + test('replacing with text containing the query steps past the insertion', async () => { + const host = mountReplaceHost('ash elm ash'); + try { + await wait(0); + setInputValue(host.queryInput, 'ash'); + expect(host.scrolled).toEqual([[0, 3]]); + expect(host.matchesLabel()).toBe('1 of 2'); + + setInputValue(host.replaceInput, 'ashash'); + pressKey(host.replaceInput, 'Enter'); + + expect(host.appliedBatches).toEqual([ + [{ start: 0, end: 3, text: 'ashash' }], + ]); + expect(host.textDocument.getText()).toBe('ashash elm ash'); + // The caret collapses at the END of the 6-char replacement ([6, 6], not + // [3, 3] where the old match ended), and the next current match is the + // one past the insertion — [11, 14], never [3, 6] which sits entirely + // inside the text this replace just produced. + expect(host.scrolled).toEqual([ + [0, 3], + [6, 6], + [11, 14], + ]); + expect(host.matchesLabel()).toBe('3 of 3'); + + pressKey(host.replaceInput, 'Enter'); + expect(host.textDocument.getText()).toBe('ashash elm ashash'); + expect(host.scrolled.at(-1)).toEqual([17, 17]); + // Nothing remains at or after the caret: the panel shows a bare count. + expect(host.matchesLabel()).toBe('4 results'); + } finally { + host.dispose(); + } + }); + + test('replace wraps to the document top once no match remains past the caret', async () => { + // DIVERGENCE: a scan-driven replace would be one forward pass that stops + // at the buffer end. pierre's panel wraps around (interactive-search + // policy), so once forward matches are exhausted the next replace lands + // on the first match again — including matches produced by earlier + // replacements. Each step still edits a genuine current match. + const host = mountReplaceHost('ash elm ash'); + try { + await wait(0); + setInputValue(host.queryInput, 'ash'); + setInputValue(host.replaceInput, 'ashash'); + pressKey(host.replaceInput, 'Enter'); // 'ashash elm ash', caret past [3, 6] + pressKey(host.replaceInput, 'Enter'); // 'ashash elm ashash', caret at doc end + + pressKey(host.replaceInput, 'Enter'); // wraps: replaces [0, 3] again + + expect(host.textDocument.getText()).toBe('ashashash elm ashash'); + expect(host.scrolled).toEqual([ + [0, 3], + [6, 6], + [11, 14], + [17, 17], + [0, 3], // the wrap target selected by the third replace + [6, 6], + [6, 9], + ]); + expect(host.matchesLabel()).toBe('3 of 5'); + } finally { + host.dispose(); + } + }); + + test('replacing with the empty string collapses the caret at the match start', async () => { + const host = mountReplaceHost('oak elm fir'); + try { + await wait(0); + setInputValue(host.queryInput, 'elm'); + setInputValue(host.replaceInput, ''); + pressKey(host.replaceInput, 'Enter'); + + expect(host.appliedBatches).toEqual([[{ start: 4, end: 7, text: '' }]]); + expect(host.textDocument.getText()).toBe('oak fir'); + expect(host.scrolled.at(-1)).toEqual([4, 4]); + expect(host.matchesLabel()).toBe('No results'); + } finally { + host.dispose(); + } + }); + + test('replace all over adjacency-prone runs equals one forward scan', async () => { + const contents = 'kkkkk\nkkk k'; + const host = mountReplaceHost(contents); + try { + await wait(0); + setInputValue(host.queryInput, 'kk'); + setInputValue(host.replaceInput, 'z'); + host.replaceAllButton.click(); + + expect(host.appliedBatches).toEqual([ + [ + { start: 0, end: 2, text: 'z' }, + { start: 2, end: 4, text: 'z' }, + { start: 6, end: 8, text: 'z' }, + ], + ]); + const expected = forwardScanReplaceAll(contents, 'kk', 'z'); + expect(expected).toBe('zzk\nzk k'); + expect(host.textDocument.getText()).toBe(expected); + } finally { + host.dispose(); + } + }); + + test('replace all with a query-containing replacement finishes in one pass', async () => { + const contents = 'ash ashash'; + const host = mountReplaceHost(contents); + try { + await wait(0); + setInputValue(host.queryInput, 'ash'); + setInputValue(host.replaceInput, 'ashash'); + host.replaceAllButton.click(); + + // One batch, built from the pre-replacement match set: the matches the + // replacements introduce are never themselves replaced. + expect(host.appliedBatches).toHaveLength(1); + const expected = forwardScanReplaceAll(contents, 'ash', 'ashash'); + expect(expected).toBe('ashash ashashashash'); + expect(host.textDocument.getText()).toBe(expected); + } finally { + host.dispose(); + } + }); + + test('replace all expands capture references per match', async () => { + const host = mountReplaceHost('id 7 and 305'); + try { + await wait(0); + host.regexToggle.click(); + setInputValue(host.queryInput, '(\\d+)'); + setInputValue(host.replaceInput, '#$1#'); + host.replaceAllButton.click(); + + expect(host.appliedBatches).toEqual([ + [ + { start: 3, end: 4, text: '#7#' }, + { start: 9, end: 12, text: '#305#' }, + ], + ]); + expect(host.textDocument.getText()).toBe('id #7# and #305#'); + } finally { + host.dispose(); + } + }); +}); + +// --------------------------------------------------------------------------- +// search-vs-reference fuzz +// --------------------------------------------------------------------------- + +// Deterministic LCG, same shape as the fuzz driver in editorPieceTable.test.ts. +function createRandom(seed: number): () => number { + let state = seed; + return () => { + state = (state * 1664525 + 1013904223) >>> 0; + return state / 0x100000000; + }; +} + +// Independent line-splitting oracle: \n, lone \r, and \r\n (one break) — the +// same policy as computeLineOffsets. +function oracleLineStarts(text: string): number[] { + const starts = [0]; + for (let i = 0; i < text.length; i++) { + const code = text.charCodeAt(i); + if (code === 10) { + starts.push(i + 1); + } else if (code === 13) { + if (i + 1 < text.length && text.charCodeAt(i + 1) === 10) { + i++; + } + starts.push(i + 1); + } + } + return starts; +} + +// String-based reference for PieceTable.search's documented contract: match +// line by line over break-stripped line text, drop zero-length matches, and +// map to document offsets. The whole-word check treats document edges and any +// charCode <= 32 as separators — equivalent to the production policy for the +// letters/digits/space/EOL alphabet the fuzz below sticks to. +function oracleSearchMatches( + text: string, + source: string, + wholeWord: boolean +): [number, number][] { + const out: [number, number][] = []; + const starts = oracleLineStarts(text); + const isSeparator = (ch: string | undefined) => + ch === undefined || ch.charCodeAt(0) <= 32; + + for (let line = 0; line < starts.length; line++) { + const spanEnd = line + 1 < starts.length ? starts[line + 1] : text.length; + let contentEnd = spanEnd; + while ( + contentEnd > starts[line] && + (text[contentEnd - 1] === '\n' || text[contentEnd - 1] === '\r') + ) { + contentEnd--; + } + const lineText = text.slice(starts[line], contentEnd); + const pattern = new RegExp(source, 'gm'); + let match: RegExpExecArray | null; + while ((match = pattern.exec(lineText)) !== null) { + if (match[0].length === 0) { + pattern.lastIndex = match.index + 1; + continue; + } + const start = starts[line] + match.index; + const end = start + match[0].length; + if ( + !wholeWord || + (isSeparator(text[start - 1]) && isSeparator(text[end])) + ) { + out.push([start, end]); + } + if (match.index === pattern.lastIndex) { + pattern.lastIndex++; + } + } + } + return out; +} + +// Anchored, character-class, zero-width-capable, lookahead, and whole-word +// probes — one of each family the search machinery special-cases. +const FUZZ_PRESETS: { source: string; wholeWord?: boolean }[] = [ + { source: '^[a-h]+' }, + { source: '[0-9]+$' }, + { source: '[aeiou][a-z]' }, + { source: 'e*' }, + { source: '[a-z]+(?=[0-9])' }, + { source: 'gap', wholeWord: true }, +]; + +function runSearchFuzz( + seed: number, + baseText: string, + inserts: readonly string[], + iterations: number +): void { + const random = createRandom(seed); + let text = baseText; + const table = new PieceTable(text); + + for (let i = 0; i < iterations; i++) { + if (random() < 0.6) { + const insert = inserts[Math.floor(random() * inserts.length)]; + const offset = Math.floor(random() * (text.length + 1)); + table.insert(insert, offset); + text = text.slice(0, offset) + insert + text.slice(offset); + } else { + const offset = Math.floor(random() * (text.length + 1)); + const length = Math.floor(random() * 5); + table.delete(offset, length); + text = text.slice(0, offset) + text.slice(offset + length); + } + expect(table.getText()).toBe(text); + + for (const preset of FUZZ_PRESETS) { + const got = table.search( + searchParams(preset.source, { wholeWord: preset.wholeWord ?? false }) + ); + const want = oracleSearchMatches( + text, + preset.source, + preset.wholeWord ?? false + ); + expect(got).toEqual(want); + } + } +} + +describe('search agrees with a string-model oracle under random splices', () => { + test('100 seeded LF-only splices keep every preset pattern on the oracle', () => { + runSearchFuzz( + 0xa70e, + 'delta gap echo 12\nfox 345 gap\n\nhollow gap 6\nquiet end 78', + ['gap', 'e', '90', '\n', ' ', 'axe', ''], + 100 + ); + }); + + // KNOWN BUG: splices that split or form \r\n pairs across piece seams + // corrupt the piece-level line-break counts (breaks double-counted or + // missed — the root cause is pinned as directed repros in + // editorPieceTable.test.ts), so line starts drift and search reports + // shifted or missing ranges even though getText() stays correct. A replace + // driven by those ranges would edit the wrong bytes. + test.failing( + 'CR/LF-biased splices keep every preset pattern on the oracle', + () => { + runSearchFuzz( + 7, + 'delta gap echo 12\r\nfox 345 gap\n\nhollow gap 6\r\nquiet end 78', + ['gap', 'e', '90', '\n', '\r', '\r\n', ' ', ''], + 40 + ); + } + ); +}); diff --git a/packages/diffs/test/editorSelection.test.ts b/packages/diffs/test/editorSelection.test.ts index 9b2361bed..08fad511c 100644 --- a/packages/diffs/test/editorSelection.test.ts +++ b/packages/diffs/test/editorSelection.test.ts @@ -1,5 +1,9 @@ -import { describe, expect, test } from 'bun:test'; +import { afterAll, describe, expect, test } from 'bun:test'; +import { File } from '../src/components/File'; +import { DEFAULT_THEMES } from '../src/constants'; +import { Editor } from '../src/editor/editor'; +import { EditStack } from '../src/editor/editStack'; import { applyDeleteCharacterToSelections, applyDeleteHardLineForwardToSelections, @@ -37,7 +41,18 @@ import { } from '../src/editor/selection'; import { DirectionBackward } from '../src/editor/selection'; import { TextDocument } from '../src/editor/textDocument'; -import type { EditorSelection, SelectionDirection } from '../src/types'; +import type { ResolvedTextEdit } from '../src/editor/textDocument'; +import { disposeHighlighter } from '../src/highlighter/shared_highlighter'; +import type { + EditorSelection, + FileContents, + SelectionDirection, +} from '../src/types'; +import { installDom, wait } from './domHarness'; + +afterAll(async () => { + await disposeHighlighter(); +}); type MockNode = { nodeType: number; @@ -2863,3 +2878,1576 @@ describe('resolveSelectionCut', () => { }); }); }); + +// --------------------------------------------------------------------------- +// Consolidated selection/word-operation suites (migrated). +// --------------------------------------------------------------------------- + +function doc(text: string) { + return new TextDocument('inmemory://1', text, 'plain'); +} + +function caret(line: number, character: number): EditorSelection { + const position = { line, character }; + return { start: position, end: position, direction: DirectionNone }; +} + +// Flat single-line selection helper: every fixture below that uses it lives +// on line 0, so `character` doubles as the flat offset. +function sel( + startCharacter: number, + endCharacter: number, + direction: SelectionDirection = DirectionForward +): EditorSelection { + return { + start: { line: 0, character: startCharacter }, + end: { line: 0, character: endCharacter }, + direction, + }; +} + +// Runs one Backspace at the given selections and returns the selections that +// result, mutating `d` in place. +function backspace(d: ReturnType, selections: EditorSelection[]) { + return applyDeleteCharacterToSelections(d, selections, false).nextSelections; +} + +describe('backward delete over grapheme clusters', () => { + test('backspace removes a whole ZWJ family emoji without splitting the cluster', () => { + // DIVERGENCE: the conventional behavior peels one ZWJ component off the + // end per keystroke (family → couple → single person → empty, one + // Backspace each). pierre-fe steps by Intl.Segmenter grapheme clusters, + // so the entire family emoji is one unit and a single Backspace removes + // it all. Both policies agree on the invariant this regression was about: + // no keystroke may split a surrogate pair or strand a lone ZWJ/modifier + // in the buffer. + const family = '\u{1F469}‍\u{1F469}‍\u{1F466}‍\u{1F466}'; // 👩‍👩‍👦‍👦 + expect(family.length).toBe(11); // 4 surrogate pairs + 3 ZWJs + + const d = doc(`hi${family}!`); + // Caret between the family emoji and the trailing '!'. + const range = resolveDeleteCharacterRange(d, caret(0, 13), false); + expect(range).toEqual([ + { line: 0, character: 2 }, + { line: 0, character: 13 }, + ]); + + const next = backspace(d, [caret(0, 13)]); + expect(d.getText()).toBe('hi!'); + expect(next).toEqual([caret(0, 2)]); + }); + + test('backspace removes base emoji and skin-tone modifier as one unit', () => { + const thumbs = '\u{1F44D}\u{1F3FD}'; // 👍🏽 = base + Fitzpatrick modifier + expect(thumbs.length).toBe(4); + + const d = doc(`ok ${thumbs}`); + const range = resolveDeleteCharacterRange(d, caret(0, 7), false); + expect(range).toEqual([ + { line: 0, character: 3 }, + { line: 0, character: 7 }, + ]); + + const next = backspace(d, [caret(0, 7)]); + // One keystroke removes the modifier together with its base — the buffer + // never holds a bare modifier or half a surrogate pair. + expect(d.getText()).toBe('ok '); + expect(next).toEqual([caret(0, 3)]); + }); + + test('backspace steps over Thai combining marks one grapheme cluster at a time', () => { + // DIVERGENCE: the conventional behavior deliberately deletes one UTF-16 + // code unit per Backspace in combining-mark scripts (Thai users filed the + // regressions this pins because they expect to erase a tone/vowel mark + // without losing the base consonant; that fixture needs six keystrokes + // for six code units). pierre-fe deletes whole Intl.Segmenter grapheme + // clusters everywhere, so a base consonant and its attached marks always + // leave together. Never splitting a cluster also means no keystroke can + // strand a combining mark. + const thai = 'น้ำใจ'; // น + ◌้ + ◌ำ (one cluster), ใ, จ + expect(thai.length).toBe(5); + + const d = doc(thai); + let selections = [caret(0, 5)]; + + // จ is a single-unit cluster. + expect(resolveDeleteCharacterRange(d, selections[0], false)).toEqual([ + { line: 0, character: 4 }, + { line: 0, character: 5 }, + ]); + selections = backspace(d, selections); + expect(d.getText()).toBe('น้ำใ'); + + selections = backspace(d, selections); + expect(d.getText()).toBe('น้ำ'); + + // The remaining three code units are one cluster: base consonant plus two + // combining marks are removed by a single keystroke. + expect(resolveDeleteCharacterRange(d, selections[0], false)).toEqual([ + { line: 0, character: 0 }, + { line: 0, character: 3 }, + ]); + selections = backspace(d, selections); + expect(d.getText()).toBe(''); + expect(selections).toEqual([caret(0, 0)]); + }); +}); + +describe('select next occurrence with touching matches', () => { + test('finds a repeat that touches the current selection with zero gap', () => { + const d = doc('abcabc'); + const first = createSelection(0, 0, 0, 3, DirectionForward); + + // The second "abc" starts exactly where the selected one ends. It must be + // returned as the next match, not skipped as overlapping. + const next = findNexMatch(d, [first]); + expect(next).toEqual([ + first, + createSelection(0, 3, 0, 6, DirectionForward), + ]); + + // Both occurrences are now held; nothing is left to add. + expect(findNexMatch(d, next!)).toBeUndefined(); + }); + + test('repeated next-occurrence walks through touching matches across lines', () => { + const d = doc('rowrow\nrow\nrowrow'); + let selections: EditorSelection[] | undefined = [ + createSelection(0, 0, 0, 3, DirectionForward), + ]; + + const expected = [ + createSelection(0, 3, 0, 6, DirectionForward), // touching repeat on the same line + createSelection(1, 0, 1, 3, DirectionForward), + createSelection(2, 0, 2, 3, DirectionForward), + createSelection(2, 3, 2, 6, DirectionForward), // touching repeat on the last line + ]; + for (const added of expected) { + selections = findNexMatch(d, selections!); + expect(selections![selections!.length - 1]).toEqual(added); + } + expect(selections!.length).toBe(5); + + // All five occurrences selected — the next request finds nothing new. + expect(findNexMatch(d, selections!)).toBeUndefined(); + }); +}); + +describe('carets converging through a delete', () => { + test('carets that converge via backspace merge and type the next character once', () => { + // The regression this pins reproduces the double-insert users saw when + // merge-on-overlap is disabled; with merging on (like pierre-fe's + // always-on mergeOverlappingSelections) the two converged carets collapse + // to one and the typed character is inserted once. + const d = doc('name = ""'); + // One caret after each quote. + const afterDelete = backspace(d, [caret(0, 8), caret(0, 9)]); + + // Each caret deleted its own quote; both land on the same position. + expect(d.getText()).toBe('name = '); + expect(afterDelete).toEqual([caret(0, 7), caret(0, 7)]); + + const merged = mergeOverlappingSelections(afterDelete); + expect(merged).toEqual([caret(0, 7)]); + + // Type a single quote at the merged caret: it must appear exactly once. + const { nextSelections } = applyTextChangeToSelections(d, merged, { + start: 7, + end: 7, + text: "'", + }); + expect(d.getText()).toBe("name = '"); + expect(nextSelections).toEqual([caret(0, 8)]); + }); +}); + +describe('auto-surround next to an astral character', () => { + test('surrounding a selection just before an emoji survives undo intact', () => { + // The owl is a surrogate pair at characters 1-2; the wrapped selection is + // the double quote at character 0, so the inserted closing quote lands + // immediately before the high surrogate. + const d = doc('"🦉"'); + const selections = [createSelection(0, 0, 0, 1, DirectionForward)]; + + const texts = getAutoSurroundReplacementTexts(d, selections, "'"); + expect(texts).toEqual(["'\"'"]); + + const { nextSelections } = applyTextReplaceToSelections( + d, + selections, + texts! + ); + expect(d.getText()).toBe('\'"\'🦉"'); + // The originally selected text stays selected inside the new pair. + expect(nextSelections).toEqual([ + createSelection(0, 1, 0, 2, DirectionForward), + ]); + + // Undo must restore the buffer byte-for-byte — the emoji is not mangled. + expect(d.canUndo).toBe(true); + d.undo(); + expect(d.getText()).toBe('"🦉"'); + + // And the round trip keeps working in both directions. + d.redo(); + expect(d.getText()).toBe('\'"\'🦉"'); + d.undo(); + expect(d.getText()).toBe('"🦉"'); + }); + + test('surrounding a selection just after an emoji survives undo intact', () => { + // Mirror case: the wrapped selection is the closing quote at character 3, + // so the inserted opening bracket lands immediately after the low + // surrogate. + const d = doc('"🦉"'); + const selections = [createSelection(0, 3, 0, 4, DirectionForward)]; + + const texts = getAutoSurroundReplacementTexts(d, selections, '('); + expect(texts).toEqual(['(")']); + + const { nextSelections } = applyTextReplaceToSelections( + d, + selections, + texts! + ); + expect(d.getText()).toBe('"🦉(")'); + expect(nextSelections).toEqual([ + createSelection(0, 4, 0, 5, DirectionForward), + ]); + + d.undo(); + expect(d.getText()).toBe('"🦉"'); + }); +}); + +// Word-granularity segments the runtime's ICU reports as word-like for a +// fixture. Used to gate segmenter-side pins: isWordLike classification +// varies across ICU builds (dictionary-based CJK segmentation especially), +// so each pin runs only where the runtime agrees with the segmentation our +// dev and CI environments ship, and skips visibly elsewhere. +function wordLikeSegments(text: string): string[] { + return [ + ...new Intl.Segmenter(undefined, { granularity: 'word' }).segment(text), + ] + .filter((seg) => seg.isWordLike === true) + .map((seg) => seg.segment); +} + +// Explicit escapes so source normalization (NFC/NFD) can never change the +// fixture: a ZWJ family sequence (7 code points, 11 UTF-16 units) and a +// baby emoji with a Fitzpatrick skin-tone modifier (2 code points, 4 units). +const FAMILY = '\u{1F468}\u200D\u{1F469}\u200D\u{1F467}\u200D\u{1F466}'; // 👨‍👩‍👧‍👦 +const TONED_BABY = '\u{1F476}\u{1F3FE}'; // 👶🏾 + +describe('word delete vs word select on CJK and mixed-script runs', () => { + // Both halves of this describe block pin an internal inconsistency on + // purpose: deleteWordBackward's classifier groups every contiguous + // \p{Alphabetic} grapheme into ONE run (Han/Hiragana/Katakana are all + // Alphabetic), while double-click word expansion uses Intl.Segmenter and + // splits the very same text into words. The tests make the inconsistency + // visible; they do not pick a winner. + + test('delete word backward swallows an unbroken Chinese run in one stroke', () => { + // DIVERGENCE: the conventional behavior segments CJK per-word only when + // word-segmenter locales are explicitly configured (and then Ctrl+Backspace + // removes one segment at a time); pierre-fe's delete-word classifier + // treats the whole Alphabetic run as one word, so a single stroke deletes + // the entire sentence — and this disagrees with pierre-fe's own + // double-click segmentation below. + const d = doc('你好世界'); + const { nextSelections } = applyDeleteWordBackwardToSelections(d, [ + caret(0, 4), + ]); + expect(d.getText()).toBe(''); + expect(nextSelections).toEqual([caret(0, 0)]); + }); + + const segmenterSplitsChineseRun = + wordLikeSegments('你好世界').join('|') === '你好|世界'; + + test.skipIf(!segmenterSplitsChineseRun)( + 'double-click word expansion splits the same Chinese run into segments', + () => { + // DIVERGENCE: the conventional default (no word-segmenter locales + // configured) selects the whole CJK run on double-click; pierre-fe + // always runs Intl.Segmenter, so the exact text deleteWordBackward + // treats as one word splits in two here. + const d = doc('你好世界'); + expect(expandCollapsedSelectionToWord(d, caret(0, 2))).toEqual({ + start: { line: 0, character: 0 }, + end: { line: 0, character: 2 }, + direction: DirectionForward, + }); + expect(expandCollapsedSelectionToWord(d, caret(0, 4))).toEqual({ + start: { line: 0, character: 2 }, + end: { line: 0, character: 4 }, + direction: DirectionForward, + }); + } + ); + + test('delete word backward swallows a whole Japanese sentence in one stroke', () => { + // DIVERGENCE: Hiragana and Han are both \p{Alphabetic}, so the classifier + // sees one uninterrupted word run across the whole sentence. The + // conventional behavior, with segmentation enabled, stops at each + // particle/word boundary. + const d = doc('私は猫が好き'); + const { nextSelections } = applyDeleteWordBackwardToSelections(d, [ + caret(0, 6), + ]); + expect(d.getText()).toBe(''); + expect(nextSelections).toEqual([caret(0, 0)]); + }); + + const segmenterIsolatesTheNoun = + wordLikeSegments('私は猫が好き').includes('猫'); + + test.skipIf(!segmenterIsolatesTheNoun)( + 'double-click word expansion segments the same Japanese sentence', + () => { + // DIVERGENCE: Intl.Segmenter (dictionary-based, engine/ICU dependent) + // isolates 猫 as its own word here, while deleteWordBackward above + // erases the entire sentence as a single unit. + const d = doc('私は猫が好き'); + expect(expandCollapsedSelectionToWord(d, caret(0, 3))).toEqual({ + start: { line: 0, character: 2 }, + end: { line: 0, character: 3 }, + direction: DirectionForward, + }); + } + ); + + test('delete word backward swallows a mixed Latin-Katakana run in one stroke', () => { + // DIVERGENCE: Latin letters and Katakana are both \p{Alphabetic}, so the + // script boundary inside "helloワールド" is invisible to the delete-word + // classifier and one stroke removes both halves. + const d = doc('helloワールド'); + const { nextSelections } = applyDeleteWordBackwardToSelections(d, [ + caret(0, 9), + ]); + expect(d.getText()).toBe(''); + expect(nextSelections).toEqual([caret(0, 0)]); + }); + + const segmenterSplitsKatakanaRun = + wordLikeSegments('helloワールド').join('|') === 'hello|ワールド'; + + test.skipIf(!segmenterSplitsKatakanaRun)( + 'double-click word expansion splits the mixed run at the script boundary', + () => { + // DIVERGENCE: Intl.Segmenter breaks "helloワールド" at the + // Latin/Katakana boundary, so double-click selects only one script's + // half while deleteWordBackward above removes both in a single stroke. + const d = doc('helloワールド'); + expect(expandCollapsedSelectionToWord(d, caret(0, 2))).toEqual({ + start: { line: 0, character: 0 }, + end: { line: 0, character: 5 }, + direction: DirectionForward, + }); + expect(expandCollapsedSelectionToWord(d, caret(0, 7))).toEqual({ + start: { line: 0, character: 5 }, + end: { line: 0, character: 9 }, + direction: DirectionForward, + }); + } + ); + + test('delete word backward treats Latin, Han, and digits as one run', () => { + // DIVERGENCE: the classifier lumps \p{Alphabetic}, \p{Number}, and _ into + // the same class, so accented Latin + Han + digits form one deletable + // run. + const d = doc('naïve東京42'); // "naïve東京42", 9 UTF-16 units + const { nextSelections } = applyDeleteWordBackwardToSelections(d, [ + caret(0, 9), + ]); + expect(d.getText()).toBe(''); + expect(nextSelections).toEqual([caret(0, 0)]); + }); + + // Intl.Segmenter's isWordLike classification varies across ICU builds + // (whether bare digit runs and Han segments count as word-like differs + // between engines and platforms). Gate the segmenter-side pin on the + // runtime agreeing with the segmentation our dev and CI environments ship, + // so a divergent ICU skips visibly instead of going red. + const segmenterSplitsMixedRun = + wordLikeSegments('naïve東京42').join('|') === 'naïve|東京|42'; + + test.skipIf(!segmenterSplitsMixedRun)( + 'double-click word expansion splits Latin, Han, and digits into three words', + () => { + // DIVERGENCE: the same string that deleteWordBackward erases whole + // yields three distinct double-click words (Latin, Han, digits). + const d = doc('naïve東京42'); + expect(expandCollapsedSelectionToWord(d, caret(0, 2))).toEqual({ + start: { line: 0, character: 0 }, + end: { line: 0, character: 5 }, + direction: DirectionForward, + }); + expect(expandCollapsedSelectionToWord(d, caret(0, 6))).toEqual({ + start: { line: 0, character: 5 }, + end: { line: 0, character: 7 }, + direction: DirectionForward, + }); + expect(expandCollapsedSelectionToWord(d, caret(0, 8))).toEqual({ + start: { line: 0, character: 7 }, + end: { line: 0, character: 9 }, + direction: DirectionForward, + }); + } + ); +}); + +describe('word delete around emoji and grapheme clusters', () => { + // Probed against the current implementation: every case below removes whole + // grapheme clusters — no surrogate halves, orphan ZWJs, lone modifiers, or + // stranded combining marks are ever left behind — so these pin the coherent + // current behavior rather than flagging bugs. + + test('deletes a lone emoji as its own run without splitting the surrogate pair', () => { + const d = doc('word🎉'); + const { nextSelections } = applyDeleteWordBackwardToSelections(d, [ + caret(0, 6), + ]); + expect(d.getText()).toBe('word'); + expect(nextSelections).toEqual([caret(0, 4)]); + }); + + test('stops a word delete at an adjacent emoji and leaves it intact', () => { + const d = doc('x 🎉party'); + const { nextSelections } = applyDeleteWordBackwardToSelections(d, [ + caret(0, 9), + ]); + expect(d.getText()).toBe('x 🎉'); + expect(nextSelections).toEqual([caret(0, 4)]); + }); + + test('deletes a ZWJ family emoji as one grapheme cluster', () => { + const d = doc(`crew ${FAMILY}`); + const { nextSelections } = applyDeleteWordBackwardToSelections(d, [ + caret(0, 5 + FAMILY.length), + ]); + expect(d.getText()).toBe('crew '); + expect(nextSelections).toEqual([caret(0, 5)]); + }); + + test('deletes only the ZWJ cluster when it directly follows a word', () => { + const d = doc(`name${FAMILY}`); + const { nextSelections } = applyDeleteWordBackwardToSelections(d, [ + caret(0, 4 + FAMILY.length), + ]); + expect(d.getText()).toBe('name'); + expect(nextSelections).toEqual([caret(0, 4)]); + }); + + test('deletes a word without disturbing a preceding ZWJ cluster', () => { + const d = doc(`${FAMILY}crew`); + const { nextSelections } = applyDeleteWordBackwardToSelections(d, [ + caret(0, FAMILY.length + 4), + ]); + expect(d.getText()).toBe(FAMILY); + expect(nextSelections).toEqual([caret(0, FAMILY.length)]); + }); + + test('deletes a skin-tone modified emoji together with its base', () => { + const d = doc(`hug${TONED_BABY}`); + const { nextSelections } = applyDeleteWordBackwardToSelections(d, [ + caret(0, 3 + TONED_BABY.length), + ]); + expect(d.getText()).toBe('hug'); + expect(nextSelections).toEqual([caret(0, 3)]); + }); + + test('removes a word containing a combining mark without leaving an orphan mark', () => { + // "mañana" in decomposed form: the ñ is n + U+0303 combining tilde. + const d = doc('mañana'); + const { nextSelections } = applyDeleteWordBackwardToSelections(d, [ + caret(0, 7), + ]); + expect(d.getText()).toBe(''); + expect(nextSelections).toEqual([caret(0, 0)]); + }); + + test('removes a word ending in a combining-mark cluster in one piece', () => { + // "go piña" in decomposed form; the final cluster is n + U+0303 + a. + const d = doc('go piña'); + const { nextSelections } = applyDeleteWordBackwardToSelections(d, [ + caret(0, 8), + ]); + expect(d.getText()).toBe('go '); + expect(nextSelections).toEqual([caret(0, 3)]); + }); +}); + +describe('multi-cursor word delete across shifting line numbers', () => { + test('joins lines at one caret while remapping a second caret on a lower line', () => { + // Caret 1 sits at column 0 of line 1, so its delete consumes the newline + // after "alpha"; caret 2 deletes the word on line 2, which becomes line 1. + const d = doc('alpha\nbravo\ncharlie'); + const { nextSelections, change } = applyDeleteWordBackwardToSelections(d, [ + caret(1, 0), + caret(2, 7), + ]); + expect(change).toBeDefined(); + expect(d.getText()).toBe('alphabravo\n'); + expect(nextSelections).toEqual([caret(0, 5), caret(1, 0)]); + }); + + test('word delete on a shifted line lands mid-line after an upstream join', () => { + // The second caret deletes only the trailing word, so its remapped caret + // must land mid-line on the renumbered line, not at column 0. + const d = doc('first\nsecond\nthird word'); + const { nextSelections } = applyDeleteWordBackwardToSelections(d, [ + caret(1, 0), + caret(2, 10), + ]); + expect(d.getText()).toBe('firstsecond\nthird '); + expect(nextSelections).toEqual([caret(0, 5), caret(1, 6)]); + }); +}); + +describe('word delete at column zero preserves whitespace byte-for-byte', () => { + test('keeps the joined line leading spaces intact', () => { + // Joining must remove exactly the newline: the four leading spaces on the + // second line survive untouched, with no collapse to a single space. + const d = doc('first line stops.\n indented next'); + const { nextSelections } = applyDeleteWordBackwardToSelections(d, [ + caret(1, 0), + ]); + expect(d.getText()).toBe('first line stops. indented next'); + expect(nextSelections).toEqual([caret(0, 17)]); + }); + + test('keeps leading tabs intact when joining', () => { + const d = doc('top\n\t\tkeep tabs'); + const { nextSelections } = applyDeleteWordBackwardToSelections(d, [ + caret(1, 0), + ]); + expect(d.getText()).toBe('top\t\tkeep tabs'); + expect(nextSelections).toEqual([caret(0, 3)]); + }); + + test('keeps trailing spaces on the surviving line intact when joining', () => { + const d = doc('padded out \nnext'); + const { nextSelections } = applyDeleteWordBackwardToSelections(d, [ + caret(1, 0), + ]); + expect(d.getText()).toBe('padded out next'); + expect(nextSelections).toEqual([caret(0, 13)]); + }); +}); + +// Applies `edits` (resolved, pre-edit offsets) to a fresh document built from +// `preText` and returns the post-edit document, ready to be handed to +// remapSelectionsAfterEdits. Going through applyEdits keeps the fixture honest: +// the expected post text asserted in each test is produced by the real edit +// path, not by hand-splicing. +function applyBatch(preText: string, edits: ResolvedTextEdit[]) { + const pre = doc(preText); + const post = doc(preText); + post.applyEdits( + edits.map((edit) => ({ + range: { + start: pre.positionAt(edit.start), + end: pre.positionAt(edit.end), + }, + newText: edit.text, + })) + ); + return post; +} + +// Remaps one selection, given as a pre-edit [start, end] offset pair, through +// `edits`. All fixtures are single-line, so the returned offsets are the +// post-edit character columns. +function remapPair( + preText: string, + pair: readonly [number, number], + edits: ResolvedTextEdit[], + direction: SelectionDirection = DirectionNone +) { + const pre = doc(preText); + const post = applyBatch(preText, edits); + const selection: EditorSelection = { + start: pre.positionAt(pair[0]), + end: pre.positionAt(pair[1]), + direction, + }; + const [result] = remapSelectionsAfterEdits(post, [selection], [pair], edits); + return { + post, + result, + offsets: [post.offsetAt(result.start), post.offsetAt(result.end)] as const, + }; +} + +describe('remap through replacements', () => { + test('caret at the start boundary of a replaced range lands after the replacement', () => { + // DIVERGENCE: the conventional behavior maps a position sitting exactly + // at the start of a replaced span back to the span's start regardless of + // which side it's associated with (a position at that boundary never + // moves through the replacement). pierre-fe's remapOffsetThroughEdits + // applies uniform right gravity to every offset at or after an edit's + // start — a documented policy on the function — so the caret lands just + // AFTER the replacement text instead. Disorienting by that other + // convention, but the caret stays at a valid buffer position and the + // policy matches the typing case (text inserted at the caret pushes the + // caret past it); the main suite already pins the interior-caret variant + // of the same rule. + const { post, offsets, result } = remapPair( + 'papaya mango salad', + [7, 7], // caret exactly at the 'm' of the replaced word + [{ start: 7, end: 12, text: 'fig' }] + ); + expect(post.getText()).toBe('papaya fig salad'); + // The conventional behavior would report 7 (the replacement start); + // pierre reports 10, after 'fig'. + expect(offsets).toEqual([10, 10]); + expect(result.direction).toBe(DirectionNone); + }); + + test('carets at the start and end boundaries of one replacement converge after it', () => { + // DIVERGENCE (same right-gravity policy as above): the start-boundary + // caret goes through the "inside the edit" branch and the end-boundary + // caret through the "past the edit" delta branch, yet both come out at + // the same offset — the two sides of a replacement are not kept apart + // the way an assoc-aware mapping convention keeps them ([start -> start, + // end -> after]). + const edits: ResolvedTextEdit[] = [{ start: 4, end: 9, text: 'DOWN' }]; + const preText = 'shutproof latch'; + const atStart = remapPair(preText, [4, 4], edits); + const atEnd = remapPair(preText, [9, 9], edits); + expect(atStart.post.getText()).toBe('shutDOWN latch'); + expect(atStart.offsets).toEqual([8, 8]); + expect(atEnd.offsets).toEqual([8, 8]); + }); + + test('caret between two adjacent replacements lands after the second one', () => { + // DIVERGENCE: the conventional behavior keeps a caret at the seam of two + // touching replacements exactly at that seam (regardless of association + // side). pierre-fe's right-gravity branch treats offset == start of the + // second replacement as "inside" it, so the caret is carried past the + // second replacement's inserted text. Coherent (valid offset, same + // documented policy), but the caret does not stay between the two spans. + const { post, offsets } = remapPair( + 'ppqqrr', + [2, 2], // caret at the seam between the two replaced spans + [ + { start: 0, end: 2, text: '11' }, + { start: 2, end: 4, text: '2233' }, + ] + ); + expect(post.getText()).toBe('112233rr'); + // The conventional behavior would report 2 (the seam, unchanged by the + // equal-length first replacement); pierre reports 6, after '2233'. + expect(offsets).toEqual([6, 6]); + }); +}); + +describe('insertion at range-selection boundaries', () => { + const preText = 'stormcloud'; + // Selection over offsets [3, 7] — the letters 'rmcl'. + const pair: [number, number] = [3, 7]; + + test('insertion exactly at the selection end is absorbed into the selection', () => { + // DIVERGENCE: the conventional behavior maps a non-empty range's edges + // with outward bias (excluding insertions at either edge), so an + // insertion touching either boundary is never absorbed and the selected + // text stays the same. pierre-fe remaps both edges with the same right + // gravity, so an insertion exactly at the END boundary lands inside the + // selection and grows it. + const edits: ResolvedTextEdit[] = [{ start: 7, end: 7, text: '__' }]; + + const forward = remapPair(preText, pair, edits, DirectionForward); + expect(forward.post.getText()).toBe('stormcl__oud'); + expect(forward.offsets).toEqual([3, 9]); // 'rmcl__' — the insert is inside + expect(forward.result.direction).toBe(DirectionForward); + + const backward = remapPair(preText, pair, edits, DirectionBackward); + expect(backward.offsets).toEqual([3, 9]); + expect(backward.result.direction).toBe(DirectionBackward); + }); + + test('insertion exactly at the selection start shifts the selection without absorbing', () => { + // The other half of the asymmetry: at the START boundary the same right + // gravity pushes the start edge past the inserted text, so the whole + // selection slides right and keeps covering exactly the original letters. + // (This half agrees with the outward-bias convention of mapping the + // `from` edge.) + const edits: ResolvedTextEdit[] = [{ start: 3, end: 3, text: '__' }]; + + const forward = remapPair(preText, pair, edits, DirectionForward); + expect(forward.post.getText()).toBe('sto__rmcloud'); + expect(forward.offsets).toEqual([5, 9]); // still exactly 'rmcl' + expect(forward.post.getTextSlice(5, 9)).toBe('rmcl'); + expect(forward.result.direction).toBe(DirectionForward); + + const backward = remapPair(preText, pair, edits, DirectionBackward); + expect(backward.offsets).toEqual([5, 9]); + expect(backward.result.direction).toBe(DirectionBackward); + }); +}); + +describe('remap through deletions', () => { + // Deleting offsets [3, 7) — the letters 'rotc' of 'carrotcake'. + const preText = 'carrotcake'; + const edits: ResolvedTextEdit[] = [{ start: 3, end: 7, text: '' }]; + + test('carets at deletion start, strictly inside, and at deletion end all converge to the deletion start', () => { + // Matches the conventional plain mapping behavior (no map-mode variants): + // every position touching the deleted span collapses to where the span + // used to begin. That convention can still distinguish the three via + // explicit tracking modes; pierre-fe has no map-mode equivalent, so plain + // convergence is the whole contract. + const atStart = remapPair(preText, [3, 3], edits); + const inside = remapPair(preText, [5, 5], edits); + const atEnd = remapPair(preText, [7, 7], edits); + + expect(atStart.post.getText()).toBe('carake'); + expect(atStart.offsets).toEqual([3, 3]); + expect(inside.offsets).toEqual([3, 3]); + expect(atEnd.offsets).toEqual([3, 3]); + }); + + test('a backward selection exactly spanning the deleted range collapses to a direction-none caret', () => { + // Both edges converge to the deletion start, and + // createSelectionFromAnchorAndFocusOffsets re-derives direction from the + // remapped offsets — equal anchor and focus must yield DirectionNone, not a + // stale backward direction on a zero-length range. + const { offsets, result } = remapPair( + preText, + [3, 7], + edits, + DirectionBackward + ); + expect(offsets).toEqual([3, 3]); + expect(result.direction).toBe(DirectionNone); + }); +}); + +describe('remap through multi-edit batches', () => { + test('caret after an insert + delete + replace batch accumulates every delta', () => { + // Digits/letters make the offsets self-documenting: the caret sits on 'E' + // (offset 14) and must still sit on 'E' after all three edits before it. + const preText = '0123456789ABCDEF'; + const { post, offsets } = remapPair( + preText, + [14, 14], + [ + { start: 2, end: 2, text: '+++' }, // insert, +3 + { start: 5, end: 7, text: '' }, // delete '56', -2 + { start: 9, end: 11, text: 'WXYZ' }, // replace '9A', +2 + ] + ); + expect(post.getText()).toBe('01+++23478WXYZBCDEF'); + expect(offsets).toEqual([17, 17]); // 14 + 3 - 2 + 2 + expect(post.getTextSlice(17, 18)).toBe('E'); + }); + + test('edits must be sorted ascending by start: unsorted input silently drops earlier edits', () => { + // DIVERGENCE / contract pin: the conventional behavior accepts change + // specs in any order and normalizes them internally, so every change is + // always seen regardless of input order. pierre-fe's remap walks the edit + // array once and stops at the first edit whose start lies past the + // offset — a documented precondition ("sorted ascending and + // non-overlapping" on remapOffsetThroughEdits). When a caller violates + // it, edits listed after the early-exit point are silently ignored, even + // though they sit before the offset. This test pins the precondition by + // contrasting sorted and unsorted calls over the same batch; it is the + // caller's job to sort, not corruption inside the remap. + const preText = '0123456789ABCDEF'; + const edits: ResolvedTextEdit[] = [ + { start: 0, end: 0, text: 'YY' }, + { start: 10, end: 10, text: 'XX' }, + ]; + + const sorted = remapPair(preText, [5, 5], edits); + expect(sorted.post.getText()).toBe('YY0123456789XXABCDEF'); + // Only the insert at 0 precedes the caret: 5 + 2. + expect(sorted.offsets).toEqual([7, 7]); + + // Same batch listed descending: the walk sees the edit at 10 first, + // breaks (5 < 10), and never applies the insert at 0 — the caret keeps + // its stale pre-edit offset. + const unsorted = remapPair(preText, [5, 5], [edits[1], edits[0]]); + expect(unsorted.post.getText()).toBe('YY0123456789XXABCDEF'); + expect(unsorted.offsets).toEqual([5, 5]); + }); +}); + +describe('caret at the shared boundary of touching ranges', () => { + test('boundary caret is absorbed into the left neighbor and the ranges stay separate', () => { + // Two non-empty ranges touch at character 10 with a caret sitting exactly + // on the seam. Non-empty touching ranges never merge with each other, but + // the caret intersects both; it must be absorbed exactly once. Both + // pierre-fe and the conventional behavior fold it into the LEFT range + // (8-10): the caret's character equals the left range's end, and the + // single merge pass reaches it while the left range is still current. + const merged = mergeOverlappingSelections([ + sel(8, 10), + caret(0, 10), + sel(10, 12), + ]); + expect(merged).toEqual([ + // The caret was the latest of the merged pair, so direction is + // re-derived from its side: the caret sits at the merged range's end, + // making the result forward. + sel(8, 10, DirectionForward), + sel(10, 12, DirectionForward), + ]); + }); + + test('absorbing the boundary caret overrides a backward left neighbor to forward', () => { + // Same geometry with a backward 8-10 range. The caret is the later entry + // in the merged pair, so its (recomputed) direction wins: the caret lands + // at the merged end => forward. The conventional behavior derives the + // same answer — the later range's anchor/head order decides, and a + // cursor is never backward. + const merged = mergeOverlappingSelections([ + sel(8, 10, DirectionBackward), + caret(0, 10), + sel(10, 12), + ]); + expect(merged).toEqual([ + sel(8, 10, DirectionForward), + sel(10, 12, DirectionForward), + ]); + }); +}); + +describe('normalization stress from scrambled input', () => { + test('one range transitively swallows contained ranges while touching ranges stay separate', () => { + // Eight shuffled ranges. 0-6 contains 3-4 and 4-5 outright; 6-7 touches + // 0-6's end but is non-empty, so it stays separate (as do 7-8 and 13-14 + // against their neighbors). 9-13 contains 10-12. Five ranges survive: + // {0-6, 6-7, 7-8, 9-13, 13-14} — the same set the conventional + // normalization produces. + const merged = mergeOverlappingSelections([ + sel(10, 12), // index 0: swallowed by 9-13 + sel(6, 7), // index 1 + sel(4, 5), // index 2: swallowed by 0-6 + sel(3, 4), // index 3: swallowed by 0-6 + sel(0, 6), // index 4 + sel(7, 8), // index 5 + sel(9, 13), // index 6 + sel(13, 14), // index 7 + ]); + // DIVERGENCE: the conventional behavior returns ranges re-sorted by + // position (0/6,6/7,7/8,9/13,13/14) and tracks the primary via a separate + // index. pierre-fe's primary selection is "last element of the array", + // so mergeOverlappingSelections restores the caller's original index + // order instead, with each merged group keeping the LATEST participating + // index: the 0-6 group keeps index 4, the 9-13 group keeps index 6. + // Hence 6-7 (index 1) precedes 0-6 (index 4) in the output. + expect(merged).toEqual([ + sel(6, 7), + sel(0, 6), + sel(7, 8), + sel(9, 13), + sel(13, 14), + ]); + }); +}); + +describe('per-range replacements of different lengths on one line', () => { + test('every later selection shifts by the cumulative length delta of preceding replacements', () => { + // Four single-character selections on one line, each replaced by a + // different-length string. Each replacement lands at its original offset + // plus the summed growth of everything before it on the same line. + const d = doc('a b c d'); + const selections = [sel(0, 1), sel(2, 3), sel(4, 5), sel(6, 7)]; + const texts = ['x', 'yy', 'zzz', 'wwww']; + + const { nextSelections } = applyTextReplaceToSelections( + d, + selections, + texts + ); + + expect(d.getText()).toBe('x yy zzz wwww'); + + // Cumulative deltas per slot: +0, +0, +1, +3. The conventional per-range + // remap reports the spans 0-1 / 2-4 / 5-8 / 9-13; pierre-fe collapses + // each result to a caret after the inserted text, i.e. at those spans' + // ends. + expect(nextSelections).toEqual([ + caret(0, 1), + caret(0, 4), + caret(0, 8), + caret(0, 13), + ]); + // The caret offsets are exactly originalStart + cumulativeDelta + inserted + // length. + let delta = 0; + for (let index = 0; index < selections.length; index++) { + const start = selections[index].start.character; + const inserted = texts[index].length; + expect(nextSelections[index].end.character).toBe( + start + delta + inserted + ); + delta += inserted - 1; // each replacement consumed one character + } + }); + + test('replacement texts stay paired with their selection when input is not in document order', () => { + // Same replacement set handed over in reverse document order: texts must + // travel with their selection, and results must come back in the caller's + // input order (not sorted document order). + const d = doc('a b c d'); + const { nextSelections } = applyTextReplaceToSelections( + d, + [sel(6, 7), sel(4, 5), sel(2, 3), sel(0, 1)], + ['wwww', 'zzz', 'yy', 'x'] + ); + + expect(d.getText()).toBe('x yy zzz wwww'); + expect(nextSelections).toEqual([ + caret(0, 13), + caret(0, 8), + caret(0, 4), + caret(0, 1), + ]); + }); +}); + +// --------------------------------------------------------------------------- +// Public Editor.setSelections scenarios: these need the real Editor, mounted +// through the same File-backed harness the editorPublicApi suite uses. +// --------------------------------------------------------------------------- + +async function waitForEditableContent( + container: HTMLElement +): Promise { + for (let attempt = 0; attempt < 20; attempt++) { + const content = container.shadowRoot?.querySelector('[data-content]'); + if ( + content instanceof HTMLElement && + (content.contentEditable === 'true' || + content.getAttribute('contenteditable') === 'true') + ) { + return content; + } + await wait(0); + } + + throw new Error('editor content did not become editable'); +} + +interface EditorFixture { + cleanup(): void; + editor: Editor; +} + +async function createEditorFixture(contents: string): Promise { + const dom = installDom(); + const fileContainer = document.createElement('div'); + document.body.appendChild(fileContainer); + + const file = new File({ + disableFileHeader: true, + theme: DEFAULT_THEMES, + }); + const editor = new Editor(); + const initialFile: FileContents = { name: 'selections.txt', contents }; + + file.render({ file: initialFile, fileContainer, forceRender: true }); + editor.edit(file); + await waitForEditableContent(fileContainer); + + return { + cleanup() { + editor.cleanUp(); + file.cleanUp(); + dom.cleanup(); + }, + editor, + }; +} + +describe('Editor.setSelections position clamping', () => { + test('positions past a line length or past the last line clamp instead of throwing', async () => { + // DIVERGENCE: a stricter contract would reject out-of-range selections + // with a RangeError; pierre-fe's setSelections routes every position + // through TextDocument.normalizePosition, clamping line to the last line + // and character to that line's length. Out-of-bounds input is accepted + // and the caret lands on real content. + const { cleanup, editor } = await createEditorFixture( + 'alpha\nbravo\ncharlie' + ); + try { + // Character overshoots line 1 ("bravo", length 5): clamps to the line + // end, keeping the line. + editor.setSelections([ + { + start: { line: 1, character: 99 }, + end: { line: 1, character: 99 }, + direction: 'none', + }, + ]); + expect(editor.getState().selections).toEqual([caret(1, 5)]); + + // Both line and character overshoot: the primary caret lands exactly at + // the document end ("charlie" is line 2, length 7). + editor.setSelections([ + { + start: { line: 99, character: 99 }, + end: { line: 99, character: 99 }, + direction: 'none', + }, + ]); + expect(editor.getState().selections).toEqual([caret(2, 7)]); + } finally { + cleanup(); + } + }); + + test('a range whose end overshoots the document clamps only the overshooting edge', async () => { + const { cleanup, editor } = await createEditorFixture( + 'alpha\nbravo\ncharlie' + ); + try { + editor.setSelections([ + { + start: { line: 0, character: 2 }, + end: { line: 99, character: 99 }, + direction: 'forward', + }, + ]); + // The valid start edge is untouched; the end edge clamps to doc end and + // the direction survives. + expect(editor.getState().selections).toEqual([ + { + start: { line: 0, character: 2 }, + end: { line: 2, character: 7 }, + direction: DirectionForward, + }, + ]); + } finally { + cleanup(); + } + }); +}); + +describe('Editor.setSelections with a reversed range', () => { + // KNOWN BUG: setSelections normalizes each position independently but never + // reorders the pair, so a selection whose start sits after its end is + // stored inverted (start 1:3 / end 0:2), violating the start <= end + // invariant that downstream code (offset resolution, rendering, merge) + // assumes. The conventional normalization of a range(3, 2) input resolves + // to from=2/to=3 with the backward flag; the equivalent here is swapping + // start/end and flipping the direction to backward, since the focus edge + // the caller supplied now sits first. + test.failing( + 'a start-after-end selection is stored with ordered edges and backward direction', + async () => { + const { cleanup, editor } = await createEditorFixture( + 'alpha\nbravo\ncharlie' + ); + try { + editor.setSelections([ + { + start: { line: 1, character: 3 }, + end: { line: 0, character: 2 }, + direction: 'forward', + }, + ]); + expect(editor.getState().selections).toEqual([ + { + start: { line: 0, character: 2 }, + end: { line: 1, character: 3 }, + direction: DirectionBackward, + }, + ]); + } finally { + cleanup(); + } + } + ); +}); + +// Splices `edits` (pre-edit offsets, sorted ascending, non-overlapping) into +// `text` at the string level. The seeded sweep below cross-checks this against +// the real applyEdits path on every random case, so the fixed fixtures can use +// it directly without losing honesty. +function spliceString(text: string, edits: readonly ResolvedTextEdit[]) { + let result = ''; + let consumed = 0; + for (const edit of edits) { + result += text.slice(consumed, edit.start) + edit.text; + consumed = edit.end; + } + return result + text.slice(consumed); +} + +// Remaps one single-line selection, given as pre-edit offsets, through `edits` +// and reports the post-edit offsets plus the re-derived direction. +function remapRange( + preText: string, + selStart: number, + selEnd: number, + direction: SelectionDirection, + edits: readonly ResolvedTextEdit[] +) { + const pre = doc(preText); + const postText = spliceString(preText, edits); + const post = doc(postText); + const selection: EditorSelection = { + start: pre.positionAt(selStart), + end: pre.positionAt(selEnd), + direction, + }; + const [next] = remapSelectionsAfterEdits( + post, + [selection], + [[selStart, selEnd]], + edits + ); + return { + post, + postText, + start: post.offsetAt(next.start), + end: post.offsetAt(next.end), + direction: next.direction, + }; +} + +// remapOffsetThroughEdits is module-private, so single offsets travel through +// remapSelectionsAfterEdits as a collapsed selection. The input selection's +// positions are never read by the remap (only its direction is), so a dummy +// caret suffices; `target` must already reflect `edits`. +function mapOffset( + target: TextDocument, + offset: number, + edits: readonly ResolvedTextEdit[] +): number { + const probe: EditorSelection = { + start: { line: 0, character: 0 }, + end: { line: 0, character: 0 }, + direction: DirectionNone, + }; + const [mapped] = remapSelectionsAfterEdits( + target, + [probe], + [[offset, offset]], + edits + ); + return target.offsetAt(mapped.start); +} + +// Deterministic 32-bit PRNG (mulberry32) so the seeded sweep is reproducible. +function seededRandom(seed: number): () => number { + let state = seed >>> 0; + return () => { + state = (state + 0x6d2b79f5) | 0; + let t = Math.imul(state ^ (state >>> 15), state | 1); + t = (t + Math.imul(t ^ (t >>> 7), t | 61)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 0x100000000; + }; +} + +describe('replacement overlapping one selection edge', () => { + test('start-edge overlap clips the selection start to the replacement end and shifts the end by the delta', () => { + // 'thicket' [8,15) is selected; the replacement covers 'r th' [6,10) — + // text before the selection plus its first two letters — and is one + // character shorter than what it removed. The conventional default + // marker bias and pierre's right gravity agree here: the start lands + // right after the new text and the end just absorbs the -1 length delta. + const preText = 'juniper thicket'; + const edits: ResolvedTextEdit[] = [{ start: 6, end: 10, text: 'y w' }]; + + const forward = remapRange(preText, 8, 15, DirectionForward, edits); + expect(forward.postText).toBe('junipey wicket'); + expect([forward.start, forward.end]).toEqual([9, 14]); + // Only the un-replaced tail of the original word stays selected. + expect(forward.post.getTextSlice(9, 14)).toBe('icket'); + expect(forward.direction).toBe(DirectionForward); + + const backward = remapRange(preText, 8, 15, DirectionBackward, edits); + expect([backward.start, backward.end]).toEqual([9, 14]); + expect(backward.direction).toBe(DirectionBackward); + }); + + test('end-edge overlap keeps the selection start and carries the end to the end of the new text', () => { + // 'lantern' [7,14) is selected; the replacement covers 'rn g' [12,16) — + // the word's last two letters plus text after it — and is one character + // longer. The start edge sits strictly before the edit so it never moves; + // the end edge rides to the end of the replacement, so the selection + // absorbs ALL of the new text (also the conventional default bias). + const preText = 'harbor lantern glow'; + const edits: ResolvedTextEdit[] = [{ start: 12, end: 16, text: '&&&&&' }]; + + const forward = remapRange(preText, 7, 14, DirectionForward, edits); + expect(forward.postText).toBe('harbor lante&&&&&low'); + expect([forward.start, forward.end]).toEqual([7, 17]); + expect(forward.post.getTextSlice(7, 17)).toBe('lante&&&&&'); + expect(forward.direction).toBe(DirectionForward); + + const backward = remapRange(preText, 7, 14, DirectionBackward, edits); + expect([backward.start, backward.end]).toEqual([7, 17]); + expect(backward.direction).toBe(DirectionBackward); + }); +}); + +describe('replacement surrounding the whole selection', () => { + test('a surrounding replacement collapses the selection to a caret after the new text and resets direction', () => { + // 'two' [4,7) is selected; the replacement ' two t' [3,9) swallows the + // selection on both sides. Both edges collapse to the offset just past the + // replacement text ('#' occupies [3,4), caret at 4), and because + // createSelectionFromAnchorAndFocusOffsets re-derives direction from the + // remapped offsets, a stale forward/backward direction cannot survive on + // the zero-length result — probed: it really does come back DirectionNone. + const preText = 'one two three'; + const edits: ResolvedTextEdit[] = [{ start: 3, end: 9, text: '#' }]; + + const forward = remapRange(preText, 4, 7, DirectionForward, edits); + expect(forward.postText).toBe('one#hree'); + expect([forward.start, forward.end]).toEqual([4, 4]); + expect(forward.direction).toBe(DirectionNone); + + const backward = remapRange(preText, 4, 7, DirectionBackward, edits); + expect([backward.start, backward.end]).toEqual([4, 4]); + expect(backward.direction).toBe(DirectionNone); + }); +}); + +describe('replacement anchored at the selection start', () => { + test('a contained replacement starting exactly at the selection start shrinks the selection to the un-replaced tail', () => { + // DIVERGENCE: the conventional default marker bias treats a change that + // begins exactly at a tailed marker's start as INSIDE the marker — the + // start stays anchored and the range absorbs the new text (here [7,12) + // would grow to [7,13)). pierre's uniform right gravity pushes any offset + // at or inside an edit past the replacement, so the selection start lands + // after the new text and only the un-replaced tail stays selected — the + // exact shape that convention reserves for its 'inside'-strategy markers. + // Coherent policy, pinned here. + const preText = 'silver maple grove'; + // 'maple' [7,12) selected; 'ma' [7,9) replaced by three characters. + const edits: ResolvedTextEdit[] = [{ start: 7, end: 9, text: 'STE' }]; + + const forward = remapRange(preText, 7, 12, DirectionForward, edits); + expect(forward.postText).toBe('silver STEple grove'); + expect([forward.start, forward.end]).toEqual([10, 13]); + expect(forward.post.getTextSlice(10, 13)).toBe('ple'); + expect(forward.direction).toBe(DirectionForward); + + const backward = remapRange(preText, 7, 12, DirectionBackward, edits); + expect([backward.start, backward.end]).toEqual([10, 13]); + expect(backward.direction).toBe(DirectionBackward); + }); +}); + +describe('seeded sweep against a splice reference model', () => { + test('200 seeded single-edit remaps match the right-gravity reference model', () => { + // Reference model for one edit, applied independently to each endpoint: + // strictly before the edit -> unchanged + // at or after the edit end -> shifted by the net length delta + // otherwise (inside) -> the offset just past the replacement text + // An endpoint EXACTLY at the edit start is the one genuinely + // bias-ambiguous spot (the conventional anchoring keeps it in place, + // pierre pushes it past the new text — see the anchored-start test + // above), so the generator nudges endpoints off that offset and the + // model stays unambiguous. An endpoint exactly at the edit END is not + // ambiguous: the shift branch and the inside branch produce the same + // offset there. + const random = seededRandom(0xa70b1a5); + const randomInt = (bound: number) => Math.floor(random() * bound); + const lower = () => String.fromCharCode(97 + randomInt(26)); + const upper = () => String.fromCharCode(65 + randomInt(26)); + + const problems: string[] = []; + for (let round = 0; round < 200; round++) { + const length = 8 + randomInt(25); + let preText = ''; + for (let i = 0; i < length; i++) { + preText += lower(); + } + + const editStart = randomInt(length + 1); + const editEnd = editStart + randomInt(length - editStart + 1); + let newText = ''; + const newLength = randomInt(7); + for (let i = 0; i < newLength; i++) { + newText += upper(); + } + const edit: ResolvedTextEdit = { + start: editStart, + end: editEnd, + text: newText, + }; + const delta = newText.length - (editEnd - editStart); + + const nudge = (offset: number) => + offset === editStart + ? offset === length + ? offset - 1 + : offset + 1 + : offset; + const a = nudge(randomInt(length + 1)); + const b = nudge(randomInt(length + 1)); + const selStart = Math.min(a, b); + const selEnd = Math.max(a, b); + const direction: SelectionDirection = + selStart === selEnd + ? DirectionNone + : random() < 0.5 + ? DirectionForward + : DirectionBackward; + + const refMap = (offset: number) => + offset < editStart + ? offset + : offset >= editEnd + ? offset + delta + : editStart + newText.length; + const wantStart = refMap(selStart); + const wantEnd = refMap(selEnd); + const wantDirection = wantStart === wantEnd ? DirectionNone : direction; + + const pre = doc(preText); + const post = doc(preText); + post.applyEdits([ + { + range: { + start: pre.positionAt(editStart), + end: pre.positionAt(editEnd), + }, + newText, + }, + ]); + const spliced = + preText.slice(0, editStart) + newText + preText.slice(editEnd); + if (post.getText() !== spliced) { + problems.push( + `#${round}: applyEdits produced '${post.getText()}', splice reference '${spliced}'` + ); + continue; + } + + const selection: EditorSelection = { + start: pre.positionAt(selStart), + end: pre.positionAt(selEnd), + direction, + }; + const [next] = remapSelectionsAfterEdits( + post, + [selection], + [[selStart, selEnd]], + [edit] + ); + const gotStart = post.offsetAt(next.start); + const gotEnd = post.offsetAt(next.end); + const label = `#${round} text='${preText}' edit=[${editStart},${editEnd})->'${newText}' sel=[${selStart},${selEnd}] dir=${direction}`; + if (gotStart > gotEnd || gotStart < 0 || gotEnd > spliced.length) { + problems.push( + `${label}: out-of-order or out-of-bounds result [${gotStart},${gotEnd}]` + ); + } + if (gotStart !== wantStart || gotEnd !== wantEnd) { + problems.push( + `${label}: remapped to [${gotStart},${gotEnd}], reference [${wantStart},${wantEnd}]` + ); + } + if (next.direction !== wantDirection) { + problems.push( + `${label}: direction ${next.direction}, reference ${wantDirection}` + ); + } + } + expect(problems).toEqual([]); + }); +}); + +describe('bidirectional round-trip through history inverse edits', () => { + // Three hunks with unequal old/new lengths: a growing replacement, a pure + // insertion, and a shrinking replacement that crosses a line break. + const baseText = 'cedar\nbirch\noak\nwillow'; + const hunks: ResolvedTextEdit[] = [ + { start: 1, end: 4, text: 'OPPER' }, // 'eda' -> 'OPPER' (+2) + { start: 8, end: 8, text: '-tree' }, // insertion (+5) + { start: 12, end: 20, text: 'elm' }, // 'oak\nwill' -> 'elm' (-5) + ]; + + // Applies the batch through the history-tracked path with an injected + // EditStack, so the inverse edits come from the real undo entry rather than + // being hand-built. The entry's inverseEdits are expressed in POST-edit + // offsets, which is exactly the coordinate space the return leg needs. + function buildHistoryEntry() { + const stack = new EditStack(); + const post = new TextDocument( + 'inmemory://1', + baseText, + 'plain', + 0, + stack + ); + post.applyResolvedEdits(hunks, true); + const entry = stack.peekUndo(); + if (entry === undefined) { + throw new Error('expected a history entry after the tracked batch'); + } + return { post, entry }; + } + + test('offsets outside every hunk round-trip exactly through forward then inverse edits', () => { + const { post, entry } = buildHistoryEntry(); + const preDoc = doc(baseText); + expect(post.getText()).toBe('cOPPERr\nbi-treerch\nelmow'); + expect(entry.inverseEdits).toEqual([ + { start: 1, end: 6, text: 'eda' }, + { start: 10, end: 15, text: '' }, + { start: 19, end: 22, text: 'oak\nwill' }, + ]); + + // "Outside" means before a hunk's start or at/after its end. That includes + // the zero-width insertion hunk's own offset 8: right gravity pushes it + // past '-tree' on the way forward and the inverse deletion pulls it back. + expect(mapOffset(post, 8, entry.forwardEdits)).toBe(15); + + const outside = [0, 4, 5, 6, 7, 8, 9, 10, 11, 20, 21, 22]; + const trips = outside.map((offset) => { + const mapped = mapOffset(post, offset, entry.forwardEdits); + return mapOffset(preDoc, mapped, entry.inverseEdits); + }); + expect(trips).toEqual(outside); + }); + + test('offsets inside a replaced hunk clamp to the hunk trailing edge instead of round-tripping', () => { + // Interior offsets are lossy by construction — the text they addressed is + // gone. Forward they collapse to the end of that hunk's replacement text; + // the return leg then clamps to the hunk's PRE-edit end offset, never + // resurrecting the original interior position. The conventional patch + // translation is likewise lossy inside a change, clamping to the change's + // boundary. + const { post, entry } = buildHistoryEntry(); + const preDoc = doc(baseText); + + const insideFirstHunk = [1, 2, 3].map((offset) => + mapOffset(post, offset, entry.forwardEdits) + ); + expect(insideFirstHunk).toEqual([6, 6, 6]); // just past 'OPPER' + const insideLastHunk = [12, 15, 19].map((offset) => + mapOffset(post, offset, entry.forwardEdits) + ); + expect(insideLastHunk).toEqual([22, 22, 22]); // just past 'elm' + + expect(mapOffset(preDoc, 6, entry.inverseEdits)).toBe(4); // hunk 1 pre-edit end + expect(mapOffset(preDoc, 22, entry.inverseEdits)).toBe(20); // hunk 3 pre-edit end + }); +}); + +describe('delete word backward at whitespace and newline boundaries', () => { + test('after a single leading space it deletes only the line-local space', () => { + // The scan is strictly per-line: even though only one space separates the + // caret from column 0, the delete stops at the line start instead of + // crossing the break into the previous line's word. + const d = doc('apex \n mono'); + const { nextSelections } = applyDeleteWordBackwardToSelections(d, [ + caret(1, 1), + ]); + expect(d.getText()).toBe('apex \nmono'); + expect(nextSelections).toEqual([caret(1, 0)]); + }); + + test('after a multi-space leading run it deletes the run and stops at column 0', () => { + const d = doc('gate\n crux'); + const { nextSelections } = applyDeleteWordBackwardToSelections(d, [ + caret(1, 3), + ]); + expect(d.getText()).toBe('gate\ncrux'); + expect(nextSelections).toEqual([caret(1, 0)]); + }); + + test('at column 0 it deletes exactly the break and keeps the trailing space above', () => { + // Joining consumes only the newline: the previous line's trailing space + // survives byte-for-byte and the caret lands after it. + const d = doc('apex \nmono'); + const { nextSelections } = applyDeleteWordBackwardToSelections(d, [ + caret(1, 0), + ]); + expect(d.getText()).toBe('apex mono'); + expect(nextSelections).toEqual([caret(0, 5)]); + }); +}); + +describe('delete word backward character groups', () => { + test('a multi-character punctuation run deletes as one group', () => { + const d = doc('stop...halt'); + const { nextSelections } = applyDeleteWordBackwardToSelections(d, [ + caret(0, 7), + ]); + expect(d.getText()).toBe('stophalt'); + expect(nextSelections).toEqual([caret(0, 4)]); + }); + + test('a mixed space-and-tab run deletes as one whitespace group', () => { + // ' \t ' is heterogeneous whitespace; the category loop must treat spaces + // and tabs as the same group and stop at the word character before them. + const d = doc('left \t right'); + const { nextSelections } = applyDeleteWordBackwardToSelections(d, [ + caret(0, 7), + ]); + expect(d.getText()).toBe('leftright'); + expect(nextSelections).toEqual([caret(0, 4)]); + }); + + test('digits and underscore are word characters, so an identifier deletes whole', () => { + const d = doc('v = net_port2'); + const { nextSelections } = applyDeleteWordBackwardToSelections(d, [ + caret(0, 13), + ]); + expect(d.getText()).toBe('v = '); + expect(nextSelections).toEqual([caret(0, 4)]); + }); + + test("'<' and '/' share the punctuation category and delete as one run", () => { + const d = doc('tag { + // Punctuation is its own category: the group ends where the word + // characters start, so only the '/' goes. + const d = doc('up/down'); + const { nextSelections } = applyDeleteWordBackwardToSelections(d, [ + caret(0, 3), + ]); + expect(d.getText()).toBe('updown'); + expect(nextSelections).toEqual([caret(0, 2)]); + }); + + // Intl.Segmenter's isWordLike classification varies across ICU builds + // (underscore joining and bare-digit word-ness differ between engines and + // platforms). Gate the segmenter-side pin on the runtime agreeing with the + // UAX #29 behavior our dev and CI environments ship, so a divergent ICU + // skips visibly instead of going red. + const segmenterJoinsIdentifier = [ + ...new Intl.Segmenter(undefined, { granularity: 'word' }).segment( + 'v = net_port2;' + ), + ].some((seg) => seg.segment === 'net_port2' && seg.isWordLike === true); + + test.skipIf(!segmenterJoinsIdentifier)( + 'double-click word expansion agrees the identifier is one word', + () => { + // Pierre-fe encodes word-ness twice: the delete-word regex + // (\p{Alphabetic}|\p{Number}|_) and Intl.Segmenter's isWordLike in + // expandCollapsedSelectionToWord. On CJK text those two disagree + // (pinned as DIVERGENCE in the CJK word-delete tests above in this + // file); on ASCII identifiers they agree — UAX #29 joins letters, + // digits, and underscore (ExtendNumLet) into a single word segment — + // so this pins the consistent half of the dual definition. + const d = doc('v = net_port2;'); + const expected: EditorSelection = { + start: { line: 0, character: 4 }, + end: { line: 0, character: 13 }, + direction: DirectionForward, + }; + expect(expandCollapsedSelectionToWord(d, caret(0, 4))).toEqual(expected); + expect(expandCollapsedSelectionToWord(d, caret(0, 9))).toEqual(expected); + expect(expandCollapsedSelectionToWord(d, caret(0, 13))).toEqual(expected); + } + ); +}); diff --git a/packages/diffs/test/editorTextDocument.test.ts b/packages/diffs/test/editorTextDocument.test.ts index d08c4369b..098053c70 100644 --- a/packages/diffs/test/editorTextDocument.test.ts +++ b/packages/diffs/test/editorTextDocument.test.ts @@ -1374,3 +1374,257 @@ describe('TextDocument', () => { expect(d.redo()?.[2]).toEqual(patchedAfter); }); }); + +// Replaces the range spanning exactly one line break (from the end of `line`'s +// content through the start of the next line) with `newText`. +function replaceLineBreak( + d: ReturnType, + line: number, + newText: string +) { + const edit: TextEdit = { + range: { + start: { line, character: d.getLineLength(line) }, + end: { line: line + 1, character: 0 }, + }, + newText, + }; + const change = d.applyEdits([edit]); + // applyEdits only returns undefined for an empty edit list; narrow the type + // so tests can assert on the change record directly. + if (change === undefined) { + throw new Error('applyEdits returned no change for a non-empty edit list'); + } + return change; +} + +describe('TextDocument edge cases', () => { + test('eol detection uses the first line break, not a whole-file majority vote', () => { + // DIVERGENCE: a common alternative picks the EOL by majority vote across + // the whole file (a document whose breaks are mostly \r\n reports \r\n + // even if the first break is \n). pierre-fe deliberately reads only the + // FIRST line's break (see the eol getter's comment in textDocument.ts): + // it is cheaper, stable under edits below line 0, and mixed-EOL files + // are an edge case for a diff-oriented editor. + const firstLfRestCrlf = doc('red\ngreen\r\nblue\r\nteal\r\npink'); + expect(firstLfRestCrlf.lineCount).toBe(5); + // A majority vote would report '\r\n' here; pierre-fe reports '\n'. + expect(firstLfRestCrlf.eol).toBe('\n'); + + const firstCrlfRestLf = doc('red\r\ngreen\nblue\nteal\npink'); + // A majority vote would report '\n' here; pierre-fe reports '\r\n'. + expect(firstCrlfRestLf.eol).toBe('\r\n'); + + // The consequence: pasted text is normalized to the first line's style. + expect(firstLfRestCrlf.normalizeEol('x\r\ny')).toBe('x\ny'); + expect(firstCrlfRestLf.normalizeEol('x\ny')).toBe('x\r\ny'); + }); + + test('replacing a selected line break with a newline is a stable no-op', () => { + // The edit range runs from end-of-line-0 content through start-of-line-1, + // covering exactly the "\n", and the replacement recreates the same break. + const d = doc('stanza\nrefrain'); + const change = replaceLineBreak(d, 0, '\n'); + + // Buffer and line structure are unchanged (and the document did not lock + // up computing the change — the regression this test pins). + expect(d.getText()).toBe('stanza\nrefrain'); + expect(d.lineCount).toBe(2); + expect(d.getLineText(0)).toBe('stanza'); + expect(d.getLineText(1)).toBe('refrain'); + + // The changed-line bookkeeping stays within the two touched lines and + // reports no net line growth. + expect(change.lineDelta).toBe(0); + expect(change.previousLineCount).toBe(2); + expect(change.lineCount).toBe(2); + expect(change.changedLineRanges).toEqual([[0, 1]]); + + // Subsequent edits still work on the untouched structure. + d.applyEdits([ + { + range: { + start: { line: 1, character: 7 }, + end: { line: 1, character: 7 }, + }, + newText: '!', + }, + ]); + expect(d.getText()).toBe('stanza\nrefrain!'); + expect(d.lineCount).toBe(2); + }); + + test('replacing a selected CRLF break with CRLF is a stable no-op', () => { + const d = doc('stanza\r\nrefrain'); + const change = replaceLineBreak(d, 0, '\r\n'); + + expect(d.getText()).toBe('stanza\r\nrefrain'); + expect(d.lineCount).toBe(2); + expect(d.getLineText(0)).toBe('stanza'); + expect(d.getLineText(1)).toBe('refrain'); + expect(change.lineDelta).toBe(0); + expect(change.changedLineRanges).toEqual([[0, 1]]); + + // A follow-up edit crossing the same break still resolves correctly. + d.applyEdits([ + { + range: { + start: { line: 0, character: 0 }, + end: { line: 1, character: 0 }, + }, + newText: '', + }, + ]); + expect(d.getText()).toBe('refrain'); + expect(d.lineCount).toBe(1); + }); + + test('replacing a selected line break with a different break style keeps the line structure', () => { + // Same boundary-straddling range, but the replacement swaps \n for \r\n: + // the byte content changes while the logical line structure does not. + const d = doc('stanza\nrefrain'); + const change = replaceLineBreak(d, 0, '\r\n'); + + expect(d.getText()).toBe('stanza\r\nrefrain'); + expect(d.lineCount).toBe(2); + expect(d.getLineText(0)).toBe('stanza'); + expect(d.getLineText(1)).toBe('refrain'); + expect(change.lineDelta).toBe(0); + expect(change.changedLineRanges).toEqual([[0, 1]]); + + // Line 1 offsets shifted by one byte; edits addressed by position must + // still land correctly. + d.applyEdits([ + { + range: { + start: { line: 1, character: 0 }, + end: { line: 1, character: 0 }, + }, + newText: '> ', + }, + ]); + expect(d.getText()).toBe('stanza\r\n> refrain'); + }); + + test('normalizePosition clamps both coordinates when line and character overshoot together', () => { + // Normalizing a far-out-of-range position like (99, 99) on a 2-line + // document must land at the end of the last line — the character is + // clamped against the CLAMPED line's length, not the requested + // (nonexistent) line. + const d = doc('ab\r\ncdef'); + expect(d.normalizePosition({ line: 99, character: 99 })).toEqual({ + line: 1, + character: 4, + }); + }); + + test('normalizePosition cannot land inside a CRLF pair', () => { + // On a line ending in \r\n, an overshooting character clamps to the line + // content length: character 3 on "ab\r\n" would sit between \r and \n. + const d = doc('ab\r\ncdef'); + expect(d.normalizePosition({ line: 0, character: 3 })).toEqual({ + line: 0, + character: 2, + }); + expect(d.normalizePosition({ line: 0, character: 4 })).toEqual({ + line: 0, + character: 2, + }); + }); + + test('normalizePosition clamps to character 0 on the empty trailing line', () => { + // A document ending in a newline has an empty final logical line; any + // character overshoot there clamps to 0 (the document end). + const d = doc('ab\n'); + expect(d.lineCount).toBe(2); + expect(d.normalizePosition({ line: 1, character: 7 })).toEqual({ + line: 1, + character: 0, + }); + }); +}); + +// Applies a single insert whose range carries malformed numeric components, +// then reports whether the document survived. A correct implementation may +// either reject the edit (throw) or clamp the position to something valid — +// both count as surviving. What must never happen is unrelated content +// vanishing. +function insertAtMalformed( + original: string, + line: number, + character: number +): { threw: boolean; text: string } { + const d = doc(original); + let threw = false; + try { + d.applyEdits([ + { + range: { + start: { line, character }, + end: { line, character }, + }, + newText: '#', + }, + ]); + } catch { + threw = true; + } + return { threw, text: d.getText() }; +} + +describe('malformed numeric position components', () => { + // A stricter contract would reject non-numeric components outright; + // pierre-fe's normalizePosition has no finiteness guard, so NaN flows + // through Math.min/max into offsetAt, the resolved offset becomes NaN, + // and the edit range degenerates into a whole-document replace. + + test.failing( + 'an insert with a NaN component never destroys unrelated content', + () => { + // KNOWN BUG: NaN in either component resolves to a NaN offset and the + // edit replaces the ENTIRE document with the inserted text. + for (const [line, character] of [ + [Number.NaN, 1], + [0, Number.NaN], + [Number.NaN, Number.NaN], + ]) { + const { threw, text } = insertAtMalformed( + 'harbor\nlantern', + line, + character + ); + if (!threw) { + expect(text).toContain('harbor'); + expect(text).toContain('lantern'); + } + } + } + ); + + test.failing( + 'an insert with fractional components never destroys unrelated content', + () => { + // KNOWN BUG: a fractional line (0.5) makes offsetAt return NaN via the + // line-offset lookup, taking the same whole-document-replace path as + // NaN inputs. + const { threw, text } = insertAtMalformed('harbor\nlantern', 0.5, 2.5); + if (!threw) { + expect(text).toContain('harbor'); + expect(text).toContain('lantern'); + } + } + ); + + test('Infinity components clamp to a valid position without data loss', () => { + // DIVERGENCE: a stricter contract would reject non-finite components by + // throwing; pierre-fe's Math.min/max clamping happens to bound Infinity + // to a real offset, so the insert lands at a valid spot and nothing is + // lost. Pinned so a future finiteness guard (which may prefer to throw) + // shows up here. + const { threw, text } = insertAtMalformed('harbor\nlantern', Infinity, 0); + expect(threw).toBe(false); + expect(text).toContain('harbor'); + expect(text).toContain('lantern'); + expect(text).toContain('#'); + }); +}); diff --git a/packages/diffs/test/editorWrapCaretPosition.test.ts b/packages/diffs/test/editorWrapCaretPosition.test.ts index 8f2ac8573..c6940d074 100644 --- a/packages/diffs/test/editorWrapCaretPosition.test.ts +++ b/packages/diffs/test/editorWrapCaretPosition.test.ts @@ -185,6 +185,7 @@ async function createWrapEditor( cleanup(): void; content: HTMLElement; editor: Editor; + fileContainer: HTMLElement; window: EditorTestWindow; }> { const dom = installDom(); @@ -216,6 +217,7 @@ async function createWrapEditor( }, content, editor, + fileContainer, window: dom.window as unknown as EditorTestWindow, }; } @@ -255,6 +257,80 @@ function expectCaret( expect(selection?.end).toEqual({ line, character }); } +// Reads back the current caret position rather than asserting it, for tests +// that need to inspect the actual landing spot (e.g. surrogate-pair checks). +function caretState(editor: Editor): { + line: number; + character: number; +} { + const selection = editor.getState().selections?.at(-1); + if (selection === undefined) { + throw new Error('no selection in editor state'); + } + expect(selection.start).toEqual(selection.end); + return { line: selection.start.line, character: selection.start.character }; +} + +function parseTranslate(transform: string): { x: number; y: number } { + const match = /translateX\((-?[\d.]+)px\) translateY\((-?[\d.]+)px\)/.exec( + transform + ); + if (match === null) { + throw new Error(`unparseable transform: ${transform}`); + } + return { x: parseFloat(match[1]), y: parseFloat(match[2]) }; +} + +// The rendered caret's overlay position. Exactly one caret is expected. +function caretXY(container: HTMLElement): { x: number; y: number } { + const carets = container.shadowRoot?.querySelectorAll('[data-caret]'); + expect(carets?.length).toBe(1); + return parseTranslate((carets![0] as HTMLElement).style.transform); +} + +// Selection rects painted on the overlay, in render order. Rounded-corner mask +// elements (same data attribute, but wrapping a [data-selection-corner] child) +// are cosmetic and excluded. +function selectionRects( + container: HTMLElement +): { x: number; y: number; width: number }[] { + const rects: { x: number; y: number; width: number }[] = []; + container.shadowRoot + ?.querySelectorAll('[data-selection-range]') + .forEach((el) => { + const rangeEl = el as HTMLElement; + if (rangeEl.querySelector('[data-selection-corner]') !== null) { + return; + } + const { x, y } = parseTranslate(rangeEl.style.transform); + rects.push({ x, y, width: parseFloat(rangeEl.style.width) }); + }); + return rects; +} + +// Geometry constants for the caret/selection assertions below. The stubbed +// canvas measures every ASCII char at 8px (so ch = 8), jsdom computed style +// leaves lineHeight at its 20px default, the gutter has no measured width, and +// content starts after a 1ch pad — so a caret at segment-relative column c on +// visual row r renders at translateX(8 + c*8 - 1) translateY(r*20). +const CH = 8; +const ROW_H = 20; +const CONTENT_X = CH; // gutter (0) + 1ch inline padding + +// Expected caret translateX for segment-relative column `col` (caret draws +// 1px left of the character edge). +function colX(col: number): number { + return CONTENT_X + col * CH - 1; +} + +const LOW_SURROGATE_MIN = 0xdc00; +const LOW_SURROGATE_MAX = 0xdfff; + +function sitsInsideSurrogatePair(lineText: string, character: number): boolean { + const unit = lineText.charCodeAt(character); + return unit >= LOW_SURROGATE_MIN && unit <= LOW_SURROGATE_MAX; +} + describe('editor wrap caret position', () => { test('arrow keys move through wrapped visual rows before changing logical lines', async () => { const { cleanup, content, editor, window } = await createWrapEditor( @@ -417,3 +493,319 @@ describe('editor wrap caret position', () => { } }); }); + +describe('caret affinity at a wrap boundary', () => { + // A 20-char line wrapped every 10 columns. Character 10 is the offset shared + // by end-of-visual-row-0 and start-of-visual-row-1; such positions could be + // disambiguated with an explicit clip-direction flag, but pierre resolves + // them with a fixed backward affinity (the earlier row wins) in both + // #getCharX and getSoftLineInfo. + const TWO_ROW_LINE = 'q0w1e2r3t4y5u6i7o8p9'; + + test('a caret on the shared wrap offset draws at the end of the earlier visual row', async () => { + const { cleanup, editor, fileContainer } = await createWrapEditor( + `${TWO_ROW_LINE}\nnext`, + 10 + ); + try { + // Calibration: a mid-segment caret proves the wrap branch is live — + // column 15 is segment-relative column 5 on visual row 1. + setCaret(editor, 0, 15); + expect(caretXY(fileContainer)).toEqual({ x: colX(5), y: ROW_H }); + + // One past the boundary belongs to the continuation row. + setCaret(editor, 0, 11); + expect(caretXY(fileContainer)).toEqual({ x: colX(1), y: ROW_H }); + + // The boundary itself renders on row 0 at the segment's right edge + // (backward affinity), not at column 0 of row 1. + setCaret(editor, 0, 10); + expect(caretXY(fileContainer)).toEqual({ x: colX(10), y: 0 }); + } finally { + cleanup(); + } + }); + + test('Home and End treat a boundary caret as belonging to the row that ends there', async () => { + const { cleanup, content, editor, window } = await createWrapEditor( + `${TWO_ROW_LINE}\nnext`, + 10 + ); + try { + // Home goes to the start of visual row 0; forward affinity would have + // kept the caret at 10 (already at row 1's start). + setCaret(editor, 0, 10); + dispatchMovementKey(window, content, { key: 'Home' }); + expectCaret(editor, 0, 0); + + // End is a no-op: the caret already sits at row 0's end; forward + // affinity would have jumped to the line end at 20. + setCaret(editor, 0, 10); + dispatchMovementKey(window, content, { key: 'End' }); + expectCaret(editor, 0, 10); + } finally { + cleanup(); + } + }); + + test('ArrowDown carries a boundary caret from wrap offset to wrap offset', async () => { + // Three visual rows: [0,10) [10,20) [20,30). + const { cleanup, content, editor, window } = await createWrapEditor( + 'wrap_me_at_ten_columns_please!\nnext', + 10 + ); + try { + setCaret(editor, 0, 10); + + // The boundary caret counts as visual column 10 of row 0, so each step + // down lands on the next boundary (clamped to the segment end), keeping + // the caret on row ends all the way to the line end. + dispatchMovementKey(window, content, { key: 'ArrowDown' }); + expectCaret(editor, 0, 20); + + dispatchMovementKey(window, content, { key: 'ArrowDown' }); + expectCaret(editor, 0, 30); + } finally { + cleanup(); + } + }); +}); + +describe('vertical motion between wrapped rows and grapheme integrity', () => { + // KNOWN BUG: moveBySoftLine computes the landing spot as target-segment + // start + visual column in raw UTF-16 units with no grapheme/surrogate + // snapping, so ArrowDown here parks the caret at character 7 — between the + // halves of the second astral character. A subsequent insert at that caret + // splits the pair into lone surrogates (verified against TextDocument: + // inserting "x" at (0,7) leaves \ud83d x \ude00 in the buffer). + test.failing( + 'ArrowDown into a continuation row never lands inside a surrogate pair', + async () => { + const astralRow = '\u{1F680}\u{1F98A}'; // 2 astral chars = 4 UTF-16 units + const lineText = `wxyz${astralRow}`; + // Visual rows: [0,4) 'wxyz' and [4,8) with both astral characters. + const { cleanup, content, editor, window } = await createWrapEditor( + lineText, + 4 + ); + try { + setCaret(editor, 0, 3); + dispatchMovementKey(window, content, { key: 'ArrowDown' }); + + const { line, character } = caretState(editor); + expect(line).toBe(0); + expect(sitsInsideSurrogatePair(lineText, character)).toBe(false); + } finally { + cleanup(); + } + } + ); + + // KNOWN BUG: same raw-column arithmetic on the upward path. Crossing the + // logical-line boundary lands on the wrapped line's last visual row at + // segment start + 1 = character 5, the low-surrogate half of the trailing + // astral character. + test.failing( + 'ArrowUp across a logical-line boundary never lands inside a surrogate pair', + async () => { + const lineText = '\u{1F680}mn\u{1F98A}'; // rows: [0,4) '🚀mn', [4,6) '🦊' + const { cleanup, content, editor, window } = await createWrapEditor( + `${lineText}\nabc`, + 4 + ); + try { + setCaret(editor, 1, 1); + dispatchMovementKey(window, content, { key: 'ArrowUp' }); + + const { line, character } = caretState(editor); + expect(line).toBe(0); + expect(sitsInsideSurrogatePair(lineText, character)).toBe(false); + } finally { + cleanup(); + } + } + ); + + test('ArrowUp from the line below lands on the last visual row of the wrapped line', async () => { + // Line 0 wraps into 'the_quick_' / 'brown_fox_' / 'jumps'. + const { cleanup, content, editor, fileContainer, window } = + await createWrapEditor('the_quick_brown_fox_jumps\ngoal', 10); + try { + setCaret(editor, 1, 3); + dispatchMovementKey(window, content, { key: 'ArrowUp' }); + + // Segment-relative column 3 of the FINAL visual row: 20 + 3 = 23. A + // logical-column interpretation would have produced character 3. + expectCaret(editor, 0, 23); + // And the caret element really renders on the third visual row. + expect(caretXY(fileContainer)).toEqual({ x: colX(3), y: 2 * ROW_H }); + } finally { + cleanup(); + } + }); + + test('ArrowUp into a shorter final row keeps the visual column as an overshoot', async () => { + // DIVERGENCE: the conventional behavior clips a screen position past a + // row's end back to the row boundary, so moving up into a shorter last + // segment lands at that segment's end (character 25 here). pierre + // deliberately skips the clamp on the final segment: the selection holds + // 20 + 8 = 28, three past the line's 25 characters. The overshoot behaves + // like an implicit goal column — rendering clamps it to the line end, + // edits would clamp through normalizePosition, and moving back down + // restores the original column — so it is coherent policy, not + // corruption. + const { cleanup, content, editor, fileContainer, window } = + await createWrapEditor('the_quick_brown_fox_jumps\nreturn 0;', 10); + try { + setCaret(editor, 1, 8); + dispatchMovementKey(window, content, { key: 'ArrowUp' }); + expectCaret(editor, 0, 28); + + // The caret element draws clamped to the line end on the last row. + expect(caretXY(fileContainer)).toEqual({ x: colX(5), y: 2 * ROW_H }); + + // Round trip: the overshoot column survives the trip back down. + dispatchMovementKey(window, content, { key: 'ArrowDown' }); + expectCaret(editor, 1, 8); + } finally { + cleanup(); + } + }); +}); + +describe('hard tabs re-expand from each continuation row start', () => { + // 'zzzzz' fills visual row 0 exactly (wrap column 5, odd on purpose); the + // continuation row is 'f\tgh'. With tabSize 2 the tab's stop depends on + // which left edge tab expansion starts from: from the segment start the tab + // sits at segment column 1 and advances 1 column (to the stop at 2); from + // the logical line start it would sit at column 6 and advance 2 (to the + // stop at 8). The two schemes disagree on every x after the tab. + const TABBED_LINE = 'zzzzzf\tgh'; + + test('caret x after a tab on a continuation row uses tab stops from the segment edge', async () => { + const { cleanup, editor, fileContainer } = await createWrapEditor( + TABBED_LINE, + 5 + ); + try { + // Caret right after the tab: segment prefix 'f\t' spans 2 columns + // (logical-line expansion would make it 3). + setCaret(editor, 0, 7); + expect(caretXY(fileContainer)).toEqual({ x: colX(2), y: ROW_H }); + + // Caret after 'g': 3 segment columns (logical-line expansion: 4). + setCaret(editor, 0, 8); + expect(caretXY(fileContainer)).toEqual({ x: colX(3), y: ROW_H }); + } finally { + cleanup(); + } + }); + + test('selection width over a tab on a continuation row matches segment tab stops', async () => { + const { cleanup, editor, fileContainer } = await createWrapEditor( + TABBED_LINE, + 5 + ); + try { + // Select 'f\tg' — the continuation row from its first character. + editor.setSelections([ + { + start: { line: 0, character: 5 }, + end: { line: 0, character: 8 }, + direction: 'forward', + }, + ]); + + // One rect on visual row 1, starting at the content edge, 3 columns + // wide (tab expanded from the segment start). + expect(selectionRects(fileContainer)).toEqual([ + { x: CONTENT_X, y: ROW_H, width: 3 * CH }, + ]); + } finally { + cleanup(); + } + }); +}); + +describe('selection endpoints on wrap offsets', () => { + // Scope note: rect painting is asserted through the overlay divs' inline + // width/transform — the geometry the editor computes — rather than painted + // pixels, which jsdom cannot produce. + const TWO_ROW_LINE = 'q0w1e2r3t4y5u6i7o8p9'; + + test('a selection ending exactly on the wrap offset paints flush to the row edge with no sliver below', async () => { + const { cleanup, editor, fileContainer } = await createWrapEditor( + `${TWO_ROW_LINE}\nnext`, + 10 + ); + try { + editor.setSelections([ + { + start: { line: 0, character: 2 }, + end: { line: 0, character: 10 }, + direction: 'forward', + }, + ]); + + // Exactly one rect: columns 2..10 of visual row 0. The zero-width slice + // the segment loop computes at the start of row 1 must be dropped, not + // painted as a sliver. + const rects = selectionRects(fileContainer); + expect(rects).toEqual([{ x: CONTENT_X + 2 * CH, y: 0, width: 8 * CH }]); + // Right edge lands exactly on the wrap boundary's x. + expect(rects[0].x + rects[0].width).toBe(CONTENT_X + 10 * CH); + } finally { + cleanup(); + } + }); + + test('a selection starting exactly on the wrap offset paints only on the continuation row', async () => { + const { cleanup, editor, fileContainer } = await createWrapEditor( + `${TWO_ROW_LINE}\nnext`, + 10 + ); + try { + editor.setSelections([ + { + start: { line: 0, character: 10 }, + end: { line: 0, character: 14 }, + direction: 'forward', + }, + ]); + + // No zero-width rect at the end of row 0; the selection begins at the + // continuation row's left content edge. + expect(selectionRects(fileContainer)).toEqual([ + { x: CONTENT_X, y: ROW_H, width: 4 * CH }, + ]); + } finally { + cleanup(); + } + }); + + test('a boundary-spanning selection paints exactly one rect per visual row', async () => { + const { cleanup, editor, fileContainer } = await createWrapEditor( + `${TWO_ROW_LINE}\nnext`, + 10 + ); + try { + editor.setSelections([ + { + start: { line: 0, character: 2 }, + end: { line: 0, character: 15 }, + direction: 'forward', + }, + ]); + + // Row 0 carries columns 2..10 flush to the wrap edge (no end padding on + // an intermediate segment); row 1 carries columns 0..5 from the content + // edge. No third rect and no zero-width boundary artifacts. + expect(selectionRects(fileContainer)).toEqual([ + { x: CONTENT_X + 2 * CH, y: 0, width: 8 * CH }, + { x: CONTENT_X, y: ROW_H, width: 5 * CH }, + ]); + } finally { + cleanup(); + } + }); +});