From 62b018b2db50dfe8c5ca12707880a150fda33aa7 Mon Sep 17 00:00:00 2001 From: fat Date: Mon, 13 Jul 2026 21:43:26 -0700 Subject: [PATCH 1/9] [diffs/edit] Add legacy-editor test suites harvested from monaco and CodeMirror Ports behavioral edge-case scenarios from microsoft/vscode (MIT) and CodeMirror 6 (MIT) test suites into two new bun:test suites under packages/diffs/test/. 139 tests, including 22 test.failing entries that encode real bugs found during the port (frozen EditStack entries vs non-history edits, undo coalescing, surrogate-pair edit boundaries, CRLF line metadata, and others). Test-only change; fixes land separately. Co-Authored-By: Claude Fable 5 --- packages/diffs/test/README.md | 17 + .../test/codemirror-legacy-tests/README.md | 78 +++ .../applyEditsBatch.cm.test.ts | 303 ++++++++++ .../commands.cm.test.ts | 475 ++++++++++++++++ .../history.cm.test.ts | 353 ++++++++++++ .../historyRemote.cm.test.ts | 296 ++++++++++ .../pieceTable.cm.test.ts | 527 ++++++++++++++++++ .../remapSelections.cm.test.ts | 255 +++++++++ .../selection.cm.test.ts | 346 ++++++++++++ .../diffs/test/monaco-legacy-tests/README.md | 79 +++ .../applyEdits.monaco.test.ts | 237 ++++++++ .../editStack.monaco.test.ts | 241 ++++++++ .../pieceTable.monaco.test.ts | 203 +++++++ .../selection.monaco.test.ts | 249 +++++++++ .../textDocument.monaco.test.ts | 183 ++++++ .../wordOperations.monaco.test.ts | 314 +++++++++++ 16 files changed, 4156 insertions(+) create mode 100644 packages/diffs/test/codemirror-legacy-tests/README.md create mode 100644 packages/diffs/test/codemirror-legacy-tests/applyEditsBatch.cm.test.ts create mode 100644 packages/diffs/test/codemirror-legacy-tests/commands.cm.test.ts create mode 100644 packages/diffs/test/codemirror-legacy-tests/history.cm.test.ts create mode 100644 packages/diffs/test/codemirror-legacy-tests/historyRemote.cm.test.ts create mode 100644 packages/diffs/test/codemirror-legacy-tests/pieceTable.cm.test.ts create mode 100644 packages/diffs/test/codemirror-legacy-tests/remapSelections.cm.test.ts create mode 100644 packages/diffs/test/codemirror-legacy-tests/selection.cm.test.ts create mode 100644 packages/diffs/test/monaco-legacy-tests/README.md create mode 100644 packages/diffs/test/monaco-legacy-tests/applyEdits.monaco.test.ts create mode 100644 packages/diffs/test/monaco-legacy-tests/editStack.monaco.test.ts create mode 100644 packages/diffs/test/monaco-legacy-tests/pieceTable.monaco.test.ts create mode 100644 packages/diffs/test/monaco-legacy-tests/selection.monaco.test.ts create mode 100644 packages/diffs/test/monaco-legacy-tests/textDocument.monaco.test.ts create mode 100644 packages/diffs/test/monaco-legacy-tests/wordOperations.monaco.test.ts diff --git a/packages/diffs/test/README.md b/packages/diffs/test/README.md index fe2bcd606..3a59e5133 100644 --- a/packages/diffs/test/README.md +++ b/packages/diffs/test/README.md @@ -6,6 +6,23 @@ Run from this package directory: AGENT=1 bun test ``` +## Legacy-editor test suites + +`monaco-legacy-tests/` (microsoft/vscode, MIT) and `codemirror-legacy-tests/` +(CodeMirror 6, MIT) hold editor-behavior scenarios harvested from other editors' +test suites. See each directory's README for provenance, the `test.failing` +known-bug convention, and the `DIVERGENCE:` comment policy. Discovered by the +normal `bun test` run like any other `*.test.ts` files. + +Authorship hygiene for any future additions to these suites: derive only from +permissively-licensed (e.g. MIT) sources; write your own test names (never reuse +the source's, however apt); use original fixtures, variable names, helper +structure, and assertions; organize by this package's architecture rather than +the source suite's file layout or ordering; and re-derive test granularity from +the behavioral requirement (split or merge cases around our API) instead of +mirroring the source's case-by-case structure. Quoting an original test name is +acceptable only inside a traceability comment, as attribution. + ## Conventions - Shared DOM bootstrap lives in `domHarness.ts` (`installDom` always installs diff --git a/packages/diffs/test/codemirror-legacy-tests/README.md b/packages/diffs/test/codemirror-legacy-tests/README.md new file mode 100644 index 000000000..d03d23f13 --- /dev/null +++ b/packages/diffs/test/codemirror-legacy-tests/README.md @@ -0,0 +1,78 @@ +# codemirror-legacy-tests + +Behavioral test scenarios harvested from CodeMirror 6's test suites and +re-expressed against `@pierre/diffs`' edit APIs. Companion to +`../monaco-legacy-tests/` (same conventions); scenarios already covered there or +in the main suite were filtered out during the audit. Scope: text buffer/rope +semantics, edit application and position remapping, selection & multi-cursor, +undo/redo. IME/composition remains deferred. + +## Provenance & attribution + +Adapted from the MIT-licensed CodeMirror packages, © Marijn Haverbeke and +others: + +- [codemirror/state](https://github.com/codemirror/state) @ + `9c801279cb83011e6f92af778f4443406e8f1200` (`test-text.ts`, `test-change.ts`, + `test-selection.ts`, `test-state.ts`, `test-charcategory.ts`) +- [codemirror/commands](https://github.com/codemirror/commands) @ + `5b9bac974f2c4af3e20b045adef949667872ecad` (`test-history.ts`, + `test-commands.ts`) + +These are behavioral rewrites, not code copies. CodeMirror addresses documents +by flat offsets and anchor/head selections; everything here is translated to +this package's 0-based `{line, character}` positions and `EditorSelection` +ranges. Traceability comment on every test: + +```ts +// codemirror-legacy: cm-state/test/test-change.ts — "" +``` + +## Conventions + +Same as `../monaco-legacy-tests/README.md`: + +- `test.failing(...)` = known bug; the test asserts the _correct_ behavior and + currently fails. Remove the modifier when the bug is fixed. +- `DIVERGENCE:` comments pin intentional/accepted differences from CodeMirror. + +## Known bugs encoded as `test.failing` + +12 tests, full details in each file's `KNOWN BUG` comment: + +- `applyEditsBatch.cm.test.ts` (1): acceptance of a {delete, + insert-at-same-offset} batch depends on the caller's array order — + delete-first throws 'Overlapping text edits are not supported', insert-first + succeeds. +- `commands.cm.test.ts` (3): the line-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. +- `historyRemote.cm.test.ts` (6): one root cause — EditStack entries are frozen + at creation and never remapped through non-history (`updateHistory=false`) + edits. Surfaces pinned: stale-offset undo corrupting remote text, undo of a + wiped entry not being a graceful no-op, batch inversion across an interleaved + remote insert, interior remote inserts not surviving undo of a tracked + insertion, stored entry selections restored without remapping, and redo + replaying at stale offsets. +- `pieceTable.cm.test.ts` (1): `TextDocument.positionAt` can return a position + strictly inside a CRLF pair (character beyond `getLineLength`) that its own + `offsetAt` clamps away, so the round trip silently loses a column. +- `selection.cm.test.ts` (1): `Editor.setSelections` normalizes positions but + never reorders a start-after-end pair, storing an inverted selection that + violates the `start <= end` invariant downstream code assumes. + +## Consciously out of scope + +- Forward word/group delete (`deleteGroupForward`) — missing feature, already + recorded in `../monaco-legacy-tests/README.md`; CodeMirror's newline-boundary + rules for it (`cm-commands/test/test-commands.ts`) are the reference if built. +- History entries mapping through _concurrent non-history edits_ (CodeMirror's + remote-change rebasing: preserving interior inserts on undo, remapping stored + selections through unrecorded edits, redo surviving non-history edits). Where + the current behavior is merely _different but safe_ it is pinned as + DIVERGENCE; where it corrupts text through plausible public-API flows it is + encoded as a known bug; full collab-style rebasing is a non-goal. +- Time-based undo grouping (`newGroupDelay`) — pierre-fe's coalescing is + geometry-based with no clock input; policy decision, not ported. +- IME composition scenarios (deferred with the rest of IME). diff --git a/packages/diffs/test/codemirror-legacy-tests/applyEditsBatch.cm.test.ts b/packages/diffs/test/codemirror-legacy-tests/applyEditsBatch.cm.test.ts new file mode 100644 index 000000000..8c3d83a53 --- /dev/null +++ b/packages/diffs/test/codemirror-legacy-tests/applyEditsBatch.cm.test.ts @@ -0,0 +1,303 @@ +import { describe, expect, test } from 'bun:test'; + +import { DirectionNone } from '../../src/editor/selection'; +import { TextDocument } from '../../src/editor/textDocument'; +import type { EditorSelection, TextEdit } from '../../src/types'; + +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 }; +} + +// 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; + }; +} + +describe('applyEdits batch: insert at a deletion boundary', () => { + // codemirror-legacy: cm-state/test/test-change.ts — "can create change sets" + 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, like CodeMirror's 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'); + }); + + // codemirror-legacy: cm-state/test/test-change.ts — "can create change sets" + // 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. CodeMirror's ChangeSet.of accepts the + // batch deterministically 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', () => { + // codemirror-legacy: cm-state/test/test-change.ts — "survives random sequences of changes" + 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', () => { + // codemirror-legacy: cm-state/test/test-change.ts — "can be iterated" + 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], + ], + }); + }); + + // codemirror-legacy: cm-state/test/test-change.ts — "can be iterated" + 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', () => { + // codemirror-legacy: cm-state/test/test-change.ts — "can handle empty sets" + 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); + }); + + // codemirror-legacy: cm-state/test/test-change.ts — "can handle empty sets" + 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', () => { + // codemirror-legacy: cm-state/test/test-state.ts — "throws when a change's bounds are invalid" + // DIVERGENCE: CodeMirror rejects a change with from: -1 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 + }); + + // codemirror-legacy: cm-state/test/test-state.ts — "throws when a change's bounds are invalid" + // DIVERGENCE: CodeMirror rejects to: 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) + }); + + // codemirror-legacy: cm-state/test/test-state.ts — "throws when a change's bounds are invalid" + // DIVERGENCE: CodeMirror throws 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!'); + }); + + // codemirror-legacy: cm-state/test/test-state.ts — "throws when a change's bounds are invalid" + // DIVERGENCE: CodeMirror throws; 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'); + }); +}); diff --git a/packages/diffs/test/codemirror-legacy-tests/commands.cm.test.ts b/packages/diffs/test/codemirror-legacy-tests/commands.cm.test.ts new file mode 100644 index 000000000..b3e8a7b9f --- /dev/null +++ b/packages/diffs/test/codemirror-legacy-tests/commands.cm.test.ts @@ -0,0 +1,475 @@ +import { describe, expect, test } from 'bun:test'; + +import { + applyDeleteWordBackwardToSelections, + applyTextChangeToSelections, + DirectionForward, + DirectionNone, + expandCollapsedSelectionToWord, + getSelectedLineBlocks, + resolveIndentEdits, + shiftSelectionLines, +} from '../../src/editor/selection'; +import { TextDocument } from '../../src/editor/textDocument'; +import type { EditorSelection, TextEdit } from '../../src/types'; + +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, + } satisfies EditorSelection; +} + +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; +} + +// 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. editorApplyEdits.test.ts 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('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', () => { + // codemirror-legacy: cm-commands/test/test-commands.ts — "doesn't double-indent a given line" + 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', + () => { + // codemirror-legacy: cm-commands/test/test-commands.ts — "doesn't double-indent a given line" + 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', + () => { + // codemirror-legacy: cm-commands/test/test-commands.ts — "doesn't double-indent a given line" + 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). CodeMirror 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', () => { + // codemirror-legacy: cm-commands/test/test-commands.ts — "can split tabs" + // DIVERGENCE: CodeMirror 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', () => { + // codemirror-legacy: cm-commands/test/test-commands.ts — "takes tabs into account" + // DIVERGENCE: CodeMirror 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', () => { + // codemirror-legacy: cm-commands/test/test-commands.ts — "takes tabs into account" + // DIVERGENCE: CodeMirror 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('delete word backward at whitespace and newline boundaries', () => { + test('after a single leading space it deletes only the line-local space', () => { + // codemirror-legacy: cm-commands/test/test-commands.ts — "stops deleting at a newline" + // 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', () => { + // codemirror-legacy: cm-commands/test/test-commands.ts — "stops deleting at a newline" + 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', () => { + // codemirror-legacy: cm-commands/test/test-commands.ts — "stops deleting after a newline" + // 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', () => { + // codemirror-legacy: cm-commands/test/test-commands.ts — "deletes a group of punctuation" + 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', () => { + // codemirror-legacy: cm-commands/test/test-commands.ts — "deletes a group of space" + // ' \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', () => { + // codemirror-legacy: cm-state/test/test-charcategory.ts — "categorises into alphanumeric" + 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", () => { + // codemirror-legacy: cm-state/test/test-charcategory.ts — "categorises into other" + const d = doc('tag { + // codemirror-legacy: cm-state/test/test-charcategory.ts — "categorises into other" + // 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)]); + }); + + test('double-click word expansion agrees the identifier is one word', () => { + // codemirror-legacy: cm-state/test/test-charcategory.ts — "categorises into alphanumeric" + // 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 ../monaco-legacy-tests/wordOperations.monaco.test.ts); + // 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); + }); +}); + +describe('move line commands with merged and same-line multi-cursor blocks', () => { + test('interleaved ranges and a caret merge into one block moving up', () => { + // codemirror-legacy: cm-commands/test/test-commands.ts — "moves blocks made of multiple ranges as one" (moveLineUp) + // 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', () => { + // codemirror-legacy: cm-commands/test/test-commands.ts — "moves blocks made of multiple ranges as one" (moveLineDown) + 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', () => { + // codemirror-legacy: cm-commands/test/test-commands.ts — "preserves multiple cursors on a single line" (moveLineDown) + 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', () => { + // codemirror-legacy: cm-commands/test/test-commands.ts — "preserves multiple cursors on a single line" (moveLineUp) + 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', () => { + // codemirror-legacy: cm-commands/test/test-commands.ts — "does not include a trailing line after a range" (moveLineUp) + // 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', () => { + // codemirror-legacy: cm-commands/test/test-commands.ts — "keeps indentation" (insertNewlineKeepIndent) + 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', () => { + // codemirror-legacy: cm-commands/test/test-commands.ts — "clears empty lines before the cursor" (insertNewlineAndIndent) + // DIVERGENCE: CodeMirror's language-aware insertNewlineAndIndent replaces + // a whitespace-only line with '\n', clearing the stale indentation. + // Pierre-fe's Enter is keep-indent semantics (like CodeMirror's own + // insertNewlineKeepIndent): 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 the + // 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', () => { + // codemirror-legacy: cm-commands/test/test-commands.ts — "deletes the selection" (insertNewlineKeepIndent) + // 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', () => { + // codemirror-legacy: cm-commands/test/test-commands.ts — "keeps zero indentation" (insertNewlineKeepIndent) + 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/codemirror-legacy-tests/history.cm.test.ts b/packages/diffs/test/codemirror-legacy-tests/history.cm.test.ts new file mode 100644 index 000000000..d1096f597 --- /dev/null +++ b/packages/diffs/test/codemirror-legacy-tests/history.cm.test.ts @@ -0,0 +1,353 @@ +// Undo/redo selection restore and history traversal scenarios ported from +// CodeMirror 6's history suite. See README.md in this directory for suite +// conventions. 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. +import { describe, expect, test } from 'bun:test'; + +import { DirectionNone } from '../../src/editor/selection'; +import { TextDocument } from '../../src/editor/textDocument'; +import type { EditorSelection } from '../../src/types'; + +function doc(text: string) { + return new TextDocument('inmemory://1', text, 'plain'); +} + +function caret(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, + [caret(line, character)], + [caret(line, character + text.length)], + undoBoundary + ); +} + +// 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; +} + +describe('TextDocument history selection restore (codemirror-legacy)', () => { + // codemirror-legacy: cm-commands/test/test-history.ts — "puts the cursor after the change on redo" + // 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, + [caret(0, 3)], + [caret(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([caret(0, 3)]); + + const redoResult = d.redo(); + expect(d.getText()).toBe('red!\n\nblue'); + expect(redoResult?.[1]).toEqual([caret(0, 4)]); + }); + + // codemirror-legacy: cm-commands/test/test-history.ts — "restores selection on undo-redo-undo" + // 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, + [caret(1, 3)], + [caret(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([caret(1, 3)]); + expect(d.getText()).toBe('ash\noak\nelm'); + + expect(d.redo()?.[1]).toEqual([caret(1, 4)]); + expect(d.getText()).toBe('ash\noak.\nelm'); + + expect(d.undo()?.[1]).toEqual([caret(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([caret(1, 4)]); + expect(d.undo()?.[1]).toEqual([caret(1, 3)]); + }); + + // codemirror-legacy: cm-commands/test/test-history.ts — "restores the selection before the first change in an item (#46)" + // Three keystrokes coalesce into one undo entry; the merged entry must keep + // the selectionsBefore of the FIRST keystroke (CodeMirror regression #46 + // restored an intermediate caret instead). + 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([caret(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([caret(0, 3)]); + }); + + // codemirror-legacy: cm-commands/test/test-history.ts — "doesn't merge document changes if there's a selection change in between" + // DIVERGENCE: CodeMirror 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 sibling + // monaco suite (editStack.monaco.test.ts) pins 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([caret(0, 0)]); + }); + + // codemirror-legacy: cm-commands/test/test-history.ts — "doesn't merge document changes if there's a selection change in between" + // The CM 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, + }, + ], + [caret(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); + }); + + // codemirror-legacy: cm-commands/test/test-history.ts — "can go back and forth through history multiple times" + // 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, + [caret(0, 12)], + [caret(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); + } + }); + + // codemirror-legacy: cm-commands/test/test-history.ts — "restores selection on redo" + // 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 = [caret(0, 2), caret(1, 3), caret(2, 3)]; + const selectionsAfter = [caret(0, 3), caret(1, 4), caret(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); + }); +}); diff --git a/packages/diffs/test/codemirror-legacy-tests/historyRemote.cm.test.ts b/packages/diffs/test/codemirror-legacy-tests/historyRemote.cm.test.ts new file mode 100644 index 000000000..991e156d1 --- /dev/null +++ b/packages/diffs/test/codemirror-legacy-tests/historyRemote.cm.test.ts @@ -0,0 +1,296 @@ +// Undo/redo interacting with non-history edits (updateHistory=false), ported +// from CodeMirror's history suite. See README.md in this directory for suite +// conventions. +// +// Root cause shared by every test.failing below: EditStack entries are frozen +// at creation. When an edit is applied with updateHistory=false (the default +// for Editor.applyEdits — collaborative patches, programmatic fixes, etc.), +// nothing remaps the offsets stored in existing undo/redo entries, so a later +// undo()/redo() applies its inverse/forward edits at stale offsets and +// corrupts the buffer. CodeMirror rebases every stored history item (and its +// selections) through each non-history transaction. The failing tests pin +// distinct surfaces of that one missing mechanism: single-entry undo, the +// dead-entry no-op, batch inversion round trips, interior-insert splitting, +// stored-selection remapping, and the redo direction. +import { describe, expect, test } from 'bun:test'; + +import { DirectionNone } from '../../src/editor/selection'; +import { TextDocument } from '../../src/editor/textDocument'; +import type { EditorSelection, TextEdit } from '../../src/types'; + +function doc(text: string) { + return new TextDocument('inmemory://1', text, 'plain'); +} + +function caret(line: number, character: number) { + const position = { line, character }; + return { + start: position, + end: position, + direction: DirectionNone, + } satisfies EditorSelection; +} + +// Every fixture in this file is single-line, so a character index on line 0 is +// also the flat document offset — which keeps the translation from +// CodeMirror's offset-based scenarios direct. +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 ?? [caret(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)]); +} + +describe('undo/redo across non-history edits (codemirror-legacy)', () => { + // codemirror-legacy: cm-commands/test/test-history.ts — "allows to undo a change" / "supports non-tracked changes next to tracked changes" + // Baseline sanity: a non-history edit strictly AFTER the tracked range does + // not shift its stored offsets, so undo restores exactly the tracked change + // even today. The failing tests below differ only in putting the remote edit + // at/before the tracked offsets. + 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'); + }); + + // codemirror-legacy: cm-commands/test/test-history.ts — "allows changes that aren't part of the history" + // KNOWN BUG: undo applies the stored inverse edit at its original offsets + // without remapping through the two non-history inserts, so it deletes the + // remote prefix plus part of the remote-shifted typed text instead of the + // typed text itself (actual today: "ilot?"). + test.failing( + 'undo reverts only the tracked change, leaving non-history text intact', + () => { + const d = doc(''); + localEdit(d, 0, 0, 'pilot'); // tracked typing + remoteEdit(d, 0, 0, 'sync'); // remote prefix: "syncpilot" + remoteEdit(d, 9, 9, '?'); // remote suffix: "syncpilot?" + expect(d.getText()).toBe('syncpilot?'); + + d.undo(); + expect(d.getText()).toBe('sync?'); + } + ); + + // codemirror-legacy: cm-commands/test/test-history.ts — "doesn't get confused by an undo not adding any redo item" + // KNOWN BUG: when a non-history edit has replaced the entire region a + // history entry covers, the entry is dead — undo must be a graceful no-op + // (and must not leave behind a redo item that re-corrupts). Today undo + // applies the stale inverse range to the replacement text ("core" -> "ce") + // and redo then splices the old typed text back in ("cGHe"). + test.failing( + 'undo is a graceful no-op when a non-history edit wiped the tracked region', + () => { + const d = doc('ok'); + localEdit(d, 1, 1, 'GH'); // tracked: "oGHk" + remoteEdit(d, 0, 4, 'core'); // remote replaces the whole doc + expect(d.getText()).toBe('core'); + + d.undo(); + expect(d.getText()).toBe('core'); + + // Whether the dead entry is dropped or kept, redo must not corrupt. + if (d.canRedo) { + d.redo(); + } + expect(d.getText()).toBe('core'); + } + ); + + // codemirror-legacy: cm-commands/test/test-history.ts — "accurately maps changes through each other" + // KNOWN BUG: a batch entry stores per-sub-edit inverse offsets chained + // through the batch's own deltas, but none of them are remapped through the + // later non-history insert that landed between the sub-edits. Undo then + // treats the remote text as if it were the tracked replacements (actual + // today: undo -> "pqrWXYZ", redo -> "UVWXYZWXYZ"). + test.failing( + 'a replacement batch round-trips through undo/redo across a non-history insert', + () => { + 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, + [caret(0, 3)] + ); + expect(d.getText()).toBe('UVWXYZ'); + + // Remote insert exactly between the first and second replacements. + remoteEdit(d, 2, 2, '####'); + expect(d.getText()).toBe('UV####WXYZ'); + + // Undo reverts the three replacements around the remote text. + d.undo(); + expect(d.getText()).toBe('p####qr'); + + // Redo restores the exact pre-undo document. + d.redo(); + expect(d.getText()).toBe('UV####WXYZ'); + } + ); + + // codemirror-legacy: cm-commands/test/test-history.ts — "preserves text inserted inside a change" + // KNOWN BUG: undoing a tracked insertion must split its deletion around + // non-history text that was inserted INSIDE the inserted range, deleting + // only the tracked characters. Today the whole stale range [0,4) is deleted + // from "WXjYZ", which erases the remote "j" and strands a tracked "Z" + // (actual today: "Z"). + test.failing( + 'non-history text inserted inside a tracked insertion survives undo', + () => { + const d = doc(''); + localEdit(d, 0, 0, 'WXYZ'); // tracked insertion + remoteEdit(d, 2, 2, 'j'); // remote insert in the middle of it + expect(d.getText()).toBe('WXjYZ'); + + d.undo(); + expect(d.getText()).toBe('j'); + } + ); + + // codemirror-legacy: cm-commands/test/test-history.ts — "properly maps selections through non-history changes" / "rebases selection on undo" + // KNOWN BUG: the selections stored in a history entry must be remapped + // through non-history edits before being restored. Geometry here is chosen + // so the BUFFER restore is exact (the tracked delete sits at offset 0, + // unshifted by the later remote insert) and only the selection contract is + // under test: the caret that sat past the remote insert's position must + // shift by its length, the one before it must not. Today the entry's + // selectionsBefore come back verbatim (6 and 11 instead of 6 and 13). + test.failing( + 'history-entry selections are remapped through later non-history edits before restore', + () => { + const d = doc('hello world'); + // Tracked delete of the leading word, with two carets recorded. + d.applyEdits([lineEdit(0, 5, '')], true, [caret(0, 6), caret(0, 11)]); + expect(d.getText()).toBe(' world'); + + // Remote 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'); + + const result = d.undo(); + expect(d.getText()).toBe('hello worXYld'); + const selections = result?.[1]; + expect(selections?.map((s) => s.start.character)).toEqual([6, 13]); + } + ); + + // codemirror-legacy: cm-commands/test/test-history.ts — "can group events around a non-history transaction" + // Two keystrokes separated by an interleaved non-history insert still + // coalesce into one undo group, and the group inverts around the remote + // character: one undo leaves exactly the remote text. Note this currently + // works because the coalescing adjacency check (next insert starts at the + // previous entry's inverse end) happens to line up in this geometry, not + // because entries are remapped through remote edits. + test('adjacent typing coalesces into one undo group across a non-history edit', () => { + const d = doc(''); + localEdit(d, 0, 0, 'a'); // tracked keystroke: "a" + remoteEdit(d, 1, 1, 'b'); // remote: "ab" + localEdit(d, 1, 1, 'c'); // tracked keystroke right after the "a": "acb" + expect(d.getText()).toBe('acb'); + + // Both keystrokes revert in a single undo step, keeping the remote "b". + d.undo(); + expect(d.getText()).toBe('b'); + expect(d.canUndo).toBe(false); + + // Redo direction: the coalesced group replays around the remote text. + d.redo(); + expect(d.getText()).toBe('acb'); + }); + + // codemirror-legacy: cm-commands/test/test-history.ts — "supports querying for the undo and redo depth" + // Non-history edits must neither consume undo entries nor clear the redo + // stack (clearRedo only fires when a new history entry is pushed). Geometry + // keeps every remote edit after the tracked offsets so the surviving + // entries also APPLY correctly today; the mapped-offset failure is split + // into the next test. + 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); + }); + + // codemirror-legacy: cm-commands/test/test-history.ts — "supports querying for the undo and redo depth" + // KNOWN BUG: the redo entry correctly survives a non-history edit (previous + // test), but its forward edits are never remapped through it, so redo + // re-inserts at the stale offset (actual today: ">> n!ote" — the "!" lands + // mid-word instead of at the end it was typed at). + test.failing( + 'a surviving redo entry applies at offsets mapped through the non-history edit', + () => { + 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, '>> '); // remote prefix while redo is pending + expect(d.getText()).toBe('>> note'); + expect(d.canRedo).toBe(true); + + d.redo(); + expect(d.getText()).toBe('>> note!'); + } + ); +}); diff --git a/packages/diffs/test/codemirror-legacy-tests/pieceTable.cm.test.ts b/packages/diffs/test/codemirror-legacy-tests/pieceTable.cm.test.ts new file mode 100644 index 000000000..74b8f4e2b --- /dev/null +++ b/packages/diffs/test/codemirror-legacy-tests/pieceTable.cm.test.ts @@ -0,0 +1,527 @@ +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'; + +// Rope/buffer semantics harvested from CodeMirror 6's Text tests (cm-state +// test-text.ts) and re-expressed against PieceTable and TextDocument. CM +// addresses documents by flat offsets; scenarios here are translated to +// 0-based {line, character} positions. See README.md for provenance. + +function doc(text: string) { + return new TextDocument('inmemory://1', text, 'plain'); +} + +// 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` (counted as +// ONE break) — the same policy as computeLineOffsets. 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] }; +} + +// 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('mixed-EOL positionAt/offsetAt round trip (codemirror-legacy)', () => { + // codemirror-legacy: cm-state/test/test-text.ts — "can get line info by position" + test('PieceTable: offsetAt of positionAt is the identity at every offset, even inside CRLF pairs', () => { + // DIVERGENCE: CM (and VS Code) 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); + } + }); + + // codemirror-legacy: cm-state/test/test-text.ts — "can get line info by position" + 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. CM 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); + }); + + // codemirror-legacy: cm-state/test/test-text.ts — "can get line info by position" + // 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) + ); + } + } + ); + + // codemirror-legacy: cm-state/test/test-text.ts — "can get line info by position" + 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 stability CM guarantees for its + // 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 (codemirror-legacy)', () => { + // codemirror-legacy: cm-state/test/test-text.ts — "can get line info by line number" + test('PieceTable.offsetAt: negative line maps to 0, line at or past lineCount throws', () => { + // DIVERGENCE: CM throws "Invalid line" on BOTH sides (line(0) and + // line(lines + 1) with its 1-based lines). 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' + ); + }); + + // codemirror-legacy: cm-state/test/test-text.ts — "can get line info by position" + test('TextDocument.offsetAt silently clamps every out-of-range position', () => { + // DIVERGENCE: CM's lineAt(-10) and lineAt(length + 1) throw "Invalid + // position". 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); + }); + + // codemirror-legacy: cm-state/test/test-text.ts — "can get line info by line number" + 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. CM 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 (codemirror-legacy)', () => { + // codemirror-legacy: cm-state/test/test-text.ts — "can retrieve pieces of text" + 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 (codemirror-legacy)', () => { + // codemirror-legacy: cm-state/test/test-text.ts — "can build up a doc by repeated appending" + 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 (codemirror-legacy)', () => { + // codemirror-legacy: cm-state/test/test-text.ts — "rebalances on insert" + 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 (codemirror-legacy)', () => { + // codemirror-legacy: cm-state/test/test-text.ts — "clips out-of-range boundaries" + 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(''); + }); + + // codemirror-legacy: cm-state/test/test-text.ts — "clips out-of-range boundaries" + test('TextDocument.getText returns empty for an inverted range while applyEdits swaps it', () => { + // DIVERGENCE (layer contrast): CM clips inverted slice bounds to empty + // and rejects 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 (codemirror-legacy)', () => { + // codemirror-legacy: cm-state/test/test-text.ts — "properly maintains content during editing" + // (adapted: 50k chars with zero line breaks, fragmented, then split in two) + 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, + }); + }); + + // codemirror-legacy: cm-state/test/test-text.ts — "can get line info by position" + // (adapted: huge character values against a single 50k-char line) + 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/codemirror-legacy-tests/remapSelections.cm.test.ts b/packages/diffs/test/codemirror-legacy-tests/remapSelections.cm.test.ts new file mode 100644 index 000000000..f6c2d67ac --- /dev/null +++ b/packages/diffs/test/codemirror-legacy-tests/remapSelections.cm.test.ts @@ -0,0 +1,255 @@ +import { describe, expect, test } from 'bun:test'; + +import { + DirectionBackward, + DirectionForward, + DirectionNone, + remapSelectionsAfterEdits, +} from '../../src/editor/selection'; +import type { ResolvedTextEdit } from '../../src/editor/textDocument'; +import { TextDocument } from '../../src/editor/textDocument'; +import type { EditorSelection, SelectionDirection } from '../../src/types'; + +function doc(text: string) { + return new TextDocument('inmemory://1', text, 'plain'); +} + +// 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 (codemirror-legacy)', () => { + // codemirror-legacy: cm-state/test/test-change.ts — "stays on its own side of replacements" + test('caret at the start boundary of a replaced range lands after the replacement', () => { + // DIVERGENCE: CodeMirror maps a position sitting exactly at the start of a + // replaced span back to the span's start under BOTH assoc -1 and assoc 1 + // (mapPos never moves it 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 versus + // CM, 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'); + // CM would report 7 (the replacement start); pierre reports 10, after 'fig'. + expect(offsets).toEqual([10, 10]); + expect(result.direction).toBe(DirectionNone); + }); + + // codemirror-legacy: cm-state/test/test-change.ts — "stays on its own side of replacements" + 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 CM's + // assoc-aware mapPos 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]); + }); + + // codemirror-legacy: cm-state/test/test-change.ts — "stays in between replacements" + test('caret between two adjacent replacements lands after the second one', () => { + // DIVERGENCE: CM keeps a caret at the seam of two touching replacements + // exactly at that seam (mapPos returns the boundary for assoc -1 and 1). + // 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'); + // CM 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 (codemirror-legacy)', () => { + const preText = 'stormcloud'; + // Selection over offsets [3, 7] — the letters 'rmcl'. + const pair: [number, number] = [3, 7]; + + // codemirror-legacy: cm-state/test/test-change.ts — "maps through an insertion" + test('insertion exactly at the selection end is absorbed into the selection', () => { + // DIVERGENCE: CM maps a non-empty range's edges with outward bias (from + // with assoc 1, to with assoc -1), 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); + }); + + // codemirror-legacy: cm-state/test/test-change.ts — "maps through an insertion" + 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 CM's outward-bias mapping of `from`.) + 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 (codemirror-legacy)', () => { + // Deleting offsets [3, 7) — the letters 'rotc' of 'carrotcake'. + const preText = 'carrotcake'; + const edits: ResolvedTextEdit[] = [{ start: 3, end: 7, text: '' }]; + + // codemirror-legacy: cm-state/test/test-change.ts — "maps through deletion" + test('carets at deletion start, strictly inside, and at deletion end all converge to the deletion start', () => { + // Matches CM's plain mapPos (no MapMode): every position touching the + // deleted span collapses to where the span used to begin. CM can still + // distinguish the three via TrackDel/TrackBefore/TrackAfter map 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]); + }); + + // codemirror-legacy: cm-state/test/test-change.ts — "maps through deletion" + 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 (codemirror-legacy)', () => { + // codemirror-legacy: cm-state/test/test-change.ts — "maps through mixed edits" + 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'); + }); + + // codemirror-legacy: cm-state/test/test-change.ts — "maps through multiple insertions" + test('edits must be sorted ascending by start: unsorted input silently drops earlier edits', () => { + // DIVERGENCE / contract pin: CM's ChangeSet.of accepts change specs in any + // order and normalizes them internally, so mapPos always sees every change. + // 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]); + }); +}); diff --git a/packages/diffs/test/codemirror-legacy-tests/selection.cm.test.ts b/packages/diffs/test/codemirror-legacy-tests/selection.cm.test.ts new file mode 100644 index 000000000..95ab5f1c9 --- /dev/null +++ b/packages/diffs/test/codemirror-legacy-tests/selection.cm.test.ts @@ -0,0 +1,346 @@ +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 { + applyTextReplaceToSelections, + DirectionBackward, + DirectionForward, + DirectionNone, + mergeOverlappingSelections, +} from '../../src/editor/selection'; +import { TextDocument } 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(); +}); + +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 }; +} + +// CodeMirror addresses selections by flat anchor/head offsets. All the pure +// merge scenarios below live on line 0, so `character` doubles as the flat +// offset from the CodeMirror originals. +function sel( + startCharacter: number, + endCharacter: number, + direction: SelectionDirection = DirectionForward +): EditorSelection { + return { + start: { line: 0, character: startCharacter }, + end: { line: 0, character: endCharacter }, + direction, + }; +} + +describe('caret at the shared boundary of touching ranges (codemirror-legacy)', () => { + // codemirror-legacy: cm-state/test/test-selection.ts — "merges adjacent point ranges when normalizing" + 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 CodeMirror 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), + ]); + }); + + // codemirror-legacy: cm-state/test/test-selection.ts — "merges adjacent point ranges when normalizing" + 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. CodeMirror 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 (codemirror-legacy)', () => { + // codemirror-legacy: cm-state/test/test-selection.ts — "merges and sorts ranges when normalizing" + 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 CodeMirror normalizes to. + 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: CodeMirror returns ranges re-sorted by position + // (0/6,6/7,7/8,9/13,13/14) and tracks the primary via a separate + // mainIndex. 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 (codemirror-legacy)', () => { + // codemirror-legacy: cm-state/test/test-state.ts — "does the right thing when there are multiple selections" + 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. CodeMirror's changeByRange + // reports the remapped 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 + } + }); + + // codemirror-legacy: cm-state/test/test-state.ts — "does the right thing when there are multiple selections" + 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 (codemirror-legacy)', () => { + // codemirror-legacy: cm-state/test/test-state.ts — "throws when a change's bounds are invalid" + // (selection analog: CodeMirror's checkSelection throws RangeError + // "Selection points outside of document" for the same out-of-bounds input on + // state creation/update.) + test('positions past a line length or past the last line clamp instead of throwing', async () => { + // DIVERGENCE: CodeMirror rejects 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(); + } + }); + + // codemirror-legacy: cm-state/test/test-state.ts — "throws when a change's bounds are invalid" + 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 (codemirror-legacy)', () => { + // codemirror-legacy: cm-state/test/test-selection.ts — "stores ranges with a primary 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. CodeMirror's + // EditorSelection.range(3, 2) normalizes 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(); + } + } + ); +}); diff --git a/packages/diffs/test/monaco-legacy-tests/README.md b/packages/diffs/test/monaco-legacy-tests/README.md new file mode 100644 index 000000000..c9bf62ab6 --- /dev/null +++ b/packages/diffs/test/monaco-legacy-tests/README.md @@ -0,0 +1,79 @@ +# monaco-legacy-tests + +Behavioral test scenarios harvested from the Monaco editor's core test suite and +re-expressed against `@pierre/diffs`' edit APIs. Scope: text editing, selection +& multi-cursor, and undo/redo. IME/composition scenarios are deferred. + +## Provenance & attribution + +Monaco's editor core lives in +[microsoft/vscode](https://github.com/microsoft/vscode) (the `monaco-editor` +repo is packaging only). Scenarios here were adapted from vscode's test suite at +commit `86f5a62f058e3905f74a9fa65d04b2f3b533408e` (the `vscodeRef` pinned by +monaco-editor at the time of the audit). vscode is MIT-licensed, © Microsoft +Corporation. + +These are behavioral rewrites, not code copies: each test re-expresses a +scenario (input text, operation, expected text/selections) in this package's own +test idiom. Every test carries a traceability comment: + +```ts +// monaco-legacy: src/vs/editor/test/common/model/textModel.test.ts — "" +``` + +## Conventions + +- vscode positions are 1-based (`line`/`column`); ours are 0-based LSP-style + (`line`/`character`). All coordinates here are already translated. +- `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 mark places where `@pierre/diffs` intentionally (or at + least knowingly) behaves differently from vscode. Those tests pin _our_ + behavior and document the difference; they are decisions, not bugs. + +## Known bugs encoded as `test.failing` (10) + +**EditStack coalescing** (`editStack.monaco.test.ts`, 3) — root cause: +`shouldCoalesceEditStackEntry()` in `src/editor/editStack.ts` compares a new +edit against whatever entry sits on top of the undo stack, purely by geometry, +with no state reset after `undo()`/`redo()`. + +1. After `undo()` pops an entry, new typing can coalesce with the newly exposed + (pre-undo) top entry, fusing old history into the new edit. +2. `undoBoundary` entries stop blocking merges once they are undone. +3. Backspace followed by forward-delete at the same pivot coalesces into one + undo step (vscode guarantees an undo stop when delete direction flips). + +**Surrogate-pair edit boundaries** (`applyEdits.monaco.test.ts`, 3) — edit range +endpoints landing strictly inside a surrogate pair split the pair and corrupt +the buffer; vscode auto-widens/snaps such ranges to pair boundaries. Affects +insert inside a pair and replaces starting or ending inside one (`TextDocument` +`#resolveEdit`/`normalizePosition` have no surrogate-aware clamping). + +**PieceTable CRLF line metadata** (`pieceTable.monaco.test.ts`, 4) — line-break +bookkeeping goes stale when a `\r\n` pair is split or assembled across edits: +deleting exactly the `\n` of a pair, inserting between the `\r` and `\n`, +assembling `\r\n` from two separate inserts, plus a CRLF-biased fuzz oracle that +catches the general class. Text content (`getText()`) stays correct; +`lineCount`/`positionAt` metadata does not. + +## Consciously out of scope (missing features, not missing tests) + +vscode has extensive tests for these; if the feature is ever built, start from +the referenced suites: + +- Forward word-delete family (`DeleteWordRight`, `DeleteWordStartRight`, + `DeleteWordEndRight`) — + `src/vs/editor/contrib/wordOperations/test/browser/wordOperations.test.ts` +- Vim-style `deleteInsideWord` — same file +- `JoinLines` (Ctrl+J) whitespace-collapsing semantics — + `src/vs/editor/contrib/linesOperations/test/browser/linesOperations.test.ts` +- Transpose crossing _forward_ over a line break at end-of-line + (`applyTransposeToSelections` has no forward-crossing branch) — + `linesOperations.test.ts` TransposeAction suite +- IME composition ↔ undo-coalescing interaction (deferred with the rest of IME) +- Vertical cursor movement preserving the _visual_ column across full-width CJK + lines (vscode issue #22717) — exercises `#lastAccessedCharX` in `editor.ts` + and `moveBySoftLine`, which need canvas text-measure stubbing; deferred, not + skipped on merit diff --git a/packages/diffs/test/monaco-legacy-tests/applyEdits.monaco.test.ts b/packages/diffs/test/monaco-legacy-tests/applyEdits.monaco.test.ts new file mode 100644 index 000000000..63eb8429b --- /dev/null +++ b/packages/diffs/test/monaco-legacy-tests/applyEdits.monaco.test.ts @@ -0,0 +1,237 @@ +import { describe, expect, test } from 'bun:test'; + +import { DirectionNone } from '../../src/editor/selection'; +import { TextDocument } from '../../src/editor/textDocument'; +import type { EditorSelection, TextEdit } from '../../src/types'; + +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 select( + startLine: number, + startCharacter: number, + endLine: number, + endCharacter: number +): EditorSelection { + return { + start: { line: startLine, character: startCharacter }, + end: { line: endLine, character: endCharacter }, + direction: DirectionNone, + }; +} + +// 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; +} + +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 vscode's + // model auto-corrects so the pair is never split. + + // monaco-legacy: src/vs/editor/test/common/model/editableTextModel.test.ts — "high-low surrogates 1" + // 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'); + } + ); + + // monaco-legacy: src/vs/editor/test/common/model/editableTextModel.test.ts — "high-low surrogates 2" + // 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'); + } + ); + + // monaco-legacy: src/vs/editor/test/common/model/editableTextModel.test.ts — "high-low surrogates 3" + // 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'); + } + ); + + // monaco-legacy: src/vs/editor/test/common/model/editableTextModel.test.ts — "high-low surrogates 4" + 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', () => { + // monaco-legacy: src/vs/editor/test/common/model/editableTextModel.test.ts — "touching edits: two inserts at the same position" + 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', () => { + // monaco-legacy: src/vs/editor/test/common/model/editableTextModel.test.ts — "issue #48741: Broken undo stack with move lines up with multiple cursors" + 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); + }); + + // monaco-legacy: src/vs/editor/test/browser/controller/cursor.test.ts — "issue #93585: Undo multi cursor edit corrupts document" + 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', () => { + // monaco-legacy: src/vs/editor/test/common/model/editableTextModel.test.ts — "issue #47733: Undo mangles unicode characters" + 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); + }); + + // monaco-legacy: src/vs/editor/test/browser/controller/cursor.test.ts — "issue #47733: Undo mangles unicode characters" + 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); + }); +}); diff --git a/packages/diffs/test/monaco-legacy-tests/editStack.monaco.test.ts b/packages/diffs/test/monaco-legacy-tests/editStack.monaco.test.ts new file mode 100644 index 000000000..d60d7117c --- /dev/null +++ b/packages/diffs/test/monaco-legacy-tests/editStack.monaco.test.ts @@ -0,0 +1,241 @@ +// Undo/redo coalescing scenarios ported from Monaco/vscode. See README.md in +// this directory for suite conventions. The three test.failing entries here are +// the P0 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. +import { describe, expect, test } from 'bun:test'; + +import { DirectionNone } from '../../src/editor/selection'; +import { TextDocument } from '../../src/editor/textDocument'; +import type { EditorSelection } from '../../src/types'; + +function doc(text: string) { + return new TextDocument('inmemory://1', text, 'plain'); +} + +function caret(line: number, character: number) { + const position = { line, character }; + return { + start: position, + end: position, + direction: DirectionNone, + } satisfies EditorSelection; +} + +// Inserts `text` at the caret with history recording enabled, like a single +// keystroke (or a paste when `undoBoundary` is set). +function typeAt( + d: ReturnType, + line: number, + character: number, + text: string, + undoBoundary = false +) { + d.applyEdits( + [ + { + range: { + start: { line, character }, + end: { line, character }, + }, + newText: text, + }, + ], + true, + [caret(line, character)], + undefined, + 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, + [caret(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, + [caret(line, caretChar)] + ); +} + +describe('EditStack coalescing across undo/redo (monaco-legacy)', () => { + // monaco-legacy: src/vs/editor/common/cursor/cursor.ts — "onModelContentChanged resets _prevEditOperationType, forcing an undo stop after every undo/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); + } + ); + + // monaco-legacy: src/vs/editor/common/cursor/cursorTypeOperations.ts — "paste pushes an undo stop before and after; the stop is durable, not consumed by undo" + // 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); + } + ); + + // monaco-legacy: src/vs/editor/test/browser/controller/cursor.test.ts — "there is an undo stop between deleting left and deleting right" + // 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); + } + ); + + // monaco-legacy: src/vs/editor/test/browser/controller/cursor.test.ts — "there is no undo stop after a single whitespace" + // DIVERGENCE: vscode 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; vscode's 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); + }); + + // monaco-legacy: src/vs/editor/test/browser/controller/cursor.test.ts — "there is a single undo stop for consecutive whitespaces" + // DIVERGENCE: vscode 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); + }); +}); diff --git a/packages/diffs/test/monaco-legacy-tests/pieceTable.monaco.test.ts b/packages/diffs/test/monaco-legacy-tests/pieceTable.monaco.test.ts new file mode 100644 index 000000000..0c9073aad --- /dev/null +++ b/packages/diffs/test/monaco-legacy-tests/pieceTable.monaco.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, test } from 'bun:test'; + +import { PieceTable } from '../../src/editor/pieceTable'; +import type { Position } from '../../src/types'; + +// 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; +} + +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 }) + ); + } + } +} + +// 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; + }; +} + +describe('PieceTable CRLF and lone-CR line breaks (monaco-legacy)', () => { + // monaco-legacy: src/vs/editor/test/common/model/model.test.ts — "Bug 13333:Model should line break on lonely CR too" + test('lone \\r mixed with \\r\\n breaks reads back byte-for-byte untouched', () => { + // DIVERGENCE: vscode's getValue() rewrites a lone \r to the document's + // dominant EOL on read (the model normalizes line endings). 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); + }); + + // monaco-legacy: src/vs/editor/test/common/model/model.test.ts — "Bug 13333:Model should line break on lonely CR too" + test('lone \\r mixed with \\r\\n breaks still counts as its own line break', () => { + // The half of Bug 13333 pierre-fe shares with vscode: 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); + }); + + // monaco-legacy: src/vs/editor/test/common/model/pieceTreeTextBuffer/pieceTreeTextBuffer.test.ts — "delete CR in CRLF 1" + 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); + }); + + // monaco-legacy: src/vs/editor/test/common/model/pieceTreeTextBuffer/pieceTreeTextBuffer.test.ts — "delete CR in CRLF 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); + } + ); + + // monaco-legacy: src/vs/editor/test/common/model/pieceTreeTextBuffer/pieceTreeTextBuffer.test.ts — CRLF suite "random bug 1"-"random bug 10" (minimal directed repro) + // 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); + } + ); + + // monaco-legacy: src/vs/editor/test/common/model/pieceTreeTextBuffer/pieceTreeTextBuffer.test.ts — CRLF suite "random bug 1"-"random bug 10" (minimal directed repro) + // 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); + } + ); + + // monaco-legacy: src/vs/editor/test/common/model/pieceTreeTextBuffer/pieceTreeTextBuffer.test.ts — CRLF suite "random bug 1"-"random bug 10" + // 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); + } + } + ); +}); diff --git a/packages/diffs/test/monaco-legacy-tests/selection.monaco.test.ts b/packages/diffs/test/monaco-legacy-tests/selection.monaco.test.ts new file mode 100644 index 000000000..35b1fb77e --- /dev/null +++ b/packages/diffs/test/monaco-legacy-tests/selection.monaco.test.ts @@ -0,0 +1,249 @@ +import { describe, expect, test } from 'bun:test'; + +import { + applyDeleteCharacterToSelections, + applyTextChangeToSelections, + applyTextReplaceToSelections, + DirectionForward, + DirectionNone, + findNexMatch, + getAutoSurroundReplacementTexts, + mergeOverlappingSelections, + resolveDeleteCharacterRange, +} from '../../src/editor/selection'; +import { TextDocument } from '../../src/editor/textDocument'; +import type { EditorSelection, SelectionDirection } from '../../src/types'; + +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 }; +} + +function sel( + startLine: number, + startCharacter: number, + endLine: number, + endCharacter: number, + direction: SelectionDirection = DirectionForward +): EditorSelection { + return { + start: { line: startLine, character: startCharacter }, + end: { line: endLine, 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 (monaco-legacy)', () => { + // monaco-legacy: src/vs/editor/test/browser/controller/cursor.test.ts — "issue #99629: Emoji modifiers in text treated separately when using backspace (ZWJ sequence)" + test('backspace removes a whole ZWJ family emoji without splitting the cluster', () => { + // DIVERGENCE: vscode 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 the vscode 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)]); + }); + + // monaco-legacy: src/vs/editor/test/browser/controller/cursor.test.ts — "issue #99629: Emoji modifiers in text treated separately when using backspace" + 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)]); + }); + + // monaco-legacy: src/vs/editor/test/browser/controller/cursor.test.ts — "issue #84897: Left delete behavior in some languages is changed" / "issue #122914: Left delete behavior in some languages is changed (useTabStops: false)" + test('backspace steps over Thai combining marks one grapheme cluster at a time', () => { + // DIVERGENCE: vscode deliberately deletes one UTF-16 code unit per + // Backspace in combining-mark scripts (issues #84897/#122914 were filed by + // Thai users who expect to erase a tone/vowel mark without losing the base + // consonant; vscode's 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 (monaco-legacy)', () => { + // monaco-legacy: src/vs/editor/contrib/multicursor/test/browser/multicursor.test.ts — "issue #6661: AddSelectionToNextFindMatchAction can work with touching ranges" + test('finds a repeat that touches the current selection with zero gap', () => { + const d = doc('abcabc'); + const first = sel(0, 0, 0, 3); + + // 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, sel(0, 3, 0, 6)]); + + // Both occurrences are now held; nothing is left to add. + expect(findNexMatch(d, next!)).toBeUndefined(); + }); + + // monaco-legacy: src/vs/editor/contrib/multicursor/test/browser/multicursor.test.ts — "issue #6661: AddSelectionToNextFindMatchAction can work with touching ranges" + test('repeated next-occurrence walks through touching matches across lines', () => { + const d = doc('rowrow\nrow\nrowrow'); + let selections: EditorSelection[] | undefined = [sel(0, 0, 0, 3)]; + + const expected = [ + sel(0, 3, 0, 6), // touching repeat on the same line + sel(1, 0, 1, 3), + sel(2, 0, 2, 3), + sel(2, 3, 2, 6), // 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 (monaco-legacy)', () => { + // monaco-legacy: src/vs/editor/test/browser/controller/cursor.test.ts — "issue #37967: problem replacing consecutive characters" + test('carets that converge via backspace merge and type the next character once', () => { + // vscode's regression test runs with multiCursorMergeOverlapping:false to + // reproduce the double-insert users saw; with its default (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 (monaco-legacy)', () => { + // monaco-legacy: src/vs/editor/test/browser/controller/cursor.test.ts — "issue #47733: Undo mangles unicode characters" + 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 = [sel(0, 0, 0, 1)]; + + 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([sel(0, 1, 0, 2)]); + + // 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('"🦉"'); + }); + + // monaco-legacy: src/vs/editor/test/browser/controller/cursor.test.ts — "issue #47733: Undo mangles unicode characters" + 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 = [sel(0, 3, 0, 4)]; + + const texts = getAutoSurroundReplacementTexts(d, selections, '('); + expect(texts).toEqual(['(")']); + + const { nextSelections } = applyTextReplaceToSelections( + d, + selections, + texts! + ); + expect(d.getText()).toBe('"🦉(")'); + expect(nextSelections).toEqual([sel(0, 4, 0, 5)]); + + d.undo(); + expect(d.getText()).toBe('"🦉"'); + }); +}); diff --git a/packages/diffs/test/monaco-legacy-tests/textDocument.monaco.test.ts b/packages/diffs/test/monaco-legacy-tests/textDocument.monaco.test.ts new file mode 100644 index 000000000..666ad5267 --- /dev/null +++ b/packages/diffs/test/monaco-legacy-tests/textDocument.monaco.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, test } from 'bun:test'; + +import { TextDocument } from '../../src/editor/textDocument'; +import type { TextEdit } from '../../src/types'; + +function doc(text: string) { + return new TextDocument('inmemory://1', text, 'plain'); +} + +// 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 (monaco-legacy)', () => { + // monaco-legacy: src/vs/editor/test/common/model/textModel.test.ts — "multiline text" + test('eol detection uses the first line break, not a whole-file majority vote', () => { + // DIVERGENCE: vscode's TextModelData.fromString 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); + // vscode (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'); + // vscode (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'); + }); + + // monaco-legacy: src/vs/editor/test/common/model/editableTextModel.test.ts — "issue #2586 Replacing selected end-of-line with newline locks up the document" + 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, per the original vscode + // regression, the model did not lock up computing the change). + 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); + }); + + // monaco-legacy: src/vs/editor/test/common/model/editableTextModel.test.ts — "issue #2586 Replacing selected end-of-line with newline locks up the document" + 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); + }); + + // monaco-legacy: src/vs/editor/test/common/model/editableTextModel.test.ts — "issue #2586 Replacing selected end-of-line with newline locks up the document" + 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'); + }); + + // monaco-legacy: src/vs/editor/test/common/model/textModel.test.ts — "validatePosition" + test('normalizePosition clamps both coordinates when line and character overshoot together', () => { + // vscode: validatePosition(new Position(30, 30)) on a 2-line model lands + // at the end of the last line — the character must be 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, + }); + }); + + // monaco-legacy: src/vs/editor/test/common/model/textModel.test.ts — "validatePosition" + 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, + }); + }); + + // monaco-legacy: src/vs/editor/test/common/model/textModel.test.ts — "validatePosition" + 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, + }); + }); +}); diff --git a/packages/diffs/test/monaco-legacy-tests/wordOperations.monaco.test.ts b/packages/diffs/test/monaco-legacy-tests/wordOperations.monaco.test.ts new file mode 100644 index 000000000..5b0094901 --- /dev/null +++ b/packages/diffs/test/monaco-legacy-tests/wordOperations.monaco.test.ts @@ -0,0 +1,314 @@ +import { describe, expect, test } from 'bun:test'; + +import { + applyDeleteWordBackwardToSelections, + DirectionForward, + DirectionNone, + expandCollapsedSelectionToWord, +} from '../../src/editor/selection'; +import { TextDocument } from '../../src/editor/textDocument'; +import type { EditorSelection } from '../../src/types'; + +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, + } satisfies EditorSelection; +} + +// 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', () => { + // monaco-legacy: src/vs/editor/contrib/wordOperations/test/browser/wordOperations.test.ts — "cursorWordLeft - Does not recognize words" + // DIVERGENCE: vscode segments CJK per-word only when wordSegmenterLocales + // is 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)]); + }); + + test('double-click word expansion splits the same Chinese run into segments', () => { + // monaco-legacy: src/vs/editor/contrib/wordOperations/test/browser/wordOperations.test.ts — "cursorWordLeft - Recognize words" + // DIVERGENCE: vscode's default (no wordSegmenterLocales) 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', () => { + // monaco-legacy: src/vs/editor/contrib/wordOperations/test/browser/wordOperations.test.ts — "cursorWordLeft - Does not recognize words" + // DIVERGENCE: Hiragana and Han are both \p{Alphabetic}, so the classifier + // sees one uninterrupted word run across the whole sentence. vscode 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)]); + }); + + test('double-click word expansion segments the same Japanese sentence', () => { + // monaco-legacy: src/vs/editor/contrib/wordOperations/test/browser/wordOperations.test.ts — "cursorWordLeft - Recognize words" + // 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', () => { + // monaco-legacy: src/vs/editor/contrib/wordOperations/test/browser/wordOperations.test.ts — "cursorWordLeft - Does not recognize words" + // 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)]); + }); + + test('double-click word expansion splits the mixed run at the script boundary', () => { + // monaco-legacy: src/vs/editor/contrib/wordOperations/test/browser/wordOperations.test.ts — "cursorWordLeft - Recognize words" + // 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', () => { + // monaco-legacy: src/vs/editor/contrib/wordOperations/test/browser/wordOperations.test.ts — "cursorWordLeft - Does not recognize words" + // 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\u00EFve東京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)]); + }); + + test('double-click word expansion splits Latin, Han, and digits into three words', () => { + // monaco-legacy: src/vs/editor/contrib/wordOperations/test/browser/wordOperations.test.ts — "cursorWordLeft - Recognize words" + // DIVERGENCE: the same string that deleteWordBackward erases whole yields + // three distinct double-click words (Latin, Han, digits). + const d = doc('na\u00EFve東京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', () => { + // monaco-legacy: src/vs/editor/contrib/wordOperations/test/browser/wordOperations.test.ts — "deleteWordLeft for cursor at end of whitespace" (emoji-at-boundary convention) + 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', () => { + // monaco-legacy: src/vs/editor/contrib/wordOperations/test/browser/wordOperations.test.ts — "deleteWordLeft for cursor at end of whitespace" (emoji-at-boundary convention) + 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', () => { + // monaco-legacy: src/vs/editor/test/browser/controller/cursor.test.ts — "issue #99629: Emoji modifiers in text treated separately when using backspace (ZWJ sequence)" + 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', () => { + // monaco-legacy: src/vs/editor/test/browser/controller/cursor.test.ts — "issue #99629: Emoji modifiers in text treated separately when using backspace (ZWJ sequence)" + 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', () => { + // monaco-legacy: src/vs/editor/test/browser/controller/cursor.test.ts — "issue #99629: Emoji modifiers in text treated separately when using backspace (ZWJ sequence)" + 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', () => { + // monaco-legacy: src/vs/editor/test/browser/controller/cursor.test.ts — "issue #99629: Emoji modifiers in text treated separately when using backspace" + 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', () => { + // monaco-legacy: src/vs/editor/test/browser/controller/cursor.test.ts — "issue #84897: Left delete behavior in some languages is changed" + // "mañana" in decomposed form: the ñ is n + U+0303 combining tilde. + const d = doc('man\u0303ana'); + 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', () => { + // monaco-legacy: src/vs/editor/test/browser/controller/cursor.test.ts — "issue #122914: Left delete behavior in some languages is changed (useTabStops: false)" + // "go piña" in decomposed form; the final cluster is n + U+0303 + a. + const d = doc('go pin\u0303a'); + 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', () => { + // monaco-legacy: src/vs/editor/contrib/linesOperations/test/browser/linesOperations.test.ts — "should keep deleting lines in multi cursor mode" + // 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', () => { + // monaco-legacy: src/vs/editor/contrib/linesOperations/test/browser/linesOperations.test.ts — "should work in multi cursor mode" + // 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', () => { + // monaco-legacy: src/vs/editor/contrib/wordOperations/test/browser/wordOperations.test.ts — "deleteWordLeft - issue #3882 (2): Ctrl+Delete removing entire line when used at the end of line" + // 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', () => { + // monaco-legacy: src/vs/editor/contrib/wordOperations/test/browser/wordOperations.test.ts — "deleteWordLeft - issue #3882 (2): Ctrl+Delete removing entire line when used at the end of line" + 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', () => { + // monaco-legacy: src/vs/editor/contrib/wordOperations/test/browser/wordOperations.test.ts — "deleteWordLeft - issue #3882 (2): Ctrl+Delete removing entire line when used at the end of line" + 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)]); + }); +}); From 327c8c8a4af032070f1651935052c424494fd6e4 Mon Sep 17 00:00:00 2001 From: fat Date: Tue, 14 Jul 2026 01:51:35 -0700 Subject: [PATCH 2/9] [diffs/edit] Add atom-legacy-tests suite Third legacy-editor harvest, from atom/text-buffer and atom/superstring (both MIT): 56 tests covering search/replace semantics (regex capture expansion, zero-width-match termination, replace-past-own-output, anchored patterns on CRLF), selection remapping under partial-overlap replacements, soft-wrap boundary behavior, history invariant fuzzing, and malformed-position handling. Includes 8 test.failing entries encoding newly found bugs, the worst being: an edit with a NaN position component silently replaces the entire document, and soft-wrap vertical caret motion can land inside a surrogate pair. Co-Authored-By: Claude Fable 5 --- packages/diffs/test/README.md | 11 +- .../diffs/test/atom-legacy-tests/README.md | 111 +++ .../atom-legacy-tests/history.atom.test.ts | 400 +++++++++ .../atom-legacy-tests/positions.atom.test.ts | 94 +++ .../searchReplace.atom.test.ts | 780 ++++++++++++++++++ .../selectionRemap.atom.test.ts | 390 +++++++++ .../atom-legacy-tests/softWrap.atom.test.ts | 548 ++++++++++++ 7 files changed, 2329 insertions(+), 5 deletions(-) create mode 100644 packages/diffs/test/atom-legacy-tests/README.md create mode 100644 packages/diffs/test/atom-legacy-tests/history.atom.test.ts create mode 100644 packages/diffs/test/atom-legacy-tests/positions.atom.test.ts create mode 100644 packages/diffs/test/atom-legacy-tests/searchReplace.atom.test.ts create mode 100644 packages/diffs/test/atom-legacy-tests/selectionRemap.atom.test.ts create mode 100644 packages/diffs/test/atom-legacy-tests/softWrap.atom.test.ts diff --git a/packages/diffs/test/README.md b/packages/diffs/test/README.md index 3a59e5133..48638ec07 100644 --- a/packages/diffs/test/README.md +++ b/packages/diffs/test/README.md @@ -8,11 +8,12 @@ AGENT=1 bun test ## Legacy-editor test suites -`monaco-legacy-tests/` (microsoft/vscode, MIT) and `codemirror-legacy-tests/` -(CodeMirror 6, MIT) hold editor-behavior scenarios harvested from other editors' -test suites. See each directory's README for provenance, the `test.failing` -known-bug convention, and the `DIVERGENCE:` comment policy. Discovered by the -normal `bun test` run like any other `*.test.ts` files. +`monaco-legacy-tests/` (microsoft/vscode, MIT), `codemirror-legacy-tests/` +(CodeMirror 6, MIT), and `atom-legacy-tests/` (atom/text-buffer + +atom/superstring, MIT) hold editor-behavior scenarios harvested from other +editors' test suites. See each directory's README for provenance, the +`test.failing` known-bug convention, and the `DIVERGENCE:` comment policy. +Discovered by the normal `bun test` run like any other `*.test.ts` files. Authorship hygiene for any future additions to these suites: derive only from permissively-licensed (e.g. MIT) sources; write your own test names (never reuse diff --git a/packages/diffs/test/atom-legacy-tests/README.md b/packages/diffs/test/atom-legacy-tests/README.md new file mode 100644 index 000000000..c97ab1cef --- /dev/null +++ b/packages/diffs/test/atom-legacy-tests/README.md @@ -0,0 +1,111 @@ +# atom-legacy-tests + +Behavioral test scenarios harvested from Atom's text-layer test suites and +re-expressed against `@pierre/diffs`' edit APIs. Companion to +`../monaco-legacy-tests/` and `../codemirror-legacy-tests/` (same conventions); +scenarios covered there or in the main suite were filtered out during the audit. + +## Provenance & attribution + +Adapted from the MIT-licensed Atom packages, © GitHub Inc.: + +- [atom/text-buffer](https://github.com/atom/text-buffer) @ + `b1f093269b175ce6cc9728c7a4d50ca75bb031b6` + (`spec/text-buffer-spec.coffee`/`.js`, `spec/marker-spec.coffee`, + `spec/marker-layer-spec.coffee`, `spec/display-layer-spec.js`) +- [atom/superstring](https://github.com/atom/superstring) @ + `6732087fac04cd68d14e93d4f83f246879200ab5` (`test/js/patch.test.js`, + `test/js/text-buffer.test.js`) + +These are behavioral rewrites, not code copies: original test names, fixtures, +helpers, and assertions; organized by this package's architecture rather than +the source suites' layout; granularity re-derived around our API. Every test +carries a traceability comment: + +```ts +// atom-legacy: atom-text-buffer/spec/text-buffer-spec.coffee — "" +``` + +## Conventions + +Same as the sibling suites: `test.failing(...)` = known bug asserting correct +behavior (remove the modifier when fixed); `DIVERGENCE:` comments pin +intentional differences from Atom. + +## Known bugs encoded as `test.failing` (8) + +**Search-replace capture expansion with lookaround** +(`searchReplace.atom.test.ts`, 3) — root cause: `buildSearchReplacementText` +re-executes the pattern against only the matched slice, so lookaround context +outside the slice is lost. + +1. A lookbehind's context sits before the slice; the re-execution finds nothing, + falls back to the raw replace text, and the literal `$1` is inserted into the + document. +2. A lookahead's context sits after the slice — same fallback, unexpanded + replace text inserted. +3. A lookahead that re-matches _shorter_ on the slice (the trailing context + character is part of the slice) trips the full-length guard and the literal + `$&` is inserted. + +**PieceTable CRLF line metadata drives search astray** +(`searchReplace.atom.test.ts`, 1) — splices that split or form `\r\n` pairs +across piece seams corrupt the piece-level line-break counts, 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. Root cause +pinned as directed repros in `../monaco-legacy-tests/pieceTable.monaco.test.ts`. + +**Soft-wrap vertical motion splits surrogate pairs** +(`softWrap.atom.test.ts`, 2) — `moveBySoftLine` computes the landing spot as +target-segment start + visual column in raw UTF-16 units with no +grapheme/surrogate snapping, so ArrowDown into a continuation row (and ArrowUp +across a logical-line boundary) can park the caret between the halves of an +astral character; a subsequent insert at that caret splits the pair into lone +surrogates. + +**Malformed position components destroy the document** +(`positions.atom.test.ts`, 2) — `normalizePosition` has no finiteness/integer +guard, so a `NaN` (or fractional) `line`/`character` flows through +`Math.min`/`Math.max` into `offsetAt`, the resolved offset becomes `NaN`, and +the degenerate range resolves to a whole-document replace: an insert with one +malformed component silently erases everything else. Atom throws `Invalid Point` +on such input. (`Infinity` happens to clamp to a valid offset today — pinned as +a DIVERGENCE.) + +## Intentional divergences (`DIVERGENCE:` comments, 8) + +`searchReplace.atom.test.ts` (5): `$0`/out-of-range capture references resolve +via `match[n] ?? ''` instead of staying literal; zero-length matches are +suppressed (starred patterns and bare `^`/`$` anchors report nothing where Atom +reports empty ranges — two comments); invalid patterns yield zero matches +instead of throwing; panel-driven replace wraps around VS Code-style rather than +stopping at the buffer end. `history.atom.test.ts` (1): every recorded history +step clears redo — no-op transactions are not dropped. +`selectionRemap.atom.test.ts` (1): uniform right gravity at an edit that starts +exactly at a selection start (Atom's marker bias would absorb the new text). +`softWrap.atom.test.ts` (1): no clamp to the final wrap segment's end when +moving up into a shorter last segment — the overshoot acts as an implicit goal +column. + +## Missing features surfaced by this audit (recorded, not tested) + +Atom has dedicated coverage for all of these; candidates for the feature backlog +rather than this suite: + +- **Preferred line-ending override** — `TextDocument.eol` is a derived getter + (first line break); no setter/option lets a host force LF/CRLF policy for + inserted text +- **Range-scoped search** — `SearchParams` has no range field; selection-scoped + find/replace is impossible today +- **Multi-line pattern search** — `PieceTable.search` rejects patterns + containing `\n`/`\r`; matching across line breaks is unsupported (the + rejection itself is pinned in `editorPieceTable.test.ts`) +- **Transactions** — no `transact()`/nested-transaction/abort API; EditStack + groups only via geometric typing coalescing and `undoBoundary` +- **History checkpoints** — no checkpoint/revert-to-checkpoint concept +- **Retroactive change grouping** — no public `groupLastChanges()`-style merge + of the last N history entries +- **Edit-tracking markers with invalidation strategies** — `Marker` is + render-only; no remapping through edits (marker _bias_ scenarios that map onto + selection remapping are tested here; the marker feature itself is not) +- **Soft-wrap continuation-row hanging indent** diff --git a/packages/diffs/test/atom-legacy-tests/history.atom.test.ts b/packages/diffs/test/atom-legacy-tests/history.atom.test.ts new file mode 100644 index 000000000..d341ac666 --- /dev/null +++ b/packages/diffs/test/atom-legacy-tests/history.atom.test.ts @@ -0,0 +1,400 @@ +// History/coalescing scenarios ported from Atom's text-buffer and superstring +// suites. See README.md in this directory for suite conventions. Two areas: +// (1) degenerate history batches around undo — Atom's 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, +// test/editorEditStack.test.ts, and the monaco/codemirror sibling suites; +// nothing here repeats those. +import { describe, expect, test } from 'bun:test'; + +import { EditStack } from '../../src/editor/editStack'; +import { DirectionNone } from '../../src/editor/selection'; +import { TextDocument } from '../../src/editor/textDocument'; +import type { EditorSelection, TextEdit } from '../../src/types'; + +function doc(text: string, editStack?: EditStack) { + return new TextDocument('inmemory://1', text, 'plain', 0, editStack); +} + +function caret(line: number, character: number) { + const position = { line, character }; + return { + start: position, + end: position, + direction: DirectionNone, + } satisfies EditorSelection; +} + +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, [ + caret(line, character), + ]); +} + +describe('degenerate history batches around undo (atom-legacy)', () => { + // atom-legacy: atom-text-buffer/spec/text-buffer-spec.coffee — "does not push the transaction to the undo stack if it is empty" + // 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, [caret(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, [caret(0, 2)], [caret(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); + }); + + // atom-legacy: atom-text-buffer/spec/text-buffer-spec.coffee — "doesn't notify observers after an empty transaction" + // 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 codemirror-legacy-tests/applyEditsBatch.cm.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, [caret(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); + }); + + // atom-legacy: atom-text-buffer/spec/text-buffer-spec.coffee — "does not push the transaction to the undo stack if it is empty" + // DIVERGENCE: Atom 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 + // codemirror-legacy-tests/applyEditsBatch.cm.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, [caret(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, [ + caret(0, 1), + caret(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 }; +} + +// Runs undo (or redo) to exhaustion and returns the step count, so the fuzz +// can assert the two directions traverse the same number of entries. +function undoToExhaustion(d: ReturnType) { + let steps = 0; + while (d.canUndo) { + d.undo(); + steps++; + } + return steps; +} + +function redoToExhaustion(d: ReturnType) { + let steps = 0; + while (d.canRedo) { + d.redo(); + steps++; + } + return steps; +} + +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 main suite's maxEntries tests) 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 caret(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 = undoToExhaustion(d); + expect(undoSteps).toBeGreaterThan(0); + expect(d.getText()).toBe(baseText); + expect(d.version).toBe(0); + expect(d.canRedo).toBe(true); + + const redoSteps = redoToExhaustion(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 (atom-legacy)', () => { + // atom-legacy: atom-superstring/test/js/patch.test.js — "correctly records random splices" + // Where superstring 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 + // monaco-legacy-tests/editStack.monaco.test.ts) and never applies + // history-skipping edits (the frozen-entry known bugs live in + // codemirror-legacy-tests/historyRemote.cm.test.ts); 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/atom-legacy-tests/positions.atom.test.ts b/packages/diffs/test/atom-legacy-tests/positions.atom.test.ts new file mode 100644 index 000000000..2f4e9b4d5 --- /dev/null +++ b/packages/diffs/test/atom-legacy-tests/positions.atom.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, test } from 'bun:test'; + +import { TextDocument } from '../../src/editor/textDocument'; + +function doc(text: string) { + return new TextDocument('inmemory://1', text, 'plain'); +} + +// 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', () => { + // Atom's clipPosition throws 'Invalid Point' on non-numeric components; + // 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', + () => { + // atom-legacy: atom-text-buffer/spec/text-buffer-spec.coffee — position clipping rejects non-numeric components instead of producing garbage offsets + // 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', + () => { + // atom-legacy: atom-text-buffer/spec/text-buffer-spec.coffee — position clipping rejects non-integer components instead of producing garbage offsets + // 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', () => { + // atom-legacy: atom-text-buffer/spec/text-buffer-spec.coffee — position clipping bounds oversized components + // DIVERGENCE: Atom throws on non-finite components; 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/atom-legacy-tests/searchReplace.atom.test.ts b/packages/diffs/test/atom-legacy-tests/searchReplace.atom.test.ts new file mode 100644 index 000000000..c352c0a73 --- /dev/null +++ b/packages/diffs/test/atom-legacy-tests/searchReplace.atom.test.ts @@ -0,0 +1,780 @@ +import { describe, expect, test } from 'bun:test'; + +import { + buildSearchReplacementText, + PieceTable, +} from '../../src/editor/pieceTable'; +import type { MatchRange, SearchParams } from '../../src/editor/searchPanel'; +import { SearchPanelWidget } from '../../src/editor/searchPanel'; +import type { ResolvedTextEdit } from '../../src/editor/textDocument'; +import { TextDocument } from '../../src/editor/textDocument'; +import { installDom, wait } from '../domHarness'; + +// Search/replace semantics harvested from Atom's text-buffer scan/replace +// specs and superstring's findAll tests, re-expressed against PieceTable.search, +// buildSearchReplacementText, and SearchPanelWidget. Atom addresses matches as +// Range(Point(row, column)) pairs (0-based); pierre's search returns flat +// [start, end) document offsets, 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 Atom's scan-driven replace 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; +} + +function setInput(input: HTMLInputElement, value: string): void { + input.value = value; + input.dispatchEvent( + new window.Event('input', { bubbles: true, cancelable: true }) + ); +} + +function pressEnter(input: HTMLInputElement): void { + input.dispatchEvent( + new window.KeyboardEvent('keydown', { + key: 'Enter', + bubbles: true, + cancelable: true, + }) + ); +} + +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 host in +// editorSearchPanel.test.ts — 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://atom-replace', + 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 (atom-legacy)', () => { + // atom-legacy: atom-text-buffer/spec/text-buffer-spec.js — "replaces tstat_fvars()->curr_setpoint[HEAT_EN] with tstat_set_curr_setpoint($1, $2);" + test('numbered group references pull the captured text into the replacement', () => { + expect( + replacementsFor( + 'lily, fern', + searchParams('(\\w+), (\\w+)', { replaceText: '$2 & $1' }) + ) + ).toEqual(['fern & lily']); + }); + + // atom-legacy: atom-text-buffer/spec/text-buffer-spec.js — "replaces tstat_fvars()->curr_setpoint[HEAT_EN] with tstat_set_curr_setpoint($1, $2);" + 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']); + }); + + // atom-legacy: atom-text-buffer/spec/text-buffer-spec.js — "replaces atom/flight-manualatomio with $1" + test('group references the pattern never captured collapse to empty text', () => { + // DIVERGENCE: Atom routes replacements through JS String.replace, which + // leaves "$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']); + }); + + // atom-legacy: atom-text-buffer/spec/text-buffer-spec.js — "replaces foo( with bar( using /\bfoo\(\b/gim" + 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]']); + }); + + // atom-legacy: atom-text-buffer/spec/text-buffer-spec.coffee — "does a case-insensitive search" + 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']); + }); + + // atom-legacy: atom-text-buffer/spec/text-buffer-spec.js — "replaces atom/flight-manualatomio with $1" + test('literal (non-regex) mode passes dollar tokens through untouched', () => { + expect( + replacementsFor( + 'k1 k2', + searchParams('k1', { replaceText: '$&-$1', regex: false }) + ) + ).toEqual(['$&-$1']); + }); + + // atom-legacy: atom-text-buffer/spec/text-buffer-spec.js — "replaces tstat_fvars()->curr_setpoint[HEAT_EN] with tstat_set_curr_setpoint($1, $2);" + // 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']); + } + ); + + // atom-legacy: atom-text-buffer/spec/text-buffer-spec.js — "replaces tstat_fvars()->curr_setpoint[HEAT_EN] with tstat_set_curr_setpoint($1, $2);" + // 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(['']); + } + ); + + // atom-legacy: atom-superstring/test/js/text-buffer.test.js — "handles patterns with lookahead that span several chunks" + // 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]']); + } + ); + + // atom-legacy: atom-text-buffer/spec/text-buffer-spec.js — "replaces atom/flight-manualatomio with $1" + test('a pattern that matches nowhere leaves the document untouched', async () => { + const host = mountReplaceHost('gray goose'); + try { + await wait(0); + host.regexToggle.click(); + setInput(host.queryInput, 'swan(\\w)'); + setInput(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 (atom-legacy)', () => { + // atom-legacy: atom-superstring/test/js/text-buffer.test.js — "handles patterns that match the empty string (regression)" + test('a starred pattern reports only its non-empty matches', () => { + // DIVERGENCE: Atom's findAllSync(/^a*/) reports zero-length ranges 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], + ]); + }); + + // atom-legacy: atom-superstring/test/js/text-buffer.test.js — "does not skip empty rows" + 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; Atom returns one empty range per row. + expect(findAll('ivy\nelm', '^')).toEqual([]); + expect(findAll('ivy\nelm', '$')).toEqual([]); + }); + + // atom-legacy: atom-superstring/test/js/text-buffer.test.js — "handles patterns that match the empty string (regression)" + test('an empty alternation arm only ever surfaces the non-empty arm', () => { + expect(findAll('ame', 'm|')).toEqual([[1, 2]]); + }); + + // atom-legacy: atom-superstring/test/js/text-buffer.test.js — "handles patterns that match the empty string (regression)" + 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], + ]); + }); + + // atom-legacy: atom-superstring/test/js/text-buffer.test.js — "does not skip empty rows" + 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], + ]); + }); + + // atom-legacy: atom-superstring/test/js/text-buffer.test.js — "handles patterns that match the empty string (regression)" + test('a starred group terminates on lines where it can only match empty', () => { + expect(findAll('axax\nbb\nax', '(?:ax)*')).toEqual([ + [0, 4], + [8, 10], + ]); + }); + + // atom-legacy: atom-superstring/test/js/text-buffer.test.js — "handles patterns that match the empty string (regression)" + 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]]); + }); + + // atom-legacy: atom-superstring/test/js/text-buffer.test.js — "handles patterns that match the empty string (regression)" + test('matches after astral characters land at UTF-16 offsets', () => { + expect(findAll('\u{1F600}w\u{1F600}ww', 'w+')).toEqual([ + [2, 3], + [5, 7], + ]); + }); + + // atom-legacy: atom-superstring/test/js/text-buffer.test.js — "throws an exception if an invalid pattern is passed" + test('an invalid pattern reports zero matches instead of throwing', () => { + // DIVERGENCE: Atom's find rejects/throws 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 (atom-legacy)', () => { + // atom-legacy: atom-superstring/test/js/text-buffer.test.js — "handles the ^ and $ anchors properly (CRLF line endings)" + 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); + } + }); + + // atom-legacy: atom-superstring/test/js/text-buffer.test.js — "handles the ^ and $ anchors properly" + 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], + ]); + }); + + // atom-legacy: atom-superstring/test/js/text-buffer.test.js — "handles empty matches before CRLF line endings (regression)" + 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 (atom-legacy)', () => { + // atom-legacy: atom-text-buffer/spec/text-buffer-spec.coffee — "replaces each occurrence of the regex match with the string" + test('replacing with text containing the query steps past the insertion', async () => { + const host = mountReplaceHost('ash elm ash'); + try { + await wait(0); + setInput(host.queryInput, 'ash'); + expect(host.scrolled).toEqual([[0, 3]]); + expect(host.matchesLabel()).toBe('1 of 2'); + + setInput(host.replaceInput, 'ashash'); + pressEnter(host.replaceInput); + + 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'); + + pressEnter(host.replaceInput); + 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(); + } + }); + + // atom-legacy: atom-text-buffer/spec/text-buffer-spec.coffee — "replaces each occurrence of the regex match with the string" + test('replace wraps to the document top once no match remains past the caret', async () => { + // DIVERGENCE: Atom's scan-driven replace is one forward pass that stops at + // the buffer end. pierre's panel wraps around (VS Code-style interactive + // 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); + setInput(host.queryInput, 'ash'); + setInput(host.replaceInput, 'ashash'); + pressEnter(host.replaceInput); // 'ashash elm ash', caret past [3, 6] + pressEnter(host.replaceInput); // 'ashash elm ashash', caret at doc end + + pressEnter(host.replaceInput); // 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(); + } + }); + + // atom-legacy: atom-text-buffer/spec/text-buffer-spec.coffee — "allows the match to be replaced with the empty string" + test('replacing with the empty string collapses the caret at the match start', async () => { + const host = mountReplaceHost('oak elm fir'); + try { + await wait(0); + setInput(host.queryInput, 'elm'); + setInput(host.replaceInput, ''); + pressEnter(host.replaceInput); + + 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(); + } + }); + + // atom-legacy: atom-text-buffer/spec/text-buffer-spec.coffee — "replaces each occurrence of the regex match with the string" + 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); + setInput(host.queryInput, 'kk'); + setInput(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(); + } + }); + + // atom-legacy: atom-text-buffer/spec/text-buffer-spec.coffee — "replaces each occurrence of the regex match with the string" + 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); + setInput(host.queryInput, 'ash'); + setInput(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(); + } + }); + + // atom-legacy: atom-text-buffer/spec/text-buffer-spec.js — "replaces tstat_fvars()->curr_setpoint[HEAT_EN] with tstat_set_curr_setpoint($1, $2);" + test('replace all expands capture references per match', async () => { + const host = mountReplaceHost('id 7 and 305'); + try { + await wait(0); + host.regexToggle.click(); + setInput(host.queryInput, '(\\d+)'); + setInput(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 (atom-legacy)', () => { + // atom-legacy: atom-superstring/test/js/text-buffer.test.js — "returns the same results as a reference implementation" + 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 + ); + }); + + // atom-legacy: atom-superstring/test/js/text-buffer.test.js — "returns the same results as a reference implementation" + // 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 + // ../monaco-legacy-tests/pieceTable.monaco.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/atom-legacy-tests/selectionRemap.atom.test.ts b/packages/diffs/test/atom-legacy-tests/selectionRemap.atom.test.ts new file mode 100644 index 000000000..df141cea7 --- /dev/null +++ b/packages/diffs/test/atom-legacy-tests/selectionRemap.atom.test.ts @@ -0,0 +1,390 @@ +import { describe, expect, test } from 'bun:test'; + +import { EditStack } from '../../src/editor/editStack'; +import { + DirectionBackward, + DirectionForward, + DirectionNone, + remapSelectionsAfterEdits, +} from '../../src/editor/selection'; +import type { ResolvedTextEdit } from '../../src/editor/textDocument'; +import { TextDocument } from '../../src/editor/textDocument'; +import type { EditorSelection, SelectionDirection } from '../../src/types'; + +function doc(text: string) { + return new TextDocument('inmemory://1', text, 'plain'); +} + +// 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 (atom-legacy)', () => { + // atom-legacy: atom-text-buffer/spec/marker-spec.coffee — "moves the start of the marker to the end of the change and invalidates the marker if its stategy is 'overlap', 'inside', or 'touch'" + 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. Atom's 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); + }); + + // atom-legacy: atom-text-buffer/spec/marker-spec.coffee — "moves the end of the marker to the end of the change and invalidates the marker if its stategy is 'overlap', 'inside', or 'touch'" + 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 Atom's 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 (atom-legacy)', () => { + // atom-legacy: atom-text-buffer/spec/marker-spec.coffee — "truncates the marker to the end of the change and invalidates every invalidation strategy except 'never'" + 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 (atom-legacy)', () => { + // atom-legacy: atom-text-buffer/spec/marker-spec.coffee — "interprets the change as being inside the marker for all invalidation strategies" + test('a contained replacement starting exactly at the selection start shrinks the selection to the un-replaced tail', () => { + // DIVERGENCE: Atom's 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 Atom + // 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 (atom-legacy)', () => { + // atom-legacy: atom-superstring/test/js/marker-index.test.js — "maintains correct marker positions during randomized insertions and mutations" + 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 (Atom anchors it, 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 (atom-legacy)', () => { + // 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 }; + } + + // atom-legacy: atom-superstring/test/js/patch.test.js — "correctly records random splices" + 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); + }); + + // atom-legacy: atom-superstring/test/js/patch.test.js — "can invert patches" + 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. Atom's 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 + }); +}); diff --git a/packages/diffs/test/atom-legacy-tests/softWrap.atom.test.ts b/packages/diffs/test/atom-legacy-tests/softWrap.atom.test.ts new file mode 100644 index 000000000..80078c449 --- /dev/null +++ b/packages/diffs/test/atom-legacy-tests/softWrap.atom.test.ts @@ -0,0 +1,548 @@ +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 { disposeHighlighter } from '../../src/highlighter/shared_highlighter'; +import type { FileContents } from '../../src/types'; +import { installDom, wait } from '../domHarness'; + +afterAll(async () => { + await disposeHighlighter(); +}); + +// Soft-wrap boundary semantics from Atom's display-layer suite, re-expressed +// against pierre-fe's wrap pipeline. Wrap segmentation is driven by the same +// Range-measurement stub editorWrapCaretPosition.test.ts uses: a visual row +// break is reported every `columns` UTF-16 offsets, making #wrapLineText's +// offsets deterministic. Everything the tests observe is real editor output: +// selection state via editor.getState() and overlay geometry via the inline +// width/transform the editor stamps on [data-caret] / [data-selection-range]. +// +// Geometry constants under the harness: 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 +// Vertical step the measurement stub reports between visual rows. Any positive +// value works; #wrapLineText only compares tops for "moved down". +const STUB_ROW_STEP = 16; + +function stubRect(left: number, top: number): DOMRect { + return { + bottom: top + 1, + height: 1, + left, + right: left + 1, + top, + width: 1, + x: left, + y: top, + toJSON() { + return {}; + }, + } as DOMRect; +} + +function putBackPrototypeProperty( + proto: object, + property: string, + descriptor: PropertyDescriptor | undefined +): void { + if (descriptor !== undefined) { + Object.defineProperty(proto, property, descriptor); + } else { + Reflect.deleteProperty(proto, property); + } +} + +// Make every logical line wrap after `columns` UTF-16 code units by stubbing +// the two rect sources #wrapLineText reads (jsdom measures nothing itself). +function stubWrapEveryNColumns(columns: number): { restore(): void } { + const rangeProto = Object.getPrototypeOf(document.createRange()) as object; + const elementProto = HTMLElement.prototype; + const savedRangeRect = Object.getOwnPropertyDescriptor( + rangeProto, + 'getBoundingClientRect' + ); + const savedElementRect = Object.getOwnPropertyDescriptor( + elementProto, + 'getBoundingClientRect' + ); + + Object.defineProperty(rangeProto, 'getBoundingClientRect', { + configurable: true, + value(this: Range): DOMRect { + const offset = this.startOffset; + return stubRect( + (offset % columns) * CH, + Math.floor(offset / columns) * STUB_ROW_STEP + ); + }, + }); + Object.defineProperty(elementProto, 'getBoundingClientRect', { + configurable: true, + value(): DOMRect { + return stubRect(0, 0); + }, + }); + + return { + restore(): void { + putBackPrototypeProperty( + rangeProto, + 'getBoundingClientRect', + savedRangeRect + ); + putBackPrototypeProperty( + elementProto, + 'getBoundingClientRect', + savedElementRect + ); + }, + }; +} + +interface WrapHarnessWindow extends Window { + KeyboardEvent: { + new (type: string, eventInitDict?: KeyboardEventInit): KeyboardEvent; + }; +} + +interface WrapHarness { + editor: Editor; + root: HTMLElement; + content: HTMLElement; + win: WrapHarnessWindow; + done(): void; +} + +async function openWrapped( + contents: string, + columns: number +): Promise { + const dom = installDom(); + const wrapStub = stubWrapEveryNColumns(columns); + const root = document.createElement('div'); + document.body.appendChild(root); + + const file = new File({ + disableFileHeader: true, + theme: DEFAULT_THEMES, + overflow: 'wrap', + }); + const editor = new Editor(); + const fileContents: FileContents = { name: 'wrapped.ts', contents }; + + file.render({ file: fileContents, fileContainer: root, forceRender: true }); + editor.edit(file); + + let content: HTMLElement | undefined; + for (let attempt = 0; attempt < 20 && content === undefined; attempt++) { + const candidate = root.shadowRoot?.querySelector('[data-content]'); + if ( + candidate instanceof HTMLElement && + (candidate.contentEditable === 'true' || + candidate.getAttribute('contenteditable') === 'true') + ) { + content = candidate; + } else { + await wait(0); + } + } + if (content === undefined) { + throw new Error('wrap harness: content never became editable'); + } + + return { + editor, + root, + content, + win: dom.window as unknown as WrapHarnessWindow, + done(): void { + wrapStub.restore(); + editor.cleanUp(); + file.cleanUp(); + dom.cleanup(); + }, + }; +} + +function placeCaret(h: WrapHarness, line: number, character: number): void { + h.editor.setSelections([ + { + start: { line, character }, + end: { line, character }, + direction: 'none', + }, + ]); +} + +function press(h: WrapHarness, key: string): void { + h.content.dispatchEvent( + new h.win.KeyboardEvent('keydown', { + bubbles: true, + cancelable: true, + composed: true, + key, + }) + ); +} + +function caretState(h: WrapHarness): { line: number; character: number } { + const selection = h.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(h: WrapHarness): { x: number; y: number } { + const carets = h.root.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( + h: WrapHarness +): { x: number; y: number; width: number }[] { + const rects: { x: number; y: number; width: number }[] = []; + h.root.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; +} + +// 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('caret affinity at a wrap boundary (atom-legacy)', () => { + // 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; Atom disambiguates such + // positions with an explicit clipDirection, pierre resolves them with a + // fixed backward affinity (the earlier row wins) in both #getCharX and + // getSoftLineInfo. + const TWO_ROW_LINE = 'q0w1e2r3t4y5u6i7o8p9'; + + // atom-legacy: atom-text-buffer/spec/display-layer-spec.js — "translates points correctly on soft-wrapped lines" + test('a caret on the shared wrap offset draws at the end of the earlier visual row', async () => { + const h = await openWrapped(`${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. + placeCaret(h, 0, 15); + expect(caretXY(h)).toEqual({ x: colX(5), y: ROW_H }); + + // One past the boundary belongs to the continuation row. + placeCaret(h, 0, 11); + expect(caretXY(h)).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. + placeCaret(h, 0, 10); + expect(caretXY(h)).toEqual({ x: colX(10), y: 0 }); + } finally { + h.done(); + } + }); + + // atom-legacy: atom-text-buffer/spec/display-layer-spec.js — "translates points correctly on soft-wrapped lines" + test('Home and End treat a boundary caret as belonging to the row that ends there', async () => { + const h = await openWrapped(`${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). + placeCaret(h, 0, 10); + press(h, 'Home'); + expect(caretState(h)).toEqual({ line: 0, character: 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. + placeCaret(h, 0, 10); + press(h, 'End'); + expect(caretState(h)).toEqual({ line: 0, character: 10 }); + } finally { + h.done(); + } + }); + + // atom-legacy: atom-text-buffer/spec/display-layer-spec.js — "translates points correctly on soft-wrapped lines" + test('ArrowDown carries a boundary caret from wrap offset to wrap offset', async () => { + // Three visual rows: [0,10) [10,20) [20,30). + const h = await openWrapped('wrap_me_at_ten_columns_please!\nnext', 10); + try { + placeCaret(h, 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. + press(h, 'ArrowDown'); + expect(caretState(h)).toEqual({ line: 0, character: 20 }); + + press(h, 'ArrowDown'); + expect(caretState(h)).toEqual({ line: 0, character: 30 }); + } finally { + h.done(); + } + }); +}); + +describe('vertical motion between wrapped rows and grapheme integrity (atom-legacy)', () => { + // atom-legacy: atom-text-buffer/spec/display-layer-spec.js — "treats paired characters as atomic units" + // 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 h = await openWrapped(lineText, 4); + try { + placeCaret(h, 0, 3); + press(h, 'ArrowDown'); + + const { line, character } = caretState(h); + expect(line).toBe(0); + expect(sitsInsideSurrogatePair(lineText, character)).toBe(false); + } finally { + h.done(); + } + } + ); + + // atom-legacy: atom-text-buffer/spec/display-layer-spec.js — "treats paired characters as atomic units" + // 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 h = await openWrapped(`${lineText}\nabc`, 4); + try { + placeCaret(h, 1, 1); + press(h, 'ArrowUp'); + + const { line, character } = caretState(h); + expect(line).toBe(0); + expect(sitsInsideSurrogatePair(lineText, character)).toBe(false); + } finally { + h.done(); + } + } + ); + + // atom-legacy: atom-text-buffer/spec/display-layer-spec.js — "translates points correctly on soft-wrapped lines" + 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 h = await openWrapped('the_quick_brown_fox_jumps\ngoal', 10); + try { + placeCaret(h, 1, 3); + press(h, 'ArrowUp'); + + // Segment-relative column 3 of the FINAL visual row: 20 + 3 = 23. A + // logical-column interpretation would have produced character 3. + expect(caretState(h)).toEqual({ line: 0, character: 23 }); + // And the caret element really renders on the third visual row. + expect(caretXY(h)).toEqual({ x: colX(3), y: 2 * ROW_H }); + } finally { + h.done(); + } + }); + + // atom-legacy: atom-text-buffer/spec/display-layer-spec.js — "translates points correctly on soft-wrapped lines" + test('ArrowUp into a shorter final row keeps the visual column as an overshoot', async () => { + // DIVERGENCE: Atom 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 h = await openWrapped('the_quick_brown_fox_jumps\nreturn 0;', 10); + try { + placeCaret(h, 1, 8); + press(h, 'ArrowUp'); + expect(caretState(h)).toEqual({ line: 0, character: 28 }); + + // The caret element draws clamped to the line end on the last row. + expect(caretXY(h)).toEqual({ x: colX(5), y: 2 * ROW_H }); + + // Round trip: the overshoot column survives the trip back down. + press(h, 'ArrowDown'); + expect(caretState(h)).toEqual({ line: 1, character: 8 }); + } finally { + h.done(); + } + }); +}); + +describe('hard tabs re-expand from each continuation row start (atom-legacy)', () => { + // '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'; + + // atom-legacy: atom-text-buffer/spec/display-layer-spec.js — "re-expands tabs on soft-wrapped lines" + test('caret x after a tab on a continuation row uses tab stops from the segment edge', async () => { + const h = await openWrapped(TABBED_LINE, 5); + try { + // Caret right after the tab: segment prefix 'f\t' spans 2 columns + // (logical-line expansion would make it 3). + placeCaret(h, 0, 7); + expect(caretXY(h)).toEqual({ x: colX(2), y: ROW_H }); + + // Caret after 'g': 3 segment columns (logical-line expansion: 4). + placeCaret(h, 0, 8); + expect(caretXY(h)).toEqual({ x: colX(3), y: ROW_H }); + } finally { + h.done(); + } + }); + + // atom-legacy: atom-text-buffer/spec/display-layer-spec.js — "expands hard tabs on soft-wrapped line segments" + test('selection width over a tab on a continuation row matches segment tab stops', async () => { + const h = await openWrapped(TABBED_LINE, 5); + try { + // Select 'f\tg' — the continuation row from its first character. + h.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(h)).toEqual([ + { x: CONTENT_X, y: ROW_H, width: 3 * CH }, + ]); + } finally { + h.done(); + } + }); +}); + +describe('selection endpoints on wrap offsets (atom-legacy)', () => { + // 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'; + + // atom-legacy: atom-text-buffer/spec/display-layer-spec.js — "translates points correctly on soft-wrapped lines" + test('a selection ending exactly on the wrap offset paints flush to the row edge with no sliver below', async () => { + const h = await openWrapped(`${TWO_ROW_LINE}\nnext`, 10); + try { + h.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(h); + 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 { + h.done(); + } + }); + + // atom-legacy: atom-text-buffer/spec/display-layer-spec.js — "translates points correctly on soft-wrapped lines" + test('a selection starting exactly on the wrap offset paints only on the continuation row', async () => { + const h = await openWrapped(`${TWO_ROW_LINE}\nnext`, 10); + try { + h.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(h)).toEqual([ + { x: CONTENT_X, y: ROW_H, width: 4 * CH }, + ]); + } finally { + h.done(); + } + }); + + // atom-legacy: atom-text-buffer/spec/display-layer-spec.js — "translates points correctly on soft-wrapped lines" + test('a boundary-spanning selection paints exactly one rect per visual row', async () => { + const h = await openWrapped(`${TWO_ROW_LINE}\nnext`, 10); + try { + h.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(h)).toEqual([ + { x: CONTENT_X + 2 * CH, y: 0, width: 8 * CH }, + { x: CONTENT_X, y: ROW_H, width: 5 * CH }, + ]); + } finally { + h.done(); + } + }); +}); From 1493398a9ae07edf830b1f4513dc17d7340380aa Mon Sep 17 00:00:00 2001 From: fat Date: Tue, 14 Jul 2026 08:49:44 -0700 Subject: [PATCH 3/9] [diffs/edit] Guard ICU-dependent word-segmentation pins with skipIf Intl.Segmenter's isWordLike classification varies across ICU builds (underscore joining, bare-digit and Han word-ness differ between engines/platforms). Two segmenter-side expansion pins now probe the runtime's segmentation of their own fixture and skip visibly on divergent ICUs instead of failing. Both tests still execute on our dev and CI environments, which agree with the pinned segmentation. Co-Authored-By: Claude Fable 5 --- .../commands.cm.test.ts | 52 +++++++++------ .../wordOperations.monaco.test.ts | 66 +++++++++++++------ 2 files changed, 78 insertions(+), 40 deletions(-) diff --git a/packages/diffs/test/codemirror-legacy-tests/commands.cm.test.ts b/packages/diffs/test/codemirror-legacy-tests/commands.cm.test.ts index b3e8a7b9f..fa67a632b 100644 --- a/packages/diffs/test/codemirror-legacy-tests/commands.cm.test.ts +++ b/packages/diffs/test/codemirror-legacy-tests/commands.cm.test.ts @@ -352,25 +352,39 @@ describe('delete word backward character groups', () => { expect(nextSelections).toEqual([caret(0, 2)]); }); - test('double-click word expansion agrees the identifier is one word', () => { - // codemirror-legacy: cm-state/test/test-charcategory.ts — "categorises into alphanumeric" - // 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 ../monaco-legacy-tests/wordOperations.monaco.test.ts); - // 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); - }); + // 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', + () => { + // codemirror-legacy: cm-state/test/test-charcategory.ts — "categorises into alphanumeric" + // 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 ../monaco-legacy-tests/wordOperations.monaco.test.ts); + // 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); + } + ); }); describe('move line commands with merged and same-line multi-cursor blocks', () => { diff --git a/packages/diffs/test/monaco-legacy-tests/wordOperations.monaco.test.ts b/packages/diffs/test/monaco-legacy-tests/wordOperations.monaco.test.ts index 5b0094901..2797a51ca 100644 --- a/packages/diffs/test/monaco-legacy-tests/wordOperations.monaco.test.ts +++ b/packages/diffs/test/monaco-legacy-tests/wordOperations.monaco.test.ts @@ -138,27 +138,51 @@ describe('word delete vs word select on CJK and mixed-script runs', () => { expect(nextSelections).toEqual([caret(0, 0)]); }); - test('double-click word expansion splits Latin, Han, and digits into three words', () => { - // monaco-legacy: src/vs/editor/contrib/wordOperations/test/browser/wordOperations.test.ts — "cursorWordLeft - Recognize words" - // DIVERGENCE: the same string that deleteWordBackward erases whole yields - // three distinct double-click words (Latin, Han, digits). - const d = doc('na\u00EFve東京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, - }); - }); + // 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 = (() => { + const wordLike = [ + ...new Intl.Segmenter(undefined, { granularity: 'word' }).segment( + 'naïve東京42' + ), + ] + .filter((seg) => seg.isWordLike === true) + .map((seg) => seg.segment); + return ( + wordLike.length === 3 && + wordLike[0] === 'naïve' && + wordLike[1] === '東京' && + wordLike[2] === '42' + ); + })(); + + test.skipIf(!segmenterSplitsMixedRun)( + 'double-click word expansion splits Latin, Han, and digits into three words', + () => { + // monaco-legacy: src/vs/editor/contrib/wordOperations/test/browser/wordOperations.test.ts — "cursorWordLeft - Recognize words" + // DIVERGENCE: the same string that deleteWordBackward erases whole yields + // three distinct double-click words (Latin, Han, digits). + const d = doc('na\u00EFve東京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', () => { From 1452936931172560179302c3382631fec2e78b64 Mon Sep 17 00:00:00 2001 From: fat Date: Tue, 14 Jul 2026 08:52:04 -0700 Subject: [PATCH 4/9] [diffs/edit] Extend ICU skipIf guards to all segmenter-side CJK pins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the previous commit: the Chinese, Japanese, and Latin-Katakana double-click expansion pins depend on the same ICU-variable isWordLike classification (dictionary-based CJK segmentation is the most engine-dependent of all), so they get the same runtime probe treatment via a shared wordLikeSegments helper. Delete-word tests are untouched — the regex classifier is deterministic. Co-Authored-By: Claude Fable 5 --- .../wordOperations.monaco.test.ts | 140 ++++++++++-------- 1 file changed, 79 insertions(+), 61 deletions(-) diff --git a/packages/diffs/test/monaco-legacy-tests/wordOperations.monaco.test.ts b/packages/diffs/test/monaco-legacy-tests/wordOperations.monaco.test.ts index 2797a51ca..7115c675e 100644 --- a/packages/diffs/test/monaco-legacy-tests/wordOperations.monaco.test.ts +++ b/packages/diffs/test/monaco-legacy-tests/wordOperations.monaco.test.ts @@ -22,6 +22,19 @@ function caret(line: number, character: number): EditorSelection { } satisfies EditorSelection; } +// 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). @@ -51,23 +64,29 @@ describe('word delete vs word select on CJK and mixed-script runs', () => { expect(nextSelections).toEqual([caret(0, 0)]); }); - test('double-click word expansion splits the same Chinese run into segments', () => { - // monaco-legacy: src/vs/editor/contrib/wordOperations/test/browser/wordOperations.test.ts — "cursorWordLeft - Recognize words" - // DIVERGENCE: vscode's default (no wordSegmenterLocales) 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, - }); - }); + const segmenterSplitsChineseRun = + wordLikeSegments('你好世界').join('|') === '你好|世界'; + + test.skipIf(!segmenterSplitsChineseRun)( + 'double-click word expansion splits the same Chinese run into segments', + () => { + // monaco-legacy: src/vs/editor/contrib/wordOperations/test/browser/wordOperations.test.ts — "cursorWordLeft - Recognize words" + // DIVERGENCE: vscode's default (no wordSegmenterLocales) 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', () => { // monaco-legacy: src/vs/editor/contrib/wordOperations/test/browser/wordOperations.test.ts — "cursorWordLeft - Does not recognize words" @@ -82,18 +101,24 @@ describe('word delete vs word select on CJK and mixed-script runs', () => { expect(nextSelections).toEqual([caret(0, 0)]); }); - test('double-click word expansion segments the same Japanese sentence', () => { - // monaco-legacy: src/vs/editor/contrib/wordOperations/test/browser/wordOperations.test.ts — "cursorWordLeft - Recognize words" - // 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, - }); - }); + const segmenterIsolatesTheNoun = + wordLikeSegments('私は猫が好き').includes('猫'); + + test.skipIf(!segmenterIsolatesTheNoun)( + 'double-click word expansion segments the same Japanese sentence', + () => { + // monaco-legacy: src/vs/editor/contrib/wordOperations/test/browser/wordOperations.test.ts — "cursorWordLeft - Recognize words" + // 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', () => { // monaco-legacy: src/vs/editor/contrib/wordOperations/test/browser/wordOperations.test.ts — "cursorWordLeft - Does not recognize words" @@ -108,23 +133,29 @@ describe('word delete vs word select on CJK and mixed-script runs', () => { expect(nextSelections).toEqual([caret(0, 0)]); }); - test('double-click word expansion splits the mixed run at the script boundary', () => { - // monaco-legacy: src/vs/editor/contrib/wordOperations/test/browser/wordOperations.test.ts — "cursorWordLeft - Recognize words" - // 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, - }); - }); + const segmenterSplitsKatakanaRun = + wordLikeSegments('helloワールド').join('|') === 'hello|ワールド'; + + test.skipIf(!segmenterSplitsKatakanaRun)( + 'double-click word expansion splits the mixed run at the script boundary', + () => { + // monaco-legacy: src/vs/editor/contrib/wordOperations/test/browser/wordOperations.test.ts — "cursorWordLeft - Recognize words" + // 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', () => { // monaco-legacy: src/vs/editor/contrib/wordOperations/test/browser/wordOperations.test.ts — "cursorWordLeft - Does not recognize words" @@ -143,21 +174,8 @@ describe('word delete vs word select on CJK and mixed-script runs', () => { // 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 = (() => { - const wordLike = [ - ...new Intl.Segmenter(undefined, { granularity: 'word' }).segment( - 'naïve東京42' - ), - ] - .filter((seg) => seg.isWordLike === true) - .map((seg) => seg.segment); - return ( - wordLike.length === 3 && - wordLike[0] === 'naïve' && - wordLike[1] === '東京' && - wordLike[2] === '42' - ); - })(); + 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', From 3adf25a230936eaee4a54a521ee374a77d933dd3 Mon Sep 17 00:00:00 2001 From: fat Date: Tue, 14 Jul 2026 12:03:13 -0700 Subject: [PATCH 5/9] [diffs/edit] Consolidate editor-derived tests into the main suite Per team feedback: the three editor-named test directories are gone and all 195 tests now live in the existing per-domain files (editorPieceTable, editorTextDocument, editorApplyEdits, editorEditStack, editorSelection, editorSearchPanel, editorWrapCaretPosition). Migration was audited name-by-name before deletion: every test present exactly once, all 30 test.failing known-bug pins and 5 ICU skipIf guards preserved. Comments no longer reference other editors; the conventions, known-bug inventory, coverage-gap additions, and a single provenance note moved into test/README.md. Co-Authored-By: Claude Fable 5 --- packages/diffs/test/README.md | 129 +- .../diffs/test/atom-legacy-tests/README.md | 111 -- .../atom-legacy-tests/history.atom.test.ts | 400 ----- .../atom-legacy-tests/positions.atom.test.ts | 94 - .../searchReplace.atom.test.ts | 780 -------- .../selectionRemap.atom.test.ts | 390 ---- .../atom-legacy-tests/softWrap.atom.test.ts | 548 ------ .../test/codemirror-legacy-tests/README.md | 78 - .../applyEditsBatch.cm.test.ts | 303 ---- .../commands.cm.test.ts | 489 ----- .../history.cm.test.ts | 353 ---- .../historyRemote.cm.test.ts | 296 --- .../pieceTable.cm.test.ts | 527 ------ .../remapSelections.cm.test.ts | 255 --- .../selection.cm.test.ts | 346 ---- packages/diffs/test/editorApplyEdits.test.ts | 837 ++++++++- packages/diffs/test/editorEditStack.test.ts | 1161 +++++++++++- packages/diffs/test/editorPieceTable.test.ts | 651 +++++++ packages/diffs/test/editorSearchPanel.test.ts | 731 ++++++++ packages/diffs/test/editorSelection.test.ts | 1592 ++++++++++++++++- .../diffs/test/editorTextDocument.test.ts | 254 +++ .../test/editorWrapCaretPosition.test.ts | 392 ++++ .../diffs/test/monaco-legacy-tests/README.md | 79 - .../applyEdits.monaco.test.ts | 237 --- .../editStack.monaco.test.ts | 241 --- .../pieceTable.monaco.test.ts | 203 --- .../selection.monaco.test.ts | 249 --- .../textDocument.monaco.test.ts | 183 -- .../wordOperations.monaco.test.ts | 356 ---- 29 files changed, 5725 insertions(+), 6540 deletions(-) delete mode 100644 packages/diffs/test/atom-legacy-tests/README.md delete mode 100644 packages/diffs/test/atom-legacy-tests/history.atom.test.ts delete mode 100644 packages/diffs/test/atom-legacy-tests/positions.atom.test.ts delete mode 100644 packages/diffs/test/atom-legacy-tests/searchReplace.atom.test.ts delete mode 100644 packages/diffs/test/atom-legacy-tests/selectionRemap.atom.test.ts delete mode 100644 packages/diffs/test/atom-legacy-tests/softWrap.atom.test.ts delete mode 100644 packages/diffs/test/codemirror-legacy-tests/README.md delete mode 100644 packages/diffs/test/codemirror-legacy-tests/applyEditsBatch.cm.test.ts delete mode 100644 packages/diffs/test/codemirror-legacy-tests/commands.cm.test.ts delete mode 100644 packages/diffs/test/codemirror-legacy-tests/history.cm.test.ts delete mode 100644 packages/diffs/test/codemirror-legacy-tests/historyRemote.cm.test.ts delete mode 100644 packages/diffs/test/codemirror-legacy-tests/pieceTable.cm.test.ts delete mode 100644 packages/diffs/test/codemirror-legacy-tests/remapSelections.cm.test.ts delete mode 100644 packages/diffs/test/codemirror-legacy-tests/selection.cm.test.ts delete mode 100644 packages/diffs/test/monaco-legacy-tests/README.md delete mode 100644 packages/diffs/test/monaco-legacy-tests/applyEdits.monaco.test.ts delete mode 100644 packages/diffs/test/monaco-legacy-tests/editStack.monaco.test.ts delete mode 100644 packages/diffs/test/monaco-legacy-tests/pieceTable.monaco.test.ts delete mode 100644 packages/diffs/test/monaco-legacy-tests/selection.monaco.test.ts delete mode 100644 packages/diffs/test/monaco-legacy-tests/textDocument.monaco.test.ts delete mode 100644 packages/diffs/test/monaco-legacy-tests/wordOperations.monaco.test.ts diff --git a/packages/diffs/test/README.md b/packages/diffs/test/README.md index 48638ec07..b27166c65 100644 --- a/packages/diffs/test/README.md +++ b/packages/diffs/test/README.md @@ -6,24 +6,6 @@ Run from this package directory: AGENT=1 bun test ``` -## Legacy-editor test suites - -`monaco-legacy-tests/` (microsoft/vscode, MIT), `codemirror-legacy-tests/` -(CodeMirror 6, MIT), and `atom-legacy-tests/` (atom/text-buffer + -atom/superstring, MIT) hold editor-behavior scenarios harvested from other -editors' test suites. See each directory's README for provenance, the -`test.failing` known-bug convention, and the `DIVERGENCE:` comment policy. -Discovered by the normal `bun test` run like any other `*.test.ts` files. - -Authorship hygiene for any future additions to these suites: derive only from -permissively-licensed (e.g. MIT) sources; write your own test names (never reuse -the source's, however apt); use original fixtures, variable names, helper -structure, and assertions; organize by this package's architecture rather than -the source suite's file layout or ordering; and re-derive test granularity from -the behavioral requirement (split or merge cases around our API) instead of -mirroring the source's case-by-case structure. Quoting an original test name is -acceptable only inside a traceability comment, as attribution. - ## Conventions - Shared DOM bootstrap lives in `domHarness.ts` (`installDom` always installs @@ -36,6 +18,27 @@ acceptable only inside a traceability comment, as attribution. 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) @@ -69,3 +72,93 @@ 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` (30) + +- **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. +- **History across non-history edits** (6) — history entries are frozen at + creation and never remapped through edits applied without history tracking: + stale-offset undo can corrupt text, undo of a wiped entry is not a graceful + no-op, batch inversion breaks across an interleaved non-tracked insert, + interior non-tracked inserts don't survive undo of a tracked insertion, stored + entry selections are restored without remapping, and redo 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/atom-legacy-tests/README.md b/packages/diffs/test/atom-legacy-tests/README.md deleted file mode 100644 index c97ab1cef..000000000 --- a/packages/diffs/test/atom-legacy-tests/README.md +++ /dev/null @@ -1,111 +0,0 @@ -# atom-legacy-tests - -Behavioral test scenarios harvested from Atom's text-layer test suites and -re-expressed against `@pierre/diffs`' edit APIs. Companion to -`../monaco-legacy-tests/` and `../codemirror-legacy-tests/` (same conventions); -scenarios covered there or in the main suite were filtered out during the audit. - -## Provenance & attribution - -Adapted from the MIT-licensed Atom packages, © GitHub Inc.: - -- [atom/text-buffer](https://github.com/atom/text-buffer) @ - `b1f093269b175ce6cc9728c7a4d50ca75bb031b6` - (`spec/text-buffer-spec.coffee`/`.js`, `spec/marker-spec.coffee`, - `spec/marker-layer-spec.coffee`, `spec/display-layer-spec.js`) -- [atom/superstring](https://github.com/atom/superstring) @ - `6732087fac04cd68d14e93d4f83f246879200ab5` (`test/js/patch.test.js`, - `test/js/text-buffer.test.js`) - -These are behavioral rewrites, not code copies: original test names, fixtures, -helpers, and assertions; organized by this package's architecture rather than -the source suites' layout; granularity re-derived around our API. Every test -carries a traceability comment: - -```ts -// atom-legacy: atom-text-buffer/spec/text-buffer-spec.coffee — "" -``` - -## Conventions - -Same as the sibling suites: `test.failing(...)` = known bug asserting correct -behavior (remove the modifier when fixed); `DIVERGENCE:` comments pin -intentional differences from Atom. - -## Known bugs encoded as `test.failing` (8) - -**Search-replace capture expansion with lookaround** -(`searchReplace.atom.test.ts`, 3) — root cause: `buildSearchReplacementText` -re-executes the pattern against only the matched slice, so lookaround context -outside the slice is lost. - -1. A lookbehind's context sits before the slice; the re-execution finds nothing, - falls back to the raw replace text, and the literal `$1` is inserted into the - document. -2. A lookahead's context sits after the slice — same fallback, unexpanded - replace text inserted. -3. A lookahead that re-matches _shorter_ on the slice (the trailing context - character is part of the slice) trips the full-length guard and the literal - `$&` is inserted. - -**PieceTable CRLF line metadata drives search astray** -(`searchReplace.atom.test.ts`, 1) — splices that split or form `\r\n` pairs -across piece seams corrupt the piece-level line-break counts, 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. Root cause -pinned as directed repros in `../monaco-legacy-tests/pieceTable.monaco.test.ts`. - -**Soft-wrap vertical motion splits surrogate pairs** -(`softWrap.atom.test.ts`, 2) — `moveBySoftLine` computes the landing spot as -target-segment start + visual column in raw UTF-16 units with no -grapheme/surrogate snapping, so ArrowDown into a continuation row (and ArrowUp -across a logical-line boundary) can park the caret between the halves of an -astral character; a subsequent insert at that caret splits the pair into lone -surrogates. - -**Malformed position components destroy the document** -(`positions.atom.test.ts`, 2) — `normalizePosition` has no finiteness/integer -guard, so a `NaN` (or fractional) `line`/`character` flows through -`Math.min`/`Math.max` into `offsetAt`, the resolved offset becomes `NaN`, and -the degenerate range resolves to a whole-document replace: an insert with one -malformed component silently erases everything else. Atom throws `Invalid Point` -on such input. (`Infinity` happens to clamp to a valid offset today — pinned as -a DIVERGENCE.) - -## Intentional divergences (`DIVERGENCE:` comments, 8) - -`searchReplace.atom.test.ts` (5): `$0`/out-of-range capture references resolve -via `match[n] ?? ''` instead of staying literal; zero-length matches are -suppressed (starred patterns and bare `^`/`$` anchors report nothing where Atom -reports empty ranges — two comments); invalid patterns yield zero matches -instead of throwing; panel-driven replace wraps around VS Code-style rather than -stopping at the buffer end. `history.atom.test.ts` (1): every recorded history -step clears redo — no-op transactions are not dropped. -`selectionRemap.atom.test.ts` (1): uniform right gravity at an edit that starts -exactly at a selection start (Atom's marker bias would absorb the new text). -`softWrap.atom.test.ts` (1): no clamp to the final wrap segment's end when -moving up into a shorter last segment — the overshoot acts as an implicit goal -column. - -## Missing features surfaced by this audit (recorded, not tested) - -Atom has dedicated coverage for all of these; candidates for the feature backlog -rather than this suite: - -- **Preferred line-ending override** — `TextDocument.eol` is a derived getter - (first line break); no setter/option lets a host force LF/CRLF policy for - inserted text -- **Range-scoped search** — `SearchParams` has no range field; selection-scoped - find/replace is impossible today -- **Multi-line pattern search** — `PieceTable.search` rejects patterns - containing `\n`/`\r`; matching across line breaks is unsupported (the - rejection itself is pinned in `editorPieceTable.test.ts`) -- **Transactions** — no `transact()`/nested-transaction/abort API; EditStack - groups only via geometric typing coalescing and `undoBoundary` -- **History checkpoints** — no checkpoint/revert-to-checkpoint concept -- **Retroactive change grouping** — no public `groupLastChanges()`-style merge - of the last N history entries -- **Edit-tracking markers with invalidation strategies** — `Marker` is - render-only; no remapping through edits (marker _bias_ scenarios that map onto - selection remapping are tested here; the marker feature itself is not) -- **Soft-wrap continuation-row hanging indent** diff --git a/packages/diffs/test/atom-legacy-tests/history.atom.test.ts b/packages/diffs/test/atom-legacy-tests/history.atom.test.ts deleted file mode 100644 index d341ac666..000000000 --- a/packages/diffs/test/atom-legacy-tests/history.atom.test.ts +++ /dev/null @@ -1,400 +0,0 @@ -// History/coalescing scenarios ported from Atom's text-buffer and superstring -// suites. See README.md in this directory for suite conventions. Two areas: -// (1) degenerate history batches around undo — Atom's 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, -// test/editorEditStack.test.ts, and the monaco/codemirror sibling suites; -// nothing here repeats those. -import { describe, expect, test } from 'bun:test'; - -import { EditStack } from '../../src/editor/editStack'; -import { DirectionNone } from '../../src/editor/selection'; -import { TextDocument } from '../../src/editor/textDocument'; -import type { EditorSelection, TextEdit } from '../../src/types'; - -function doc(text: string, editStack?: EditStack) { - return new TextDocument('inmemory://1', text, 'plain', 0, editStack); -} - -function caret(line: number, character: number) { - const position = { line, character }; - return { - start: position, - end: position, - direction: DirectionNone, - } satisfies EditorSelection; -} - -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, [ - caret(line, character), - ]); -} - -describe('degenerate history batches around undo (atom-legacy)', () => { - // atom-legacy: atom-text-buffer/spec/text-buffer-spec.coffee — "does not push the transaction to the undo stack if it is empty" - // 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, [caret(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, [caret(0, 2)], [caret(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); - }); - - // atom-legacy: atom-text-buffer/spec/text-buffer-spec.coffee — "doesn't notify observers after an empty transaction" - // 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 codemirror-legacy-tests/applyEditsBatch.cm.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, [caret(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); - }); - - // atom-legacy: atom-text-buffer/spec/text-buffer-spec.coffee — "does not push the transaction to the undo stack if it is empty" - // DIVERGENCE: Atom 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 - // codemirror-legacy-tests/applyEditsBatch.cm.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, [caret(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, [ - caret(0, 1), - caret(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 }; -} - -// Runs undo (or redo) to exhaustion and returns the step count, so the fuzz -// can assert the two directions traverse the same number of entries. -function undoToExhaustion(d: ReturnType) { - let steps = 0; - while (d.canUndo) { - d.undo(); - steps++; - } - return steps; -} - -function redoToExhaustion(d: ReturnType) { - let steps = 0; - while (d.canRedo) { - d.redo(); - steps++; - } - return steps; -} - -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 main suite's maxEntries tests) 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 caret(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 = undoToExhaustion(d); - expect(undoSteps).toBeGreaterThan(0); - expect(d.getText()).toBe(baseText); - expect(d.version).toBe(0); - expect(d.canRedo).toBe(true); - - const redoSteps = redoToExhaustion(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 (atom-legacy)', () => { - // atom-legacy: atom-superstring/test/js/patch.test.js — "correctly records random splices" - // Where superstring 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 - // monaco-legacy-tests/editStack.monaco.test.ts) and never applies - // history-skipping edits (the frozen-entry known bugs live in - // codemirror-legacy-tests/historyRemote.cm.test.ts); 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/atom-legacy-tests/positions.atom.test.ts b/packages/diffs/test/atom-legacy-tests/positions.atom.test.ts deleted file mode 100644 index 2f4e9b4d5..000000000 --- a/packages/diffs/test/atom-legacy-tests/positions.atom.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { describe, expect, test } from 'bun:test'; - -import { TextDocument } from '../../src/editor/textDocument'; - -function doc(text: string) { - return new TextDocument('inmemory://1', text, 'plain'); -} - -// 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', () => { - // Atom's clipPosition throws 'Invalid Point' on non-numeric components; - // 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', - () => { - // atom-legacy: atom-text-buffer/spec/text-buffer-spec.coffee — position clipping rejects non-numeric components instead of producing garbage offsets - // 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', - () => { - // atom-legacy: atom-text-buffer/spec/text-buffer-spec.coffee — position clipping rejects non-integer components instead of producing garbage offsets - // 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', () => { - // atom-legacy: atom-text-buffer/spec/text-buffer-spec.coffee — position clipping bounds oversized components - // DIVERGENCE: Atom throws on non-finite components; 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/atom-legacy-tests/searchReplace.atom.test.ts b/packages/diffs/test/atom-legacy-tests/searchReplace.atom.test.ts deleted file mode 100644 index c352c0a73..000000000 --- a/packages/diffs/test/atom-legacy-tests/searchReplace.atom.test.ts +++ /dev/null @@ -1,780 +0,0 @@ -import { describe, expect, test } from 'bun:test'; - -import { - buildSearchReplacementText, - PieceTable, -} from '../../src/editor/pieceTable'; -import type { MatchRange, SearchParams } from '../../src/editor/searchPanel'; -import { SearchPanelWidget } from '../../src/editor/searchPanel'; -import type { ResolvedTextEdit } from '../../src/editor/textDocument'; -import { TextDocument } from '../../src/editor/textDocument'; -import { installDom, wait } from '../domHarness'; - -// Search/replace semantics harvested from Atom's text-buffer scan/replace -// specs and superstring's findAll tests, re-expressed against PieceTable.search, -// buildSearchReplacementText, and SearchPanelWidget. Atom addresses matches as -// Range(Point(row, column)) pairs (0-based); pierre's search returns flat -// [start, end) document offsets, 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 Atom's scan-driven replace 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; -} - -function setInput(input: HTMLInputElement, value: string): void { - input.value = value; - input.dispatchEvent( - new window.Event('input', { bubbles: true, cancelable: true }) - ); -} - -function pressEnter(input: HTMLInputElement): void { - input.dispatchEvent( - new window.KeyboardEvent('keydown', { - key: 'Enter', - bubbles: true, - cancelable: true, - }) - ); -} - -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 host in -// editorSearchPanel.test.ts — 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://atom-replace', - 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 (atom-legacy)', () => { - // atom-legacy: atom-text-buffer/spec/text-buffer-spec.js — "replaces tstat_fvars()->curr_setpoint[HEAT_EN] with tstat_set_curr_setpoint($1, $2);" - test('numbered group references pull the captured text into the replacement', () => { - expect( - replacementsFor( - 'lily, fern', - searchParams('(\\w+), (\\w+)', { replaceText: '$2 & $1' }) - ) - ).toEqual(['fern & lily']); - }); - - // atom-legacy: atom-text-buffer/spec/text-buffer-spec.js — "replaces tstat_fvars()->curr_setpoint[HEAT_EN] with tstat_set_curr_setpoint($1, $2);" - 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']); - }); - - // atom-legacy: atom-text-buffer/spec/text-buffer-spec.js — "replaces atom/flight-manualatomio with $1" - test('group references the pattern never captured collapse to empty text', () => { - // DIVERGENCE: Atom routes replacements through JS String.replace, which - // leaves "$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']); - }); - - // atom-legacy: atom-text-buffer/spec/text-buffer-spec.js — "replaces foo( with bar( using /\bfoo\(\b/gim" - 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]']); - }); - - // atom-legacy: atom-text-buffer/spec/text-buffer-spec.coffee — "does a case-insensitive search" - 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']); - }); - - // atom-legacy: atom-text-buffer/spec/text-buffer-spec.js — "replaces atom/flight-manualatomio with $1" - test('literal (non-regex) mode passes dollar tokens through untouched', () => { - expect( - replacementsFor( - 'k1 k2', - searchParams('k1', { replaceText: '$&-$1', regex: false }) - ) - ).toEqual(['$&-$1']); - }); - - // atom-legacy: atom-text-buffer/spec/text-buffer-spec.js — "replaces tstat_fvars()->curr_setpoint[HEAT_EN] with tstat_set_curr_setpoint($1, $2);" - // 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']); - } - ); - - // atom-legacy: atom-text-buffer/spec/text-buffer-spec.js — "replaces tstat_fvars()->curr_setpoint[HEAT_EN] with tstat_set_curr_setpoint($1, $2);" - // 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(['']); - } - ); - - // atom-legacy: atom-superstring/test/js/text-buffer.test.js — "handles patterns with lookahead that span several chunks" - // 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]']); - } - ); - - // atom-legacy: atom-text-buffer/spec/text-buffer-spec.js — "replaces atom/flight-manualatomio with $1" - test('a pattern that matches nowhere leaves the document untouched', async () => { - const host = mountReplaceHost('gray goose'); - try { - await wait(0); - host.regexToggle.click(); - setInput(host.queryInput, 'swan(\\w)'); - setInput(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 (atom-legacy)', () => { - // atom-legacy: atom-superstring/test/js/text-buffer.test.js — "handles patterns that match the empty string (regression)" - test('a starred pattern reports only its non-empty matches', () => { - // DIVERGENCE: Atom's findAllSync(/^a*/) reports zero-length ranges 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], - ]); - }); - - // atom-legacy: atom-superstring/test/js/text-buffer.test.js — "does not skip empty rows" - 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; Atom returns one empty range per row. - expect(findAll('ivy\nelm', '^')).toEqual([]); - expect(findAll('ivy\nelm', '$')).toEqual([]); - }); - - // atom-legacy: atom-superstring/test/js/text-buffer.test.js — "handles patterns that match the empty string (regression)" - test('an empty alternation arm only ever surfaces the non-empty arm', () => { - expect(findAll('ame', 'm|')).toEqual([[1, 2]]); - }); - - // atom-legacy: atom-superstring/test/js/text-buffer.test.js — "handles patterns that match the empty string (regression)" - 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], - ]); - }); - - // atom-legacy: atom-superstring/test/js/text-buffer.test.js — "does not skip empty rows" - 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], - ]); - }); - - // atom-legacy: atom-superstring/test/js/text-buffer.test.js — "handles patterns that match the empty string (regression)" - test('a starred group terminates on lines where it can only match empty', () => { - expect(findAll('axax\nbb\nax', '(?:ax)*')).toEqual([ - [0, 4], - [8, 10], - ]); - }); - - // atom-legacy: atom-superstring/test/js/text-buffer.test.js — "handles patterns that match the empty string (regression)" - 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]]); - }); - - // atom-legacy: atom-superstring/test/js/text-buffer.test.js — "handles patterns that match the empty string (regression)" - test('matches after astral characters land at UTF-16 offsets', () => { - expect(findAll('\u{1F600}w\u{1F600}ww', 'w+')).toEqual([ - [2, 3], - [5, 7], - ]); - }); - - // atom-legacy: atom-superstring/test/js/text-buffer.test.js — "throws an exception if an invalid pattern is passed" - test('an invalid pattern reports zero matches instead of throwing', () => { - // DIVERGENCE: Atom's find rejects/throws 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 (atom-legacy)', () => { - // atom-legacy: atom-superstring/test/js/text-buffer.test.js — "handles the ^ and $ anchors properly (CRLF line endings)" - 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); - } - }); - - // atom-legacy: atom-superstring/test/js/text-buffer.test.js — "handles the ^ and $ anchors properly" - 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], - ]); - }); - - // atom-legacy: atom-superstring/test/js/text-buffer.test.js — "handles empty matches before CRLF line endings (regression)" - 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 (atom-legacy)', () => { - // atom-legacy: atom-text-buffer/spec/text-buffer-spec.coffee — "replaces each occurrence of the regex match with the string" - test('replacing with text containing the query steps past the insertion', async () => { - const host = mountReplaceHost('ash elm ash'); - try { - await wait(0); - setInput(host.queryInput, 'ash'); - expect(host.scrolled).toEqual([[0, 3]]); - expect(host.matchesLabel()).toBe('1 of 2'); - - setInput(host.replaceInput, 'ashash'); - pressEnter(host.replaceInput); - - 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'); - - pressEnter(host.replaceInput); - 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(); - } - }); - - // atom-legacy: atom-text-buffer/spec/text-buffer-spec.coffee — "replaces each occurrence of the regex match with the string" - test('replace wraps to the document top once no match remains past the caret', async () => { - // DIVERGENCE: Atom's scan-driven replace is one forward pass that stops at - // the buffer end. pierre's panel wraps around (VS Code-style interactive - // 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); - setInput(host.queryInput, 'ash'); - setInput(host.replaceInput, 'ashash'); - pressEnter(host.replaceInput); // 'ashash elm ash', caret past [3, 6] - pressEnter(host.replaceInput); // 'ashash elm ashash', caret at doc end - - pressEnter(host.replaceInput); // 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(); - } - }); - - // atom-legacy: atom-text-buffer/spec/text-buffer-spec.coffee — "allows the match to be replaced with the empty string" - test('replacing with the empty string collapses the caret at the match start', async () => { - const host = mountReplaceHost('oak elm fir'); - try { - await wait(0); - setInput(host.queryInput, 'elm'); - setInput(host.replaceInput, ''); - pressEnter(host.replaceInput); - - 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(); - } - }); - - // atom-legacy: atom-text-buffer/spec/text-buffer-spec.coffee — "replaces each occurrence of the regex match with the string" - 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); - setInput(host.queryInput, 'kk'); - setInput(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(); - } - }); - - // atom-legacy: atom-text-buffer/spec/text-buffer-spec.coffee — "replaces each occurrence of the regex match with the string" - 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); - setInput(host.queryInput, 'ash'); - setInput(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(); - } - }); - - // atom-legacy: atom-text-buffer/spec/text-buffer-spec.js — "replaces tstat_fvars()->curr_setpoint[HEAT_EN] with tstat_set_curr_setpoint($1, $2);" - test('replace all expands capture references per match', async () => { - const host = mountReplaceHost('id 7 and 305'); - try { - await wait(0); - host.regexToggle.click(); - setInput(host.queryInput, '(\\d+)'); - setInput(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 (atom-legacy)', () => { - // atom-legacy: atom-superstring/test/js/text-buffer.test.js — "returns the same results as a reference implementation" - 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 - ); - }); - - // atom-legacy: atom-superstring/test/js/text-buffer.test.js — "returns the same results as a reference implementation" - // 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 - // ../monaco-legacy-tests/pieceTable.monaco.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/atom-legacy-tests/selectionRemap.atom.test.ts b/packages/diffs/test/atom-legacy-tests/selectionRemap.atom.test.ts deleted file mode 100644 index df141cea7..000000000 --- a/packages/diffs/test/atom-legacy-tests/selectionRemap.atom.test.ts +++ /dev/null @@ -1,390 +0,0 @@ -import { describe, expect, test } from 'bun:test'; - -import { EditStack } from '../../src/editor/editStack'; -import { - DirectionBackward, - DirectionForward, - DirectionNone, - remapSelectionsAfterEdits, -} from '../../src/editor/selection'; -import type { ResolvedTextEdit } from '../../src/editor/textDocument'; -import { TextDocument } from '../../src/editor/textDocument'; -import type { EditorSelection, SelectionDirection } from '../../src/types'; - -function doc(text: string) { - return new TextDocument('inmemory://1', text, 'plain'); -} - -// 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 (atom-legacy)', () => { - // atom-legacy: atom-text-buffer/spec/marker-spec.coffee — "moves the start of the marker to the end of the change and invalidates the marker if its stategy is 'overlap', 'inside', or 'touch'" - 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. Atom's 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); - }); - - // atom-legacy: atom-text-buffer/spec/marker-spec.coffee — "moves the end of the marker to the end of the change and invalidates the marker if its stategy is 'overlap', 'inside', or 'touch'" - 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 Atom's 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 (atom-legacy)', () => { - // atom-legacy: atom-text-buffer/spec/marker-spec.coffee — "truncates the marker to the end of the change and invalidates every invalidation strategy except 'never'" - 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 (atom-legacy)', () => { - // atom-legacy: atom-text-buffer/spec/marker-spec.coffee — "interprets the change as being inside the marker for all invalidation strategies" - test('a contained replacement starting exactly at the selection start shrinks the selection to the un-replaced tail', () => { - // DIVERGENCE: Atom's 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 Atom - // 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 (atom-legacy)', () => { - // atom-legacy: atom-superstring/test/js/marker-index.test.js — "maintains correct marker positions during randomized insertions and mutations" - 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 (Atom anchors it, 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 (atom-legacy)', () => { - // 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 }; - } - - // atom-legacy: atom-superstring/test/js/patch.test.js — "correctly records random splices" - 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); - }); - - // atom-legacy: atom-superstring/test/js/patch.test.js — "can invert patches" - 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. Atom's 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 - }); -}); diff --git a/packages/diffs/test/atom-legacy-tests/softWrap.atom.test.ts b/packages/diffs/test/atom-legacy-tests/softWrap.atom.test.ts deleted file mode 100644 index 80078c449..000000000 --- a/packages/diffs/test/atom-legacy-tests/softWrap.atom.test.ts +++ /dev/null @@ -1,548 +0,0 @@ -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 { disposeHighlighter } from '../../src/highlighter/shared_highlighter'; -import type { FileContents } from '../../src/types'; -import { installDom, wait } from '../domHarness'; - -afterAll(async () => { - await disposeHighlighter(); -}); - -// Soft-wrap boundary semantics from Atom's display-layer suite, re-expressed -// against pierre-fe's wrap pipeline. Wrap segmentation is driven by the same -// Range-measurement stub editorWrapCaretPosition.test.ts uses: a visual row -// break is reported every `columns` UTF-16 offsets, making #wrapLineText's -// offsets deterministic. Everything the tests observe is real editor output: -// selection state via editor.getState() and overlay geometry via the inline -// width/transform the editor stamps on [data-caret] / [data-selection-range]. -// -// Geometry constants under the harness: 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 -// Vertical step the measurement stub reports between visual rows. Any positive -// value works; #wrapLineText only compares tops for "moved down". -const STUB_ROW_STEP = 16; - -function stubRect(left: number, top: number): DOMRect { - return { - bottom: top + 1, - height: 1, - left, - right: left + 1, - top, - width: 1, - x: left, - y: top, - toJSON() { - return {}; - }, - } as DOMRect; -} - -function putBackPrototypeProperty( - proto: object, - property: string, - descriptor: PropertyDescriptor | undefined -): void { - if (descriptor !== undefined) { - Object.defineProperty(proto, property, descriptor); - } else { - Reflect.deleteProperty(proto, property); - } -} - -// Make every logical line wrap after `columns` UTF-16 code units by stubbing -// the two rect sources #wrapLineText reads (jsdom measures nothing itself). -function stubWrapEveryNColumns(columns: number): { restore(): void } { - const rangeProto = Object.getPrototypeOf(document.createRange()) as object; - const elementProto = HTMLElement.prototype; - const savedRangeRect = Object.getOwnPropertyDescriptor( - rangeProto, - 'getBoundingClientRect' - ); - const savedElementRect = Object.getOwnPropertyDescriptor( - elementProto, - 'getBoundingClientRect' - ); - - Object.defineProperty(rangeProto, 'getBoundingClientRect', { - configurable: true, - value(this: Range): DOMRect { - const offset = this.startOffset; - return stubRect( - (offset % columns) * CH, - Math.floor(offset / columns) * STUB_ROW_STEP - ); - }, - }); - Object.defineProperty(elementProto, 'getBoundingClientRect', { - configurable: true, - value(): DOMRect { - return stubRect(0, 0); - }, - }); - - return { - restore(): void { - putBackPrototypeProperty( - rangeProto, - 'getBoundingClientRect', - savedRangeRect - ); - putBackPrototypeProperty( - elementProto, - 'getBoundingClientRect', - savedElementRect - ); - }, - }; -} - -interface WrapHarnessWindow extends Window { - KeyboardEvent: { - new (type: string, eventInitDict?: KeyboardEventInit): KeyboardEvent; - }; -} - -interface WrapHarness { - editor: Editor; - root: HTMLElement; - content: HTMLElement; - win: WrapHarnessWindow; - done(): void; -} - -async function openWrapped( - contents: string, - columns: number -): Promise { - const dom = installDom(); - const wrapStub = stubWrapEveryNColumns(columns); - const root = document.createElement('div'); - document.body.appendChild(root); - - const file = new File({ - disableFileHeader: true, - theme: DEFAULT_THEMES, - overflow: 'wrap', - }); - const editor = new Editor(); - const fileContents: FileContents = { name: 'wrapped.ts', contents }; - - file.render({ file: fileContents, fileContainer: root, forceRender: true }); - editor.edit(file); - - let content: HTMLElement | undefined; - for (let attempt = 0; attempt < 20 && content === undefined; attempt++) { - const candidate = root.shadowRoot?.querySelector('[data-content]'); - if ( - candidate instanceof HTMLElement && - (candidate.contentEditable === 'true' || - candidate.getAttribute('contenteditable') === 'true') - ) { - content = candidate; - } else { - await wait(0); - } - } - if (content === undefined) { - throw new Error('wrap harness: content never became editable'); - } - - return { - editor, - root, - content, - win: dom.window as unknown as WrapHarnessWindow, - done(): void { - wrapStub.restore(); - editor.cleanUp(); - file.cleanUp(); - dom.cleanup(); - }, - }; -} - -function placeCaret(h: WrapHarness, line: number, character: number): void { - h.editor.setSelections([ - { - start: { line, character }, - end: { line, character }, - direction: 'none', - }, - ]); -} - -function press(h: WrapHarness, key: string): void { - h.content.dispatchEvent( - new h.win.KeyboardEvent('keydown', { - bubbles: true, - cancelable: true, - composed: true, - key, - }) - ); -} - -function caretState(h: WrapHarness): { line: number; character: number } { - const selection = h.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(h: WrapHarness): { x: number; y: number } { - const carets = h.root.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( - h: WrapHarness -): { x: number; y: number; width: number }[] { - const rects: { x: number; y: number; width: number }[] = []; - h.root.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; -} - -// 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('caret affinity at a wrap boundary (atom-legacy)', () => { - // 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; Atom disambiguates such - // positions with an explicit clipDirection, pierre resolves them with a - // fixed backward affinity (the earlier row wins) in both #getCharX and - // getSoftLineInfo. - const TWO_ROW_LINE = 'q0w1e2r3t4y5u6i7o8p9'; - - // atom-legacy: atom-text-buffer/spec/display-layer-spec.js — "translates points correctly on soft-wrapped lines" - test('a caret on the shared wrap offset draws at the end of the earlier visual row', async () => { - const h = await openWrapped(`${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. - placeCaret(h, 0, 15); - expect(caretXY(h)).toEqual({ x: colX(5), y: ROW_H }); - - // One past the boundary belongs to the continuation row. - placeCaret(h, 0, 11); - expect(caretXY(h)).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. - placeCaret(h, 0, 10); - expect(caretXY(h)).toEqual({ x: colX(10), y: 0 }); - } finally { - h.done(); - } - }); - - // atom-legacy: atom-text-buffer/spec/display-layer-spec.js — "translates points correctly on soft-wrapped lines" - test('Home and End treat a boundary caret as belonging to the row that ends there', async () => { - const h = await openWrapped(`${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). - placeCaret(h, 0, 10); - press(h, 'Home'); - expect(caretState(h)).toEqual({ line: 0, character: 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. - placeCaret(h, 0, 10); - press(h, 'End'); - expect(caretState(h)).toEqual({ line: 0, character: 10 }); - } finally { - h.done(); - } - }); - - // atom-legacy: atom-text-buffer/spec/display-layer-spec.js — "translates points correctly on soft-wrapped lines" - test('ArrowDown carries a boundary caret from wrap offset to wrap offset', async () => { - // Three visual rows: [0,10) [10,20) [20,30). - const h = await openWrapped('wrap_me_at_ten_columns_please!\nnext', 10); - try { - placeCaret(h, 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. - press(h, 'ArrowDown'); - expect(caretState(h)).toEqual({ line: 0, character: 20 }); - - press(h, 'ArrowDown'); - expect(caretState(h)).toEqual({ line: 0, character: 30 }); - } finally { - h.done(); - } - }); -}); - -describe('vertical motion between wrapped rows and grapheme integrity (atom-legacy)', () => { - // atom-legacy: atom-text-buffer/spec/display-layer-spec.js — "treats paired characters as atomic units" - // 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 h = await openWrapped(lineText, 4); - try { - placeCaret(h, 0, 3); - press(h, 'ArrowDown'); - - const { line, character } = caretState(h); - expect(line).toBe(0); - expect(sitsInsideSurrogatePair(lineText, character)).toBe(false); - } finally { - h.done(); - } - } - ); - - // atom-legacy: atom-text-buffer/spec/display-layer-spec.js — "treats paired characters as atomic units" - // 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 h = await openWrapped(`${lineText}\nabc`, 4); - try { - placeCaret(h, 1, 1); - press(h, 'ArrowUp'); - - const { line, character } = caretState(h); - expect(line).toBe(0); - expect(sitsInsideSurrogatePair(lineText, character)).toBe(false); - } finally { - h.done(); - } - } - ); - - // atom-legacy: atom-text-buffer/spec/display-layer-spec.js — "translates points correctly on soft-wrapped lines" - 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 h = await openWrapped('the_quick_brown_fox_jumps\ngoal', 10); - try { - placeCaret(h, 1, 3); - press(h, 'ArrowUp'); - - // Segment-relative column 3 of the FINAL visual row: 20 + 3 = 23. A - // logical-column interpretation would have produced character 3. - expect(caretState(h)).toEqual({ line: 0, character: 23 }); - // And the caret element really renders on the third visual row. - expect(caretXY(h)).toEqual({ x: colX(3), y: 2 * ROW_H }); - } finally { - h.done(); - } - }); - - // atom-legacy: atom-text-buffer/spec/display-layer-spec.js — "translates points correctly on soft-wrapped lines" - test('ArrowUp into a shorter final row keeps the visual column as an overshoot', async () => { - // DIVERGENCE: Atom 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 h = await openWrapped('the_quick_brown_fox_jumps\nreturn 0;', 10); - try { - placeCaret(h, 1, 8); - press(h, 'ArrowUp'); - expect(caretState(h)).toEqual({ line: 0, character: 28 }); - - // The caret element draws clamped to the line end on the last row. - expect(caretXY(h)).toEqual({ x: colX(5), y: 2 * ROW_H }); - - // Round trip: the overshoot column survives the trip back down. - press(h, 'ArrowDown'); - expect(caretState(h)).toEqual({ line: 1, character: 8 }); - } finally { - h.done(); - } - }); -}); - -describe('hard tabs re-expand from each continuation row start (atom-legacy)', () => { - // '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'; - - // atom-legacy: atom-text-buffer/spec/display-layer-spec.js — "re-expands tabs on soft-wrapped lines" - test('caret x after a tab on a continuation row uses tab stops from the segment edge', async () => { - const h = await openWrapped(TABBED_LINE, 5); - try { - // Caret right after the tab: segment prefix 'f\t' spans 2 columns - // (logical-line expansion would make it 3). - placeCaret(h, 0, 7); - expect(caretXY(h)).toEqual({ x: colX(2), y: ROW_H }); - - // Caret after 'g': 3 segment columns (logical-line expansion: 4). - placeCaret(h, 0, 8); - expect(caretXY(h)).toEqual({ x: colX(3), y: ROW_H }); - } finally { - h.done(); - } - }); - - // atom-legacy: atom-text-buffer/spec/display-layer-spec.js — "expands hard tabs on soft-wrapped line segments" - test('selection width over a tab on a continuation row matches segment tab stops', async () => { - const h = await openWrapped(TABBED_LINE, 5); - try { - // Select 'f\tg' — the continuation row from its first character. - h.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(h)).toEqual([ - { x: CONTENT_X, y: ROW_H, width: 3 * CH }, - ]); - } finally { - h.done(); - } - }); -}); - -describe('selection endpoints on wrap offsets (atom-legacy)', () => { - // 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'; - - // atom-legacy: atom-text-buffer/spec/display-layer-spec.js — "translates points correctly on soft-wrapped lines" - test('a selection ending exactly on the wrap offset paints flush to the row edge with no sliver below', async () => { - const h = await openWrapped(`${TWO_ROW_LINE}\nnext`, 10); - try { - h.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(h); - 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 { - h.done(); - } - }); - - // atom-legacy: atom-text-buffer/spec/display-layer-spec.js — "translates points correctly on soft-wrapped lines" - test('a selection starting exactly on the wrap offset paints only on the continuation row', async () => { - const h = await openWrapped(`${TWO_ROW_LINE}\nnext`, 10); - try { - h.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(h)).toEqual([ - { x: CONTENT_X, y: ROW_H, width: 4 * CH }, - ]); - } finally { - h.done(); - } - }); - - // atom-legacy: atom-text-buffer/spec/display-layer-spec.js — "translates points correctly on soft-wrapped lines" - test('a boundary-spanning selection paints exactly one rect per visual row', async () => { - const h = await openWrapped(`${TWO_ROW_LINE}\nnext`, 10); - try { - h.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(h)).toEqual([ - { x: CONTENT_X + 2 * CH, y: 0, width: 8 * CH }, - { x: CONTENT_X, y: ROW_H, width: 5 * CH }, - ]); - } finally { - h.done(); - } - }); -}); diff --git a/packages/diffs/test/codemirror-legacy-tests/README.md b/packages/diffs/test/codemirror-legacy-tests/README.md deleted file mode 100644 index d03d23f13..000000000 --- a/packages/diffs/test/codemirror-legacy-tests/README.md +++ /dev/null @@ -1,78 +0,0 @@ -# codemirror-legacy-tests - -Behavioral test scenarios harvested from CodeMirror 6's test suites and -re-expressed against `@pierre/diffs`' edit APIs. Companion to -`../monaco-legacy-tests/` (same conventions); scenarios already covered there or -in the main suite were filtered out during the audit. Scope: text buffer/rope -semantics, edit application and position remapping, selection & multi-cursor, -undo/redo. IME/composition remains deferred. - -## Provenance & attribution - -Adapted from the MIT-licensed CodeMirror packages, © Marijn Haverbeke and -others: - -- [codemirror/state](https://github.com/codemirror/state) @ - `9c801279cb83011e6f92af778f4443406e8f1200` (`test-text.ts`, `test-change.ts`, - `test-selection.ts`, `test-state.ts`, `test-charcategory.ts`) -- [codemirror/commands](https://github.com/codemirror/commands) @ - `5b9bac974f2c4af3e20b045adef949667872ecad` (`test-history.ts`, - `test-commands.ts`) - -These are behavioral rewrites, not code copies. CodeMirror addresses documents -by flat offsets and anchor/head selections; everything here is translated to -this package's 0-based `{line, character}` positions and `EditorSelection` -ranges. Traceability comment on every test: - -```ts -// codemirror-legacy: cm-state/test/test-change.ts — "" -``` - -## Conventions - -Same as `../monaco-legacy-tests/README.md`: - -- `test.failing(...)` = known bug; the test asserts the _correct_ behavior and - currently fails. Remove the modifier when the bug is fixed. -- `DIVERGENCE:` comments pin intentional/accepted differences from CodeMirror. - -## Known bugs encoded as `test.failing` - -12 tests, full details in each file's `KNOWN BUG` comment: - -- `applyEditsBatch.cm.test.ts` (1): acceptance of a {delete, - insert-at-same-offset} batch depends on the caller's array order — - delete-first throws 'Overlapping text edits are not supported', insert-first - succeeds. -- `commands.cm.test.ts` (3): the line-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. -- `historyRemote.cm.test.ts` (6): one root cause — EditStack entries are frozen - at creation and never remapped through non-history (`updateHistory=false`) - edits. Surfaces pinned: stale-offset undo corrupting remote text, undo of a - wiped entry not being a graceful no-op, batch inversion across an interleaved - remote insert, interior remote inserts not surviving undo of a tracked - insertion, stored entry selections restored without remapping, and redo - replaying at stale offsets. -- `pieceTable.cm.test.ts` (1): `TextDocument.positionAt` can return a position - strictly inside a CRLF pair (character beyond `getLineLength`) that its own - `offsetAt` clamps away, so the round trip silently loses a column. -- `selection.cm.test.ts` (1): `Editor.setSelections` normalizes positions but - never reorders a start-after-end pair, storing an inverted selection that - violates the `start <= end` invariant downstream code assumes. - -## Consciously out of scope - -- Forward word/group delete (`deleteGroupForward`) — missing feature, already - recorded in `../monaco-legacy-tests/README.md`; CodeMirror's newline-boundary - rules for it (`cm-commands/test/test-commands.ts`) are the reference if built. -- History entries mapping through _concurrent non-history edits_ (CodeMirror's - remote-change rebasing: preserving interior inserts on undo, remapping stored - selections through unrecorded edits, redo surviving non-history edits). Where - the current behavior is merely _different but safe_ it is pinned as - DIVERGENCE; where it corrupts text through plausible public-API flows it is - encoded as a known bug; full collab-style rebasing is a non-goal. -- Time-based undo grouping (`newGroupDelay`) — pierre-fe's coalescing is - geometry-based with no clock input; policy decision, not ported. -- IME composition scenarios (deferred with the rest of IME). diff --git a/packages/diffs/test/codemirror-legacy-tests/applyEditsBatch.cm.test.ts b/packages/diffs/test/codemirror-legacy-tests/applyEditsBatch.cm.test.ts deleted file mode 100644 index 8c3d83a53..000000000 --- a/packages/diffs/test/codemirror-legacy-tests/applyEditsBatch.cm.test.ts +++ /dev/null @@ -1,303 +0,0 @@ -import { describe, expect, test } from 'bun:test'; - -import { DirectionNone } from '../../src/editor/selection'; -import { TextDocument } from '../../src/editor/textDocument'; -import type { EditorSelection, TextEdit } from '../../src/types'; - -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 }; -} - -// 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; - }; -} - -describe('applyEdits batch: insert at a deletion boundary', () => { - // codemirror-legacy: cm-state/test/test-change.ts — "can create change sets" - 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, like CodeMirror's 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'); - }); - - // codemirror-legacy: cm-state/test/test-change.ts — "can create change sets" - // 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. CodeMirror's ChangeSet.of accepts the - // batch deterministically 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', () => { - // codemirror-legacy: cm-state/test/test-change.ts — "survives random sequences of changes" - 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', () => { - // codemirror-legacy: cm-state/test/test-change.ts — "can be iterated" - 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], - ], - }); - }); - - // codemirror-legacy: cm-state/test/test-change.ts — "can be iterated" - 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', () => { - // codemirror-legacy: cm-state/test/test-change.ts — "can handle empty sets" - 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); - }); - - // codemirror-legacy: cm-state/test/test-change.ts — "can handle empty sets" - 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', () => { - // codemirror-legacy: cm-state/test/test-state.ts — "throws when a change's bounds are invalid" - // DIVERGENCE: CodeMirror rejects a change with from: -1 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 - }); - - // codemirror-legacy: cm-state/test/test-state.ts — "throws when a change's bounds are invalid" - // DIVERGENCE: CodeMirror rejects to: 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) - }); - - // codemirror-legacy: cm-state/test/test-state.ts — "throws when a change's bounds are invalid" - // DIVERGENCE: CodeMirror throws 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!'); - }); - - // codemirror-legacy: cm-state/test/test-state.ts — "throws when a change's bounds are invalid" - // DIVERGENCE: CodeMirror throws; 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'); - }); -}); diff --git a/packages/diffs/test/codemirror-legacy-tests/commands.cm.test.ts b/packages/diffs/test/codemirror-legacy-tests/commands.cm.test.ts deleted file mode 100644 index fa67a632b..000000000 --- a/packages/diffs/test/codemirror-legacy-tests/commands.cm.test.ts +++ /dev/null @@ -1,489 +0,0 @@ -import { describe, expect, test } from 'bun:test'; - -import { - applyDeleteWordBackwardToSelections, - applyTextChangeToSelections, - DirectionForward, - DirectionNone, - expandCollapsedSelectionToWord, - getSelectedLineBlocks, - resolveIndentEdits, - shiftSelectionLines, -} from '../../src/editor/selection'; -import { TextDocument } from '../../src/editor/textDocument'; -import type { EditorSelection, TextEdit } from '../../src/types'; - -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, - } satisfies EditorSelection; -} - -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; -} - -// 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. editorApplyEdits.test.ts 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('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', () => { - // codemirror-legacy: cm-commands/test/test-commands.ts — "doesn't double-indent a given line" - 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', - () => { - // codemirror-legacy: cm-commands/test/test-commands.ts — "doesn't double-indent a given line" - 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', - () => { - // codemirror-legacy: cm-commands/test/test-commands.ts — "doesn't double-indent a given line" - 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). CodeMirror 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', () => { - // codemirror-legacy: cm-commands/test/test-commands.ts — "can split tabs" - // DIVERGENCE: CodeMirror 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', () => { - // codemirror-legacy: cm-commands/test/test-commands.ts — "takes tabs into account" - // DIVERGENCE: CodeMirror 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', () => { - // codemirror-legacy: cm-commands/test/test-commands.ts — "takes tabs into account" - // DIVERGENCE: CodeMirror 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('delete word backward at whitespace and newline boundaries', () => { - test('after a single leading space it deletes only the line-local space', () => { - // codemirror-legacy: cm-commands/test/test-commands.ts — "stops deleting at a newline" - // 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', () => { - // codemirror-legacy: cm-commands/test/test-commands.ts — "stops deleting at a newline" - 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', () => { - // codemirror-legacy: cm-commands/test/test-commands.ts — "stops deleting after a newline" - // 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', () => { - // codemirror-legacy: cm-commands/test/test-commands.ts — "deletes a group of punctuation" - 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', () => { - // codemirror-legacy: cm-commands/test/test-commands.ts — "deletes a group of space" - // ' \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', () => { - // codemirror-legacy: cm-state/test/test-charcategory.ts — "categorises into alphanumeric" - 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", () => { - // codemirror-legacy: cm-state/test/test-charcategory.ts — "categorises into other" - const d = doc('tag { - // codemirror-legacy: cm-state/test/test-charcategory.ts — "categorises into other" - // 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', - () => { - // codemirror-legacy: cm-state/test/test-charcategory.ts — "categorises into alphanumeric" - // 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 ../monaco-legacy-tests/wordOperations.monaco.test.ts); - // 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); - } - ); -}); - -describe('move line commands with merged and same-line multi-cursor blocks', () => { - test('interleaved ranges and a caret merge into one block moving up', () => { - // codemirror-legacy: cm-commands/test/test-commands.ts — "moves blocks made of multiple ranges as one" (moveLineUp) - // 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', () => { - // codemirror-legacy: cm-commands/test/test-commands.ts — "moves blocks made of multiple ranges as one" (moveLineDown) - 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', () => { - // codemirror-legacy: cm-commands/test/test-commands.ts — "preserves multiple cursors on a single line" (moveLineDown) - 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', () => { - // codemirror-legacy: cm-commands/test/test-commands.ts — "preserves multiple cursors on a single line" (moveLineUp) - 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', () => { - // codemirror-legacy: cm-commands/test/test-commands.ts — "does not include a trailing line after a range" (moveLineUp) - // 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', () => { - // codemirror-legacy: cm-commands/test/test-commands.ts — "keeps indentation" (insertNewlineKeepIndent) - 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', () => { - // codemirror-legacy: cm-commands/test/test-commands.ts — "clears empty lines before the cursor" (insertNewlineAndIndent) - // DIVERGENCE: CodeMirror's language-aware insertNewlineAndIndent replaces - // a whitespace-only line with '\n', clearing the stale indentation. - // Pierre-fe's Enter is keep-indent semantics (like CodeMirror's own - // insertNewlineKeepIndent): 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 the - // 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', () => { - // codemirror-legacy: cm-commands/test/test-commands.ts — "deletes the selection" (insertNewlineKeepIndent) - // 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', () => { - // codemirror-legacy: cm-commands/test/test-commands.ts — "keeps zero indentation" (insertNewlineKeepIndent) - 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/codemirror-legacy-tests/history.cm.test.ts b/packages/diffs/test/codemirror-legacy-tests/history.cm.test.ts deleted file mode 100644 index d1096f597..000000000 --- a/packages/diffs/test/codemirror-legacy-tests/history.cm.test.ts +++ /dev/null @@ -1,353 +0,0 @@ -// Undo/redo selection restore and history traversal scenarios ported from -// CodeMirror 6's history suite. See README.md in this directory for suite -// conventions. 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. -import { describe, expect, test } from 'bun:test'; - -import { DirectionNone } from '../../src/editor/selection'; -import { TextDocument } from '../../src/editor/textDocument'; -import type { EditorSelection } from '../../src/types'; - -function doc(text: string) { - return new TextDocument('inmemory://1', text, 'plain'); -} - -function caret(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, - [caret(line, character)], - [caret(line, character + text.length)], - undoBoundary - ); -} - -// 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; -} - -describe('TextDocument history selection restore (codemirror-legacy)', () => { - // codemirror-legacy: cm-commands/test/test-history.ts — "puts the cursor after the change on redo" - // 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, - [caret(0, 3)], - [caret(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([caret(0, 3)]); - - const redoResult = d.redo(); - expect(d.getText()).toBe('red!\n\nblue'); - expect(redoResult?.[1]).toEqual([caret(0, 4)]); - }); - - // codemirror-legacy: cm-commands/test/test-history.ts — "restores selection on undo-redo-undo" - // 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, - [caret(1, 3)], - [caret(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([caret(1, 3)]); - expect(d.getText()).toBe('ash\noak\nelm'); - - expect(d.redo()?.[1]).toEqual([caret(1, 4)]); - expect(d.getText()).toBe('ash\noak.\nelm'); - - expect(d.undo()?.[1]).toEqual([caret(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([caret(1, 4)]); - expect(d.undo()?.[1]).toEqual([caret(1, 3)]); - }); - - // codemirror-legacy: cm-commands/test/test-history.ts — "restores the selection before the first change in an item (#46)" - // Three keystrokes coalesce into one undo entry; the merged entry must keep - // the selectionsBefore of the FIRST keystroke (CodeMirror regression #46 - // restored an intermediate caret instead). - 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([caret(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([caret(0, 3)]); - }); - - // codemirror-legacy: cm-commands/test/test-history.ts — "doesn't merge document changes if there's a selection change in between" - // DIVERGENCE: CodeMirror 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 sibling - // monaco suite (editStack.monaco.test.ts) pins 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([caret(0, 0)]); - }); - - // codemirror-legacy: cm-commands/test/test-history.ts — "doesn't merge document changes if there's a selection change in between" - // The CM 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, - }, - ], - [caret(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); - }); - - // codemirror-legacy: cm-commands/test/test-history.ts — "can go back and forth through history multiple times" - // 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, - [caret(0, 12)], - [caret(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); - } - }); - - // codemirror-legacy: cm-commands/test/test-history.ts — "restores selection on redo" - // 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 = [caret(0, 2), caret(1, 3), caret(2, 3)]; - const selectionsAfter = [caret(0, 3), caret(1, 4), caret(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); - }); -}); diff --git a/packages/diffs/test/codemirror-legacy-tests/historyRemote.cm.test.ts b/packages/diffs/test/codemirror-legacy-tests/historyRemote.cm.test.ts deleted file mode 100644 index 991e156d1..000000000 --- a/packages/diffs/test/codemirror-legacy-tests/historyRemote.cm.test.ts +++ /dev/null @@ -1,296 +0,0 @@ -// Undo/redo interacting with non-history edits (updateHistory=false), ported -// from CodeMirror's history suite. See README.md in this directory for suite -// conventions. -// -// Root cause shared by every test.failing below: EditStack entries are frozen -// at creation. When an edit is applied with updateHistory=false (the default -// for Editor.applyEdits — collaborative patches, programmatic fixes, etc.), -// nothing remaps the offsets stored in existing undo/redo entries, so a later -// undo()/redo() applies its inverse/forward edits at stale offsets and -// corrupts the buffer. CodeMirror rebases every stored history item (and its -// selections) through each non-history transaction. The failing tests pin -// distinct surfaces of that one missing mechanism: single-entry undo, the -// dead-entry no-op, batch inversion round trips, interior-insert splitting, -// stored-selection remapping, and the redo direction. -import { describe, expect, test } from 'bun:test'; - -import { DirectionNone } from '../../src/editor/selection'; -import { TextDocument } from '../../src/editor/textDocument'; -import type { EditorSelection, TextEdit } from '../../src/types'; - -function doc(text: string) { - return new TextDocument('inmemory://1', text, 'plain'); -} - -function caret(line: number, character: number) { - const position = { line, character }; - return { - start: position, - end: position, - direction: DirectionNone, - } satisfies EditorSelection; -} - -// Every fixture in this file is single-line, so a character index on line 0 is -// also the flat document offset — which keeps the translation from -// CodeMirror's offset-based scenarios direct. -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 ?? [caret(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)]); -} - -describe('undo/redo across non-history edits (codemirror-legacy)', () => { - // codemirror-legacy: cm-commands/test/test-history.ts — "allows to undo a change" / "supports non-tracked changes next to tracked changes" - // Baseline sanity: a non-history edit strictly AFTER the tracked range does - // not shift its stored offsets, so undo restores exactly the tracked change - // even today. The failing tests below differ only in putting the remote edit - // at/before the tracked offsets. - 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'); - }); - - // codemirror-legacy: cm-commands/test/test-history.ts — "allows changes that aren't part of the history" - // KNOWN BUG: undo applies the stored inverse edit at its original offsets - // without remapping through the two non-history inserts, so it deletes the - // remote prefix plus part of the remote-shifted typed text instead of the - // typed text itself (actual today: "ilot?"). - test.failing( - 'undo reverts only the tracked change, leaving non-history text intact', - () => { - const d = doc(''); - localEdit(d, 0, 0, 'pilot'); // tracked typing - remoteEdit(d, 0, 0, 'sync'); // remote prefix: "syncpilot" - remoteEdit(d, 9, 9, '?'); // remote suffix: "syncpilot?" - expect(d.getText()).toBe('syncpilot?'); - - d.undo(); - expect(d.getText()).toBe('sync?'); - } - ); - - // codemirror-legacy: cm-commands/test/test-history.ts — "doesn't get confused by an undo not adding any redo item" - // KNOWN BUG: when a non-history edit has replaced the entire region a - // history entry covers, the entry is dead — undo must be a graceful no-op - // (and must not leave behind a redo item that re-corrupts). Today undo - // applies the stale inverse range to the replacement text ("core" -> "ce") - // and redo then splices the old typed text back in ("cGHe"). - test.failing( - 'undo is a graceful no-op when a non-history edit wiped the tracked region', - () => { - const d = doc('ok'); - localEdit(d, 1, 1, 'GH'); // tracked: "oGHk" - remoteEdit(d, 0, 4, 'core'); // remote replaces the whole doc - expect(d.getText()).toBe('core'); - - d.undo(); - expect(d.getText()).toBe('core'); - - // Whether the dead entry is dropped or kept, redo must not corrupt. - if (d.canRedo) { - d.redo(); - } - expect(d.getText()).toBe('core'); - } - ); - - // codemirror-legacy: cm-commands/test/test-history.ts — "accurately maps changes through each other" - // KNOWN BUG: a batch entry stores per-sub-edit inverse offsets chained - // through the batch's own deltas, but none of them are remapped through the - // later non-history insert that landed between the sub-edits. Undo then - // treats the remote text as if it were the tracked replacements (actual - // today: undo -> "pqrWXYZ", redo -> "UVWXYZWXYZ"). - test.failing( - 'a replacement batch round-trips through undo/redo across a non-history insert', - () => { - 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, - [caret(0, 3)] - ); - expect(d.getText()).toBe('UVWXYZ'); - - // Remote insert exactly between the first and second replacements. - remoteEdit(d, 2, 2, '####'); - expect(d.getText()).toBe('UV####WXYZ'); - - // Undo reverts the three replacements around the remote text. - d.undo(); - expect(d.getText()).toBe('p####qr'); - - // Redo restores the exact pre-undo document. - d.redo(); - expect(d.getText()).toBe('UV####WXYZ'); - } - ); - - // codemirror-legacy: cm-commands/test/test-history.ts — "preserves text inserted inside a change" - // KNOWN BUG: undoing a tracked insertion must split its deletion around - // non-history text that was inserted INSIDE the inserted range, deleting - // only the tracked characters. Today the whole stale range [0,4) is deleted - // from "WXjYZ", which erases the remote "j" and strands a tracked "Z" - // (actual today: "Z"). - test.failing( - 'non-history text inserted inside a tracked insertion survives undo', - () => { - const d = doc(''); - localEdit(d, 0, 0, 'WXYZ'); // tracked insertion - remoteEdit(d, 2, 2, 'j'); // remote insert in the middle of it - expect(d.getText()).toBe('WXjYZ'); - - d.undo(); - expect(d.getText()).toBe('j'); - } - ); - - // codemirror-legacy: cm-commands/test/test-history.ts — "properly maps selections through non-history changes" / "rebases selection on undo" - // KNOWN BUG: the selections stored in a history entry must be remapped - // through non-history edits before being restored. Geometry here is chosen - // so the BUFFER restore is exact (the tracked delete sits at offset 0, - // unshifted by the later remote insert) and only the selection contract is - // under test: the caret that sat past the remote insert's position must - // shift by its length, the one before it must not. Today the entry's - // selectionsBefore come back verbatim (6 and 11 instead of 6 and 13). - test.failing( - 'history-entry selections are remapped through later non-history edits before restore', - () => { - const d = doc('hello world'); - // Tracked delete of the leading word, with two carets recorded. - d.applyEdits([lineEdit(0, 5, '')], true, [caret(0, 6), caret(0, 11)]); - expect(d.getText()).toBe(' world'); - - // Remote 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'); - - const result = d.undo(); - expect(d.getText()).toBe('hello worXYld'); - const selections = result?.[1]; - expect(selections?.map((s) => s.start.character)).toEqual([6, 13]); - } - ); - - // codemirror-legacy: cm-commands/test/test-history.ts — "can group events around a non-history transaction" - // Two keystrokes separated by an interleaved non-history insert still - // coalesce into one undo group, and the group inverts around the remote - // character: one undo leaves exactly the remote text. Note this currently - // works because the coalescing adjacency check (next insert starts at the - // previous entry's inverse end) happens to line up in this geometry, not - // because entries are remapped through remote edits. - test('adjacent typing coalesces into one undo group across a non-history edit', () => { - const d = doc(''); - localEdit(d, 0, 0, 'a'); // tracked keystroke: "a" - remoteEdit(d, 1, 1, 'b'); // remote: "ab" - localEdit(d, 1, 1, 'c'); // tracked keystroke right after the "a": "acb" - expect(d.getText()).toBe('acb'); - - // Both keystrokes revert in a single undo step, keeping the remote "b". - d.undo(); - expect(d.getText()).toBe('b'); - expect(d.canUndo).toBe(false); - - // Redo direction: the coalesced group replays around the remote text. - d.redo(); - expect(d.getText()).toBe('acb'); - }); - - // codemirror-legacy: cm-commands/test/test-history.ts — "supports querying for the undo and redo depth" - // Non-history edits must neither consume undo entries nor clear the redo - // stack (clearRedo only fires when a new history entry is pushed). Geometry - // keeps every remote edit after the tracked offsets so the surviving - // entries also APPLY correctly today; the mapped-offset failure is split - // into the next test. - 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); - }); - - // codemirror-legacy: cm-commands/test/test-history.ts — "supports querying for the undo and redo depth" - // KNOWN BUG: the redo entry correctly survives a non-history edit (previous - // test), but its forward edits are never remapped through it, so redo - // re-inserts at the stale offset (actual today: ">> n!ote" — the "!" lands - // mid-word instead of at the end it was typed at). - test.failing( - 'a surviving redo entry applies at offsets mapped through the non-history edit', - () => { - 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, '>> '); // remote prefix while redo is pending - expect(d.getText()).toBe('>> note'); - expect(d.canRedo).toBe(true); - - d.redo(); - expect(d.getText()).toBe('>> note!'); - } - ); -}); diff --git a/packages/diffs/test/codemirror-legacy-tests/pieceTable.cm.test.ts b/packages/diffs/test/codemirror-legacy-tests/pieceTable.cm.test.ts deleted file mode 100644 index 74b8f4e2b..000000000 --- a/packages/diffs/test/codemirror-legacy-tests/pieceTable.cm.test.ts +++ /dev/null @@ -1,527 +0,0 @@ -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'; - -// Rope/buffer semantics harvested from CodeMirror 6's Text tests (cm-state -// test-text.ts) and re-expressed against PieceTable and TextDocument. CM -// addresses documents by flat offsets; scenarios here are translated to -// 0-based {line, character} positions. See README.md for provenance. - -function doc(text: string) { - return new TextDocument('inmemory://1', text, 'plain'); -} - -// 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` (counted as -// ONE break) — the same policy as computeLineOffsets. 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] }; -} - -// 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('mixed-EOL positionAt/offsetAt round trip (codemirror-legacy)', () => { - // codemirror-legacy: cm-state/test/test-text.ts — "can get line info by position" - test('PieceTable: offsetAt of positionAt is the identity at every offset, even inside CRLF pairs', () => { - // DIVERGENCE: CM (and VS Code) 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); - } - }); - - // codemirror-legacy: cm-state/test/test-text.ts — "can get line info by position" - 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. CM 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); - }); - - // codemirror-legacy: cm-state/test/test-text.ts — "can get line info by position" - // 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) - ); - } - } - ); - - // codemirror-legacy: cm-state/test/test-text.ts — "can get line info by position" - 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 stability CM guarantees for its - // 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 (codemirror-legacy)', () => { - // codemirror-legacy: cm-state/test/test-text.ts — "can get line info by line number" - test('PieceTable.offsetAt: negative line maps to 0, line at or past lineCount throws', () => { - // DIVERGENCE: CM throws "Invalid line" on BOTH sides (line(0) and - // line(lines + 1) with its 1-based lines). 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' - ); - }); - - // codemirror-legacy: cm-state/test/test-text.ts — "can get line info by position" - test('TextDocument.offsetAt silently clamps every out-of-range position', () => { - // DIVERGENCE: CM's lineAt(-10) and lineAt(length + 1) throw "Invalid - // position". 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); - }); - - // codemirror-legacy: cm-state/test/test-text.ts — "can get line info by line number" - 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. CM 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 (codemirror-legacy)', () => { - // codemirror-legacy: cm-state/test/test-text.ts — "can retrieve pieces of text" - 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 (codemirror-legacy)', () => { - // codemirror-legacy: cm-state/test/test-text.ts — "can build up a doc by repeated appending" - 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 (codemirror-legacy)', () => { - // codemirror-legacy: cm-state/test/test-text.ts — "rebalances on insert" - 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 (codemirror-legacy)', () => { - // codemirror-legacy: cm-state/test/test-text.ts — "clips out-of-range boundaries" - 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(''); - }); - - // codemirror-legacy: cm-state/test/test-text.ts — "clips out-of-range boundaries" - test('TextDocument.getText returns empty for an inverted range while applyEdits swaps it', () => { - // DIVERGENCE (layer contrast): CM clips inverted slice bounds to empty - // and rejects 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 (codemirror-legacy)', () => { - // codemirror-legacy: cm-state/test/test-text.ts — "properly maintains content during editing" - // (adapted: 50k chars with zero line breaks, fragmented, then split in two) - 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, - }); - }); - - // codemirror-legacy: cm-state/test/test-text.ts — "can get line info by position" - // (adapted: huge character values against a single 50k-char line) - 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/codemirror-legacy-tests/remapSelections.cm.test.ts b/packages/diffs/test/codemirror-legacy-tests/remapSelections.cm.test.ts deleted file mode 100644 index f6c2d67ac..000000000 --- a/packages/diffs/test/codemirror-legacy-tests/remapSelections.cm.test.ts +++ /dev/null @@ -1,255 +0,0 @@ -import { describe, expect, test } from 'bun:test'; - -import { - DirectionBackward, - DirectionForward, - DirectionNone, - remapSelectionsAfterEdits, -} from '../../src/editor/selection'; -import type { ResolvedTextEdit } from '../../src/editor/textDocument'; -import { TextDocument } from '../../src/editor/textDocument'; -import type { EditorSelection, SelectionDirection } from '../../src/types'; - -function doc(text: string) { - return new TextDocument('inmemory://1', text, 'plain'); -} - -// 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 (codemirror-legacy)', () => { - // codemirror-legacy: cm-state/test/test-change.ts — "stays on its own side of replacements" - test('caret at the start boundary of a replaced range lands after the replacement', () => { - // DIVERGENCE: CodeMirror maps a position sitting exactly at the start of a - // replaced span back to the span's start under BOTH assoc -1 and assoc 1 - // (mapPos never moves it 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 versus - // CM, 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'); - // CM would report 7 (the replacement start); pierre reports 10, after 'fig'. - expect(offsets).toEqual([10, 10]); - expect(result.direction).toBe(DirectionNone); - }); - - // codemirror-legacy: cm-state/test/test-change.ts — "stays on its own side of replacements" - 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 CM's - // assoc-aware mapPos 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]); - }); - - // codemirror-legacy: cm-state/test/test-change.ts — "stays in between replacements" - test('caret between two adjacent replacements lands after the second one', () => { - // DIVERGENCE: CM keeps a caret at the seam of two touching replacements - // exactly at that seam (mapPos returns the boundary for assoc -1 and 1). - // 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'); - // CM 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 (codemirror-legacy)', () => { - const preText = 'stormcloud'; - // Selection over offsets [3, 7] — the letters 'rmcl'. - const pair: [number, number] = [3, 7]; - - // codemirror-legacy: cm-state/test/test-change.ts — "maps through an insertion" - test('insertion exactly at the selection end is absorbed into the selection', () => { - // DIVERGENCE: CM maps a non-empty range's edges with outward bias (from - // with assoc 1, to with assoc -1), 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); - }); - - // codemirror-legacy: cm-state/test/test-change.ts — "maps through an insertion" - 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 CM's outward-bias mapping of `from`.) - 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 (codemirror-legacy)', () => { - // Deleting offsets [3, 7) — the letters 'rotc' of 'carrotcake'. - const preText = 'carrotcake'; - const edits: ResolvedTextEdit[] = [{ start: 3, end: 7, text: '' }]; - - // codemirror-legacy: cm-state/test/test-change.ts — "maps through deletion" - test('carets at deletion start, strictly inside, and at deletion end all converge to the deletion start', () => { - // Matches CM's plain mapPos (no MapMode): every position touching the - // deleted span collapses to where the span used to begin. CM can still - // distinguish the three via TrackDel/TrackBefore/TrackAfter map 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]); - }); - - // codemirror-legacy: cm-state/test/test-change.ts — "maps through deletion" - 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 (codemirror-legacy)', () => { - // codemirror-legacy: cm-state/test/test-change.ts — "maps through mixed edits" - 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'); - }); - - // codemirror-legacy: cm-state/test/test-change.ts — "maps through multiple insertions" - test('edits must be sorted ascending by start: unsorted input silently drops earlier edits', () => { - // DIVERGENCE / contract pin: CM's ChangeSet.of accepts change specs in any - // order and normalizes them internally, so mapPos always sees every change. - // 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]); - }); -}); diff --git a/packages/diffs/test/codemirror-legacy-tests/selection.cm.test.ts b/packages/diffs/test/codemirror-legacy-tests/selection.cm.test.ts deleted file mode 100644 index 95ab5f1c9..000000000 --- a/packages/diffs/test/codemirror-legacy-tests/selection.cm.test.ts +++ /dev/null @@ -1,346 +0,0 @@ -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 { - applyTextReplaceToSelections, - DirectionBackward, - DirectionForward, - DirectionNone, - mergeOverlappingSelections, -} from '../../src/editor/selection'; -import { TextDocument } 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(); -}); - -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 }; -} - -// CodeMirror addresses selections by flat anchor/head offsets. All the pure -// merge scenarios below live on line 0, so `character` doubles as the flat -// offset from the CodeMirror originals. -function sel( - startCharacter: number, - endCharacter: number, - direction: SelectionDirection = DirectionForward -): EditorSelection { - return { - start: { line: 0, character: startCharacter }, - end: { line: 0, character: endCharacter }, - direction, - }; -} - -describe('caret at the shared boundary of touching ranges (codemirror-legacy)', () => { - // codemirror-legacy: cm-state/test/test-selection.ts — "merges adjacent point ranges when normalizing" - 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 CodeMirror 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), - ]); - }); - - // codemirror-legacy: cm-state/test/test-selection.ts — "merges adjacent point ranges when normalizing" - 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. CodeMirror 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 (codemirror-legacy)', () => { - // codemirror-legacy: cm-state/test/test-selection.ts — "merges and sorts ranges when normalizing" - 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 CodeMirror normalizes to. - 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: CodeMirror returns ranges re-sorted by position - // (0/6,6/7,7/8,9/13,13/14) and tracks the primary via a separate - // mainIndex. 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 (codemirror-legacy)', () => { - // codemirror-legacy: cm-state/test/test-state.ts — "does the right thing when there are multiple selections" - 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. CodeMirror's changeByRange - // reports the remapped 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 - } - }); - - // codemirror-legacy: cm-state/test/test-state.ts — "does the right thing when there are multiple selections" - 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 (codemirror-legacy)', () => { - // codemirror-legacy: cm-state/test/test-state.ts — "throws when a change's bounds are invalid" - // (selection analog: CodeMirror's checkSelection throws RangeError - // "Selection points outside of document" for the same out-of-bounds input on - // state creation/update.) - test('positions past a line length or past the last line clamp instead of throwing', async () => { - // DIVERGENCE: CodeMirror rejects 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(); - } - }); - - // codemirror-legacy: cm-state/test/test-state.ts — "throws when a change's bounds are invalid" - 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 (codemirror-legacy)', () => { - // codemirror-legacy: cm-state/test/test-selection.ts — "stores ranges with a primary 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. CodeMirror's - // EditorSelection.range(3, 2) normalizes 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(); - } - } - ); -}); diff --git a/packages/diffs/test/editorApplyEdits.test.ts b/packages/diffs/test/editorApplyEdits.test.ts index 29c62bab8..6c65457e9 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,829 @@ 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('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..89a6ed9c8 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,1158 @@ 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). +// +// Root cause shared by every test.failing below: EditStack entries are frozen +// at creation. When an edit is applied with updateHistory=false (the default +// for Editor.applyEdits — collaborative patches, programmatic fixes, etc.), +// nothing remaps the offsets stored in existing undo/redo entries, so a later +// undo()/redo() applies its inverse/forward edits at stale offsets and +// corrupts the buffer. The conventional approach rebases every stored history +// item (and its selections) through each non-history transaction. The failing +// tests pin distinct surfaces of that one missing mechanism: single-entry +// undo, the dead-entry no-op, batch inversion round trips, interior-insert +// splitting, stored-selection remapping, and the redo direction. +describe('undo/redo across non-history edits', () => { + // Baseline sanity: a non-history edit strictly AFTER the tracked range does + // not shift its stored offsets, so undo restores exactly the tracked change + // even today. The failing tests below differ only in putting the remote edit + // at/before the tracked offsets. + 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: undo applies the stored inverse edit at its original offsets + // without remapping through the two non-history inserts, so it deletes the + // remote prefix plus part of the remote-shifted typed text instead of the + // typed text itself (actual today: "ilot?"). + test.failing( + 'undo reverts only the tracked change, leaving non-history text intact', + () => { + const d = doc(''); + localEdit(d, 0, 0, 'pilot'); // tracked typing + remoteEdit(d, 0, 0, 'sync'); // remote prefix: "syncpilot" + remoteEdit(d, 9, 9, '?'); // remote suffix: "syncpilot?" + expect(d.getText()).toBe('syncpilot?'); + + d.undo(); + expect(d.getText()).toBe('sync?'); + } + ); + + // KNOWN BUG: when a non-history edit has replaced the entire region a + // history entry covers, the entry is dead — undo must be a graceful no-op + // (and must not leave behind a redo item that re-corrupts). Today undo + // applies the stale inverse range to the replacement text ("core" -> "ce") + // and redo then splices the old typed text back in ("cGHe"). + test.failing( + 'undo is a graceful no-op when a non-history edit wiped the tracked region', + () => { + const d = doc('ok'); + localEdit(d, 1, 1, 'GH'); // tracked: "oGHk" + remoteEdit(d, 0, 4, 'core'); // remote replaces the whole doc + expect(d.getText()).toBe('core'); + + d.undo(); + expect(d.getText()).toBe('core'); + + // Whether the dead entry is dropped or kept, redo must not corrupt. + if (d.canRedo) { + d.redo(); + } + expect(d.getText()).toBe('core'); + } + ); + + // KNOWN BUG: a batch entry stores per-sub-edit inverse offsets chained + // through the batch's own deltas, but none of them are remapped through the + // later non-history insert that landed between the sub-edits. Undo then + // treats the remote text as if it were the tracked replacements (actual + // today: undo -> "pqrWXYZ", redo -> "UVWXYZWXYZ"). + test.failing( + 'a replacement batch round-trips through undo/redo across a non-history insert', + () => { + 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'); + + // Remote insert exactly between the first and second replacements. + remoteEdit(d, 2, 2, '####'); + expect(d.getText()).toBe('UV####WXYZ'); + + // Undo reverts the three replacements around the remote text. + d.undo(); + expect(d.getText()).toBe('p####qr'); + + // Redo restores the exact pre-undo document. + d.redo(); + expect(d.getText()).toBe('UV####WXYZ'); + } + ); + + // KNOWN BUG: undoing a tracked insertion must split its deletion around + // non-history text that was inserted INSIDE the inserted range, deleting + // only the tracked characters. Today the whole stale range [0,4) is deleted + // from "WXjYZ", which erases the remote "j" and strands a tracked "Z" + // (actual today: "Z"). + test.failing( + 'non-history text inserted inside a tracked insertion survives undo', + () => { + const d = doc(''); + localEdit(d, 0, 0, 'WXYZ'); // tracked insertion + remoteEdit(d, 2, 2, 'j'); // remote insert in the middle of it + expect(d.getText()).toBe('WXjYZ'); + + d.undo(); + expect(d.getText()).toBe('j'); + } + ); + + // KNOWN BUG: the selections stored in a history entry must be remapped + // through non-history edits before being restored. Geometry here is chosen + // so the BUFFER restore is exact (the tracked delete sits at offset 0, + // unshifted by the later remote insert) and only the selection contract is + // under test: the caret that sat past the remote insert's position must + // shift by its length, the one before it must not. Today the entry's + // selectionsBefore come back verbatim (6 and 11 instead of 6 and 13). + test.failing( + 'history-entry selections are remapped through later non-history edits before restore', + () => { + 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'); + + // Remote 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'); + + const result = d.undo(); + expect(d.getText()).toBe('hello worXYld'); + const selections = result?.[1]; + expect(selections?.map((s) => s.start.character)).toEqual([6, 13]); + } + ); + + // Two keystrokes separated by an interleaved non-history insert still + // coalesce into one undo group, and the group inverts around the remote + // character: one undo leaves exactly the remote text. Note this currently + // works because the coalescing adjacency check (next insert starts at the + // previous entry's inverse end) happens to line up in this geometry, not + // because entries are remapped through remote edits. + test('adjacent typing coalesces into one undo group across a non-history edit', () => { + const d = doc(''); + localEdit(d, 0, 0, 'a'); // tracked keystroke: "a" + remoteEdit(d, 1, 1, 'b'); // remote: "ab" + localEdit(d, 1, 1, 'c'); // tracked keystroke right after the "a": "acb" + expect(d.getText()).toBe('acb'); + + // Both keystrokes revert in a single undo step, keeping the remote "b". + d.undo(); + expect(d.getText()).toBe('b'); + expect(d.canUndo).toBe(false); + + // Redo direction: the coalesced group replays around the remote text. + d.redo(); + expect(d.getText()).toBe('acb'); + }); + + // Non-history edits must neither consume undo entries nor clear the redo + // stack (clearRedo only fires when a new history entry is pushed). Geometry + // keeps every remote edit after the tracked offsets so the surviving + // entries also APPLY correctly today; the mapped-offset failure is split + // into the next test. + 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: the redo entry correctly survives a non-history edit (previous + // test), but its forward edits are never remapped through it, so redo + // re-inserts at the stale offset (actual today: ">> n!ote" — the "!" lands + // mid-word instead of at the end it was typed at). + test.failing( + 'a surviving redo entry applies at offsets mapped through the non-history edit', + () => { + 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, '>> '); // remote prefix while redo is pending + expect(d.getText()).toBe('>> note'); + expect(d.canRedo).toBe(true); + + d.redo(); + 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(); + } + }); +}); diff --git a/packages/diffs/test/monaco-legacy-tests/README.md b/packages/diffs/test/monaco-legacy-tests/README.md deleted file mode 100644 index c9bf62ab6..000000000 --- a/packages/diffs/test/monaco-legacy-tests/README.md +++ /dev/null @@ -1,79 +0,0 @@ -# monaco-legacy-tests - -Behavioral test scenarios harvested from the Monaco editor's core test suite and -re-expressed against `@pierre/diffs`' edit APIs. Scope: text editing, selection -& multi-cursor, and undo/redo. IME/composition scenarios are deferred. - -## Provenance & attribution - -Monaco's editor core lives in -[microsoft/vscode](https://github.com/microsoft/vscode) (the `monaco-editor` -repo is packaging only). Scenarios here were adapted from vscode's test suite at -commit `86f5a62f058e3905f74a9fa65d04b2f3b533408e` (the `vscodeRef` pinned by -monaco-editor at the time of the audit). vscode is MIT-licensed, © Microsoft -Corporation. - -These are behavioral rewrites, not code copies: each test re-expresses a -scenario (input text, operation, expected text/selections) in this package's own -test idiom. Every test carries a traceability comment: - -```ts -// monaco-legacy: src/vs/editor/test/common/model/textModel.test.ts — "" -``` - -## Conventions - -- vscode positions are 1-based (`line`/`column`); ours are 0-based LSP-style - (`line`/`character`). All coordinates here are already translated. -- `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 mark places where `@pierre/diffs` intentionally (or at - least knowingly) behaves differently from vscode. Those tests pin _our_ - behavior and document the difference; they are decisions, not bugs. - -## Known bugs encoded as `test.failing` (10) - -**EditStack coalescing** (`editStack.monaco.test.ts`, 3) — root cause: -`shouldCoalesceEditStackEntry()` in `src/editor/editStack.ts` compares a new -edit against whatever entry sits on top of the undo stack, purely by geometry, -with no state reset after `undo()`/`redo()`. - -1. After `undo()` pops an entry, new typing can coalesce with the newly exposed - (pre-undo) top entry, fusing old history into the new edit. -2. `undoBoundary` entries stop blocking merges once they are undone. -3. Backspace followed by forward-delete at the same pivot coalesces into one - undo step (vscode guarantees an undo stop when delete direction flips). - -**Surrogate-pair edit boundaries** (`applyEdits.monaco.test.ts`, 3) — edit range -endpoints landing strictly inside a surrogate pair split the pair and corrupt -the buffer; vscode auto-widens/snaps such ranges to pair boundaries. Affects -insert inside a pair and replaces starting or ending inside one (`TextDocument` -`#resolveEdit`/`normalizePosition` have no surrogate-aware clamping). - -**PieceTable CRLF line metadata** (`pieceTable.monaco.test.ts`, 4) — line-break -bookkeeping goes stale when a `\r\n` pair is split or assembled across edits: -deleting exactly the `\n` of a pair, inserting between the `\r` and `\n`, -assembling `\r\n` from two separate inserts, plus a CRLF-biased fuzz oracle that -catches the general class. Text content (`getText()`) stays correct; -`lineCount`/`positionAt` metadata does not. - -## Consciously out of scope (missing features, not missing tests) - -vscode has extensive tests for these; if the feature is ever built, start from -the referenced suites: - -- Forward word-delete family (`DeleteWordRight`, `DeleteWordStartRight`, - `DeleteWordEndRight`) — - `src/vs/editor/contrib/wordOperations/test/browser/wordOperations.test.ts` -- Vim-style `deleteInsideWord` — same file -- `JoinLines` (Ctrl+J) whitespace-collapsing semantics — - `src/vs/editor/contrib/linesOperations/test/browser/linesOperations.test.ts` -- Transpose crossing _forward_ over a line break at end-of-line - (`applyTransposeToSelections` has no forward-crossing branch) — - `linesOperations.test.ts` TransposeAction suite -- IME composition ↔ undo-coalescing interaction (deferred with the rest of IME) -- Vertical cursor movement preserving the _visual_ column across full-width CJK - lines (vscode issue #22717) — exercises `#lastAccessedCharX` in `editor.ts` - and `moveBySoftLine`, which need canvas text-measure stubbing; deferred, not - skipped on merit diff --git a/packages/diffs/test/monaco-legacy-tests/applyEdits.monaco.test.ts b/packages/diffs/test/monaco-legacy-tests/applyEdits.monaco.test.ts deleted file mode 100644 index 63eb8429b..000000000 --- a/packages/diffs/test/monaco-legacy-tests/applyEdits.monaco.test.ts +++ /dev/null @@ -1,237 +0,0 @@ -import { describe, expect, test } from 'bun:test'; - -import { DirectionNone } from '../../src/editor/selection'; -import { TextDocument } from '../../src/editor/textDocument'; -import type { EditorSelection, TextEdit } from '../../src/types'; - -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 select( - startLine: number, - startCharacter: number, - endLine: number, - endCharacter: number -): EditorSelection { - return { - start: { line: startLine, character: startCharacter }, - end: { line: endLine, character: endCharacter }, - direction: DirectionNone, - }; -} - -// 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; -} - -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 vscode's - // model auto-corrects so the pair is never split. - - // monaco-legacy: src/vs/editor/test/common/model/editableTextModel.test.ts — "high-low surrogates 1" - // 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'); - } - ); - - // monaco-legacy: src/vs/editor/test/common/model/editableTextModel.test.ts — "high-low surrogates 2" - // 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'); - } - ); - - // monaco-legacy: src/vs/editor/test/common/model/editableTextModel.test.ts — "high-low surrogates 3" - // 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'); - } - ); - - // monaco-legacy: src/vs/editor/test/common/model/editableTextModel.test.ts — "high-low surrogates 4" - 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', () => { - // monaco-legacy: src/vs/editor/test/common/model/editableTextModel.test.ts — "touching edits: two inserts at the same position" - 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', () => { - // monaco-legacy: src/vs/editor/test/common/model/editableTextModel.test.ts — "issue #48741: Broken undo stack with move lines up with multiple cursors" - 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); - }); - - // monaco-legacy: src/vs/editor/test/browser/controller/cursor.test.ts — "issue #93585: Undo multi cursor edit corrupts document" - 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', () => { - // monaco-legacy: src/vs/editor/test/common/model/editableTextModel.test.ts — "issue #47733: Undo mangles unicode characters" - 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); - }); - - // monaco-legacy: src/vs/editor/test/browser/controller/cursor.test.ts — "issue #47733: Undo mangles unicode characters" - 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); - }); -}); diff --git a/packages/diffs/test/monaco-legacy-tests/editStack.monaco.test.ts b/packages/diffs/test/monaco-legacy-tests/editStack.monaco.test.ts deleted file mode 100644 index d60d7117c..000000000 --- a/packages/diffs/test/monaco-legacy-tests/editStack.monaco.test.ts +++ /dev/null @@ -1,241 +0,0 @@ -// Undo/redo coalescing scenarios ported from Monaco/vscode. See README.md in -// this directory for suite conventions. The three test.failing entries here are -// the P0 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. -import { describe, expect, test } from 'bun:test'; - -import { DirectionNone } from '../../src/editor/selection'; -import { TextDocument } from '../../src/editor/textDocument'; -import type { EditorSelection } from '../../src/types'; - -function doc(text: string) { - return new TextDocument('inmemory://1', text, 'plain'); -} - -function caret(line: number, character: number) { - const position = { line, character }; - return { - start: position, - end: position, - direction: DirectionNone, - } satisfies EditorSelection; -} - -// Inserts `text` at the caret with history recording enabled, like a single -// keystroke (or a paste when `undoBoundary` is set). -function typeAt( - d: ReturnType, - line: number, - character: number, - text: string, - undoBoundary = false -) { - d.applyEdits( - [ - { - range: { - start: { line, character }, - end: { line, character }, - }, - newText: text, - }, - ], - true, - [caret(line, character)], - undefined, - 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, - [caret(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, - [caret(line, caretChar)] - ); -} - -describe('EditStack coalescing across undo/redo (monaco-legacy)', () => { - // monaco-legacy: src/vs/editor/common/cursor/cursor.ts — "onModelContentChanged resets _prevEditOperationType, forcing an undo stop after every undo/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); - } - ); - - // monaco-legacy: src/vs/editor/common/cursor/cursorTypeOperations.ts — "paste pushes an undo stop before and after; the stop is durable, not consumed by undo" - // 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); - } - ); - - // monaco-legacy: src/vs/editor/test/browser/controller/cursor.test.ts — "there is an undo stop between deleting left and deleting right" - // 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); - } - ); - - // monaco-legacy: src/vs/editor/test/browser/controller/cursor.test.ts — "there is no undo stop after a single whitespace" - // DIVERGENCE: vscode 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; vscode's 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); - }); - - // monaco-legacy: src/vs/editor/test/browser/controller/cursor.test.ts — "there is a single undo stop for consecutive whitespaces" - // DIVERGENCE: vscode 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); - }); -}); diff --git a/packages/diffs/test/monaco-legacy-tests/pieceTable.monaco.test.ts b/packages/diffs/test/monaco-legacy-tests/pieceTable.monaco.test.ts deleted file mode 100644 index 0c9073aad..000000000 --- a/packages/diffs/test/monaco-legacy-tests/pieceTable.monaco.test.ts +++ /dev/null @@ -1,203 +0,0 @@ -import { describe, expect, test } from 'bun:test'; - -import { PieceTable } from '../../src/editor/pieceTable'; -import type { Position } from '../../src/types'; - -// 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; -} - -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 }) - ); - } - } -} - -// 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; - }; -} - -describe('PieceTable CRLF and lone-CR line breaks (monaco-legacy)', () => { - // monaco-legacy: src/vs/editor/test/common/model/model.test.ts — "Bug 13333:Model should line break on lonely CR too" - test('lone \\r mixed with \\r\\n breaks reads back byte-for-byte untouched', () => { - // DIVERGENCE: vscode's getValue() rewrites a lone \r to the document's - // dominant EOL on read (the model normalizes line endings). 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); - }); - - // monaco-legacy: src/vs/editor/test/common/model/model.test.ts — "Bug 13333:Model should line break on lonely CR too" - test('lone \\r mixed with \\r\\n breaks still counts as its own line break', () => { - // The half of Bug 13333 pierre-fe shares with vscode: 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); - }); - - // monaco-legacy: src/vs/editor/test/common/model/pieceTreeTextBuffer/pieceTreeTextBuffer.test.ts — "delete CR in CRLF 1" - 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); - }); - - // monaco-legacy: src/vs/editor/test/common/model/pieceTreeTextBuffer/pieceTreeTextBuffer.test.ts — "delete CR in CRLF 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); - } - ); - - // monaco-legacy: src/vs/editor/test/common/model/pieceTreeTextBuffer/pieceTreeTextBuffer.test.ts — CRLF suite "random bug 1"-"random bug 10" (minimal directed repro) - // 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); - } - ); - - // monaco-legacy: src/vs/editor/test/common/model/pieceTreeTextBuffer/pieceTreeTextBuffer.test.ts — CRLF suite "random bug 1"-"random bug 10" (minimal directed repro) - // 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); - } - ); - - // monaco-legacy: src/vs/editor/test/common/model/pieceTreeTextBuffer/pieceTreeTextBuffer.test.ts — CRLF suite "random bug 1"-"random bug 10" - // 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); - } - } - ); -}); diff --git a/packages/diffs/test/monaco-legacy-tests/selection.monaco.test.ts b/packages/diffs/test/monaco-legacy-tests/selection.monaco.test.ts deleted file mode 100644 index 35b1fb77e..000000000 --- a/packages/diffs/test/monaco-legacy-tests/selection.monaco.test.ts +++ /dev/null @@ -1,249 +0,0 @@ -import { describe, expect, test } from 'bun:test'; - -import { - applyDeleteCharacterToSelections, - applyTextChangeToSelections, - applyTextReplaceToSelections, - DirectionForward, - DirectionNone, - findNexMatch, - getAutoSurroundReplacementTexts, - mergeOverlappingSelections, - resolveDeleteCharacterRange, -} from '../../src/editor/selection'; -import { TextDocument } from '../../src/editor/textDocument'; -import type { EditorSelection, SelectionDirection } from '../../src/types'; - -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 }; -} - -function sel( - startLine: number, - startCharacter: number, - endLine: number, - endCharacter: number, - direction: SelectionDirection = DirectionForward -): EditorSelection { - return { - start: { line: startLine, character: startCharacter }, - end: { line: endLine, 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 (monaco-legacy)', () => { - // monaco-legacy: src/vs/editor/test/browser/controller/cursor.test.ts — "issue #99629: Emoji modifiers in text treated separately when using backspace (ZWJ sequence)" - test('backspace removes a whole ZWJ family emoji without splitting the cluster', () => { - // DIVERGENCE: vscode 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 the vscode 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)]); - }); - - // monaco-legacy: src/vs/editor/test/browser/controller/cursor.test.ts — "issue #99629: Emoji modifiers in text treated separately when using backspace" - 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)]); - }); - - // monaco-legacy: src/vs/editor/test/browser/controller/cursor.test.ts — "issue #84897: Left delete behavior in some languages is changed" / "issue #122914: Left delete behavior in some languages is changed (useTabStops: false)" - test('backspace steps over Thai combining marks one grapheme cluster at a time', () => { - // DIVERGENCE: vscode deliberately deletes one UTF-16 code unit per - // Backspace in combining-mark scripts (issues #84897/#122914 were filed by - // Thai users who expect to erase a tone/vowel mark without losing the base - // consonant; vscode's 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 (monaco-legacy)', () => { - // monaco-legacy: src/vs/editor/contrib/multicursor/test/browser/multicursor.test.ts — "issue #6661: AddSelectionToNextFindMatchAction can work with touching ranges" - test('finds a repeat that touches the current selection with zero gap', () => { - const d = doc('abcabc'); - const first = sel(0, 0, 0, 3); - - // 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, sel(0, 3, 0, 6)]); - - // Both occurrences are now held; nothing is left to add. - expect(findNexMatch(d, next!)).toBeUndefined(); - }); - - // monaco-legacy: src/vs/editor/contrib/multicursor/test/browser/multicursor.test.ts — "issue #6661: AddSelectionToNextFindMatchAction can work with touching ranges" - test('repeated next-occurrence walks through touching matches across lines', () => { - const d = doc('rowrow\nrow\nrowrow'); - let selections: EditorSelection[] | undefined = [sel(0, 0, 0, 3)]; - - const expected = [ - sel(0, 3, 0, 6), // touching repeat on the same line - sel(1, 0, 1, 3), - sel(2, 0, 2, 3), - sel(2, 3, 2, 6), // 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 (monaco-legacy)', () => { - // monaco-legacy: src/vs/editor/test/browser/controller/cursor.test.ts — "issue #37967: problem replacing consecutive characters" - test('carets that converge via backspace merge and type the next character once', () => { - // vscode's regression test runs with multiCursorMergeOverlapping:false to - // reproduce the double-insert users saw; with its default (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 (monaco-legacy)', () => { - // monaco-legacy: src/vs/editor/test/browser/controller/cursor.test.ts — "issue #47733: Undo mangles unicode characters" - 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 = [sel(0, 0, 0, 1)]; - - 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([sel(0, 1, 0, 2)]); - - // 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('"🦉"'); - }); - - // monaco-legacy: src/vs/editor/test/browser/controller/cursor.test.ts — "issue #47733: Undo mangles unicode characters" - 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 = [sel(0, 3, 0, 4)]; - - const texts = getAutoSurroundReplacementTexts(d, selections, '('); - expect(texts).toEqual(['(")']); - - const { nextSelections } = applyTextReplaceToSelections( - d, - selections, - texts! - ); - expect(d.getText()).toBe('"🦉(")'); - expect(nextSelections).toEqual([sel(0, 4, 0, 5)]); - - d.undo(); - expect(d.getText()).toBe('"🦉"'); - }); -}); diff --git a/packages/diffs/test/monaco-legacy-tests/textDocument.monaco.test.ts b/packages/diffs/test/monaco-legacy-tests/textDocument.monaco.test.ts deleted file mode 100644 index 666ad5267..000000000 --- a/packages/diffs/test/monaco-legacy-tests/textDocument.monaco.test.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { describe, expect, test } from 'bun:test'; - -import { TextDocument } from '../../src/editor/textDocument'; -import type { TextEdit } from '../../src/types'; - -function doc(text: string) { - return new TextDocument('inmemory://1', text, 'plain'); -} - -// 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 (monaco-legacy)', () => { - // monaco-legacy: src/vs/editor/test/common/model/textModel.test.ts — "multiline text" - test('eol detection uses the first line break, not a whole-file majority vote', () => { - // DIVERGENCE: vscode's TextModelData.fromString 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); - // vscode (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'); - // vscode (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'); - }); - - // monaco-legacy: src/vs/editor/test/common/model/editableTextModel.test.ts — "issue #2586 Replacing selected end-of-line with newline locks up the document" - 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, per the original vscode - // regression, the model did not lock up computing the change). - 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); - }); - - // monaco-legacy: src/vs/editor/test/common/model/editableTextModel.test.ts — "issue #2586 Replacing selected end-of-line with newline locks up the document" - 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); - }); - - // monaco-legacy: src/vs/editor/test/common/model/editableTextModel.test.ts — "issue #2586 Replacing selected end-of-line with newline locks up the document" - 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'); - }); - - // monaco-legacy: src/vs/editor/test/common/model/textModel.test.ts — "validatePosition" - test('normalizePosition clamps both coordinates when line and character overshoot together', () => { - // vscode: validatePosition(new Position(30, 30)) on a 2-line model lands - // at the end of the last line — the character must be 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, - }); - }); - - // monaco-legacy: src/vs/editor/test/common/model/textModel.test.ts — "validatePosition" - 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, - }); - }); - - // monaco-legacy: src/vs/editor/test/common/model/textModel.test.ts — "validatePosition" - 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, - }); - }); -}); diff --git a/packages/diffs/test/monaco-legacy-tests/wordOperations.monaco.test.ts b/packages/diffs/test/monaco-legacy-tests/wordOperations.monaco.test.ts deleted file mode 100644 index 7115c675e..000000000 --- a/packages/diffs/test/monaco-legacy-tests/wordOperations.monaco.test.ts +++ /dev/null @@ -1,356 +0,0 @@ -import { describe, expect, test } from 'bun:test'; - -import { - applyDeleteWordBackwardToSelections, - DirectionForward, - DirectionNone, - expandCollapsedSelectionToWord, -} from '../../src/editor/selection'; -import { TextDocument } from '../../src/editor/textDocument'; -import type { EditorSelection } from '../../src/types'; - -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, - } satisfies EditorSelection; -} - -// 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', () => { - // monaco-legacy: src/vs/editor/contrib/wordOperations/test/browser/wordOperations.test.ts — "cursorWordLeft - Does not recognize words" - // DIVERGENCE: vscode segments CJK per-word only when wordSegmenterLocales - // is 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', - () => { - // monaco-legacy: src/vs/editor/contrib/wordOperations/test/browser/wordOperations.test.ts — "cursorWordLeft - Recognize words" - // DIVERGENCE: vscode's default (no wordSegmenterLocales) 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', () => { - // monaco-legacy: src/vs/editor/contrib/wordOperations/test/browser/wordOperations.test.ts — "cursorWordLeft - Does not recognize words" - // DIVERGENCE: Hiragana and Han are both \p{Alphabetic}, so the classifier - // sees one uninterrupted word run across the whole sentence. vscode 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', - () => { - // monaco-legacy: src/vs/editor/contrib/wordOperations/test/browser/wordOperations.test.ts — "cursorWordLeft - Recognize words" - // 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', () => { - // monaco-legacy: src/vs/editor/contrib/wordOperations/test/browser/wordOperations.test.ts — "cursorWordLeft - Does not recognize words" - // 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', - () => { - // monaco-legacy: src/vs/editor/contrib/wordOperations/test/browser/wordOperations.test.ts — "cursorWordLeft - Recognize words" - // 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', () => { - // monaco-legacy: src/vs/editor/contrib/wordOperations/test/browser/wordOperations.test.ts — "cursorWordLeft - Does not recognize words" - // 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\u00EFve東京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', - () => { - // monaco-legacy: src/vs/editor/contrib/wordOperations/test/browser/wordOperations.test.ts — "cursorWordLeft - Recognize words" - // DIVERGENCE: the same string that deleteWordBackward erases whole yields - // three distinct double-click words (Latin, Han, digits). - const d = doc('na\u00EFve東京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', () => { - // monaco-legacy: src/vs/editor/contrib/wordOperations/test/browser/wordOperations.test.ts — "deleteWordLeft for cursor at end of whitespace" (emoji-at-boundary convention) - 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', () => { - // monaco-legacy: src/vs/editor/contrib/wordOperations/test/browser/wordOperations.test.ts — "deleteWordLeft for cursor at end of whitespace" (emoji-at-boundary convention) - 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', () => { - // monaco-legacy: src/vs/editor/test/browser/controller/cursor.test.ts — "issue #99629: Emoji modifiers in text treated separately when using backspace (ZWJ sequence)" - 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', () => { - // monaco-legacy: src/vs/editor/test/browser/controller/cursor.test.ts — "issue #99629: Emoji modifiers in text treated separately when using backspace (ZWJ sequence)" - 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', () => { - // monaco-legacy: src/vs/editor/test/browser/controller/cursor.test.ts — "issue #99629: Emoji modifiers in text treated separately when using backspace (ZWJ sequence)" - 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', () => { - // monaco-legacy: src/vs/editor/test/browser/controller/cursor.test.ts — "issue #99629: Emoji modifiers in text treated separately when using backspace" - 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', () => { - // monaco-legacy: src/vs/editor/test/browser/controller/cursor.test.ts — "issue #84897: Left delete behavior in some languages is changed" - // "mañana" in decomposed form: the ñ is n + U+0303 combining tilde. - const d = doc('man\u0303ana'); - 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', () => { - // monaco-legacy: src/vs/editor/test/browser/controller/cursor.test.ts — "issue #122914: Left delete behavior in some languages is changed (useTabStops: false)" - // "go piña" in decomposed form; the final cluster is n + U+0303 + a. - const d = doc('go pin\u0303a'); - 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', () => { - // monaco-legacy: src/vs/editor/contrib/linesOperations/test/browser/linesOperations.test.ts — "should keep deleting lines in multi cursor mode" - // 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', () => { - // monaco-legacy: src/vs/editor/contrib/linesOperations/test/browser/linesOperations.test.ts — "should work in multi cursor mode" - // 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', () => { - // monaco-legacy: src/vs/editor/contrib/wordOperations/test/browser/wordOperations.test.ts — "deleteWordLeft - issue #3882 (2): Ctrl+Delete removing entire line when used at the end of line" - // 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', () => { - // monaco-legacy: src/vs/editor/contrib/wordOperations/test/browser/wordOperations.test.ts — "deleteWordLeft - issue #3882 (2): Ctrl+Delete removing entire line when used at the end of line" - 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', () => { - // monaco-legacy: src/vs/editor/contrib/wordOperations/test/browser/wordOperations.test.ts — "deleteWordLeft - issue #3882 (2): Ctrl+Delete removing entire line when used at the end of line" - 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)]); - }); -}); From 094eeb6647c004efc45d4abac2d295291cd0a3ac Mon Sep 17 00:00:00 2001 From: fat Date: Tue, 14 Jul 2026 12:06:25 -0700 Subject: [PATCH 6/9] [diffs/edit] Pin block-indent blank-line bug as test.failing resolveIndentEdits inserts the indent unit at column 0 of every line a multi-line selection touches, including empty and whitespace-only lines, injecting trailing whitespace on lines the user never typed on. Outdent round-trips it away, bounding the damage to the indented state. Brings the pinned known-bug count to 31. Co-Authored-By: Claude Fable 5 --- packages/diffs/test/README.md | 6 ++++- packages/diffs/test/editorApplyEdits.test.ts | 27 ++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/packages/diffs/test/README.md b/packages/diffs/test/README.md index b27166c65..7d6ff4f3f 100644 --- a/packages/diffs/test/README.md +++ b/packages/diffs/test/README.md @@ -103,7 +103,7 @@ order. If you touch one of these areas, consider adding the missing coverage: - **IME composition deferrals** — IME/composition interaction with editing and undo-coalescing is deferred and not covered. -## Known bugs pinned as `test.failing` (30) +## Known bugs pinned as `test.failing` (31) - **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 @@ -129,6 +129,10 @@ order. If you touch one of these areas, consider adding the missing coverage: 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 across non-history edits** (6) — history entries are frozen at creation and never remapped through edits applied without history tracking: stale-offset undo can corrupt text, undo of a wiped entry is not a graceful diff --git a/packages/diffs/test/editorApplyEdits.test.ts b/packages/diffs/test/editorApplyEdits.test.ts index 6c65457e9..39fd6b056 100644 --- a/packages/diffs/test/editorApplyEdits.test.ts +++ b/packages/diffs/test/editorApplyEdits.test.ts @@ -2268,6 +2268,33 @@ describe('indentLess on tab and mixed indentation', () => { }); }); +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 From da4527cd2a903ceb9431c477f2af60dc165cc6b4 Mon Sep 17 00:00:00 2001 From: fat Date: Tue, 14 Jul 2026 14:58:42 -0700 Subject: [PATCH 7/9] [diffs/edit] Drop the wiped-region graceful-undo pin We don't model programmatic edits as a class distinct from local edits, so the scenario's premise (a dead history entry after an untracked edit replaced its region) doesn't match the intended design. The remaining five history-across-untracked-edits pins stand; pinned known-bug count returns to 30. Co-Authored-By: Claude Fable 5 --- packages/diffs/test/README.md | 13 ++++++----- packages/diffs/test/editorEditStack.test.ts | 24 --------------------- 2 files changed, 6 insertions(+), 31 deletions(-) diff --git a/packages/diffs/test/README.md b/packages/diffs/test/README.md index 7d6ff4f3f..a634206b9 100644 --- a/packages/diffs/test/README.md +++ b/packages/diffs/test/README.md @@ -103,7 +103,7 @@ order. If you touch one of these areas, consider adding the missing coverage: - **IME composition deferrals** — IME/composition interaction with editing and undo-coalescing is deferred and not covered. -## Known bugs pinned as `test.failing` (31) +## Known bugs pinned as `test.failing` (30) - **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 @@ -133,13 +133,12 @@ order. If you touch one of these areas, consider adding the missing coverage: 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 across non-history edits** (6) — history entries are frozen at +- **History across non-history edits** (5) — history entries are frozen at creation and never remapped through edits applied without history tracking: - stale-offset undo can corrupt text, undo of a wiped entry is not a graceful - no-op, batch inversion breaks across an interleaved non-tracked insert, - interior non-tracked inserts don't survive undo of a tracked insertion, stored - entry selections are restored without remapping, and redo replays at stale - offsets. + stale-offset undo can corrupt text, batch inversion breaks across an + interleaved non-tracked insert, interior non-tracked inserts don't survive + undo of a tracked insertion, stored entry selections are restored without + remapping, and redo 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 diff --git a/packages/diffs/test/editorEditStack.test.ts b/packages/diffs/test/editorEditStack.test.ts index 89a6ed9c8..89cb84718 100644 --- a/packages/diffs/test/editorEditStack.test.ts +++ b/packages/diffs/test/editorEditStack.test.ts @@ -876,30 +876,6 @@ describe('undo/redo across non-history edits', () => { } ); - // KNOWN BUG: when a non-history edit has replaced the entire region a - // history entry covers, the entry is dead — undo must be a graceful no-op - // (and must not leave behind a redo item that re-corrupts). Today undo - // applies the stale inverse range to the replacement text ("core" -> "ce") - // and redo then splices the old typed text back in ("cGHe"). - test.failing( - 'undo is a graceful no-op when a non-history edit wiped the tracked region', - () => { - const d = doc('ok'); - localEdit(d, 1, 1, 'GH'); // tracked: "oGHk" - remoteEdit(d, 0, 4, 'core'); // remote replaces the whole doc - expect(d.getText()).toBe('core'); - - d.undo(); - expect(d.getText()).toBe('core'); - - // Whether the dead entry is dropped or kept, redo must not corrupt. - if (d.canRedo) { - d.redo(); - } - expect(d.getText()).toBe('core'); - } - ); - // KNOWN BUG: a batch entry stores per-sub-edit inverse offsets chained // through the batch's own deltas, but none of them are remapped through the // later non-history insert that landed between the sub-edits. Undo then From 8aa25b8fe2f803b29652d3f3a1e198194df1137d Mon Sep 17 00:00:00 2001 From: fat Date: Tue, 14 Jul 2026 15:29:50 -0700 Subject: [PATCH 8/9] [diffs/edit] Reframe history-mixing pins around programmatic==local equivalence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design decision: edits applied with updateHistory=false are not a semantic class distinct from local edits — a mixed sequence must behave exactly like the same sequence applied all-tracked. The old pins asserted rebasing semantics (untracked edits surviving undo); all are flipped to equivalence assertions whose expected values mirror an all-tracked reference run: undo-to-exhaustion restores the original text, redo-to-exhaustion the final text. The wiped-region test returns under the new model, and the coalescing-across-an-untracked-edit pin flipped from passing to failing because its outcome also deviates from the all-tracked timeline. Cluster: 7 failing pins; suite total 32. Co-Authored-By: Claude Fable 5 --- packages/diffs/test/README.md | 24 +- packages/diffs/test/editorEditStack.test.ts | 284 +++++++++++++------- 2 files changed, 211 insertions(+), 97 deletions(-) diff --git a/packages/diffs/test/README.md b/packages/diffs/test/README.md index a634206b9..9c7a206d4 100644 --- a/packages/diffs/test/README.md +++ b/packages/diffs/test/README.md @@ -103,7 +103,7 @@ order. If you touch one of these areas, consider adding the missing coverage: - **IME composition deferrals** — IME/composition interaction with editing and undo-coalescing is deferred and not covered. -## Known bugs pinned as `test.failing` (30) +## 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 @@ -133,12 +133,22 @@ order. If you touch one of these areas, consider adding the missing coverage: 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 across non-history edits** (5) — history entries are frozen at - creation and never remapped through edits applied without history tracking: - stale-offset undo can corrupt text, batch inversion breaks across an - interleaved non-tracked insert, interior non-tracked inserts don't survive - undo of a tracked insertion, stored entry selections are restored without - remapping, and redo replays at stale offsets. +- **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 diff --git a/packages/diffs/test/editorEditStack.test.ts b/packages/diffs/test/editorEditStack.test.ts index 89cb84718..08b583f40 100644 --- a/packages/diffs/test/editorEditStack.test.ts +++ b/packages/diffs/test/editorEditStack.test.ts @@ -831,21 +831,39 @@ function remoteEdit( // Undo/redo interacting with non-history edits (updateHistory=false). // -// Root cause shared by every test.failing below: EditStack entries are frozen -// at creation. When an edit is applied with updateHistory=false (the default -// for Editor.applyEdits — collaborative patches, programmatic fixes, etc.), -// nothing remaps the offsets stored in existing undo/redo entries, so a later -// undo()/redo() applies its inverse/forward edits at stale offsets and -// corrupts the buffer. The conventional approach rebases every stored history -// item (and its selections) through each non-history transaction. The failing -// tests pin distinct surfaces of that one missing mechanism: single-entry -// undo, the dead-entry no-op, batch inversion round trips, interior-insert -// splitting, stored-selection remapping, and the redo direction. +// 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', () => { - // Baseline sanity: a non-history edit strictly AFTER the tracked range does - // not shift its stored offsets, so undo restores exactly the tracked change - // even today. The failing tests below differ only in putting the remote edit - // at/before the tracked offsets. + // 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" @@ -858,31 +876,41 @@ describe('undo/redo across non-history edits', () => { expect(d.getText()).toBe('sour lemon tart'); }); - // KNOWN BUG: undo applies the stored inverse edit at its original offsets - // without remapping through the two non-history inserts, so it deletes the - // remote prefix plus part of the remote-shifted typed text instead of the - // typed text itself (actual today: "ilot?"). + // 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( - 'undo reverts only the tracked change, leaving non-history text intact', + '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'); // remote prefix: "syncpilot" - remoteEdit(d, 9, 9, '?'); // remote suffix: "syncpilot?" + remoteEdit(d, 0, 0, 'sync'); // untracked prefix: "syncpilot" + remoteEdit(d, 9, 9, '?'); // untracked suffix: "syncpilot?" expect(d.getText()).toBe('syncpilot?'); - d.undo(); - expect(d.getText()).toBe('sync?'); + // 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: a batch entry stores per-sub-edit inverse offsets chained - // through the batch's own deltas, but none of them are remapped through the - // later non-history insert that landed between the sub-edits. Undo then - // treats the remote text as if it were the tracked replacements (actual - // today: undo -> "pqrWXYZ", redo -> "UVWXYZWXYZ"). + // 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 round-trips through undo/redo across a non-history insert', + '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. @@ -893,93 +921,156 @@ describe('undo/redo across non-history edits', () => { ); expect(d.getText()).toBe('UVWXYZ'); - // Remote insert exactly between the first and second replacements. + // Untracked insert exactly between the first and second replacements. remoteEdit(d, 2, 2, '####'); expect(d.getText()).toBe('UV####WXYZ'); - // Undo reverts the three replacements around the remote text. - d.undo(); - expect(d.getText()).toBe('p####qr'); + // 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 restores the exact pre-undo document. - d.redo(); + // Redo-to-exhaustion restores the final text. + redoAll(d); expect(d.getText()).toBe('UV####WXYZ'); } ); - // KNOWN BUG: undoing a tracked insertion must split its deletion around - // non-history text that was inserted INSIDE the inserted range, deleting - // only the tracked characters. Today the whole stale range [0,4) is deleted - // from "WXjYZ", which erases the remote "j" and strands a tracked "Z" - // (actual today: "Z"). + // 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( - 'non-history text inserted inside a tracked insertion survives undo', + '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'); // remote insert in the middle of it + remoteEdit(d, 2, 2, 'j'); // untracked insert in the middle of it expect(d.getText()).toBe('WXjYZ'); - d.undo(); - expect(d.getText()).toBe('j'); + 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: the selections stored in a history entry must be remapped - // through non-history edits before being restored. Geometry here is chosen - // so the BUFFER restore is exact (the tracked delete sits at offset 0, - // unshifted by the later remote insert) and only the selection contract is - // under test: the caret that sat past the remote insert's position must - // shift by its length, the one before it must not. Today the entry's - // selectionsBefore come back verbatim (6 and 11 instead of 6 and 13). + // 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( - 'history-entry selections are remapped through later non-history edits before restore', + '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'); - // Remote insert; in the original coordinates this lands at offset 9, + // 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'); - const result = d.undo(); - expect(d.getText()).toBe('hello worXYld'); - const selections = result?.[1]; - expect(selections?.map((s) => s.start.character)).toEqual([6, 13]); + 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'); } ); - // Two keystrokes separated by an interleaved non-history insert still - // coalesce into one undo group, and the group inverts around the remote - // character: one undo leaves exactly the remote text. Note this currently - // works because the coalescing adjacency check (next insert starts at the - // previous entry's inverse end) happens to line up in this geometry, not - // because entries are remapped through remote edits. - test('adjacent typing coalesces into one undo group across a non-history edit', () => { - const d = doc(''); - localEdit(d, 0, 0, 'a'); // tracked keystroke: "a" - remoteEdit(d, 1, 1, 'b'); // remote: "ab" - localEdit(d, 1, 1, 'c'); // tracked keystroke right after the "a": "acb" - expect(d.getText()).toBe('acb'); + // 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'); - // Both keystrokes revert in a single undo step, keeping the remote "b". - d.undo(); - expect(d.getText()).toBe('b'); - expect(d.canUndo).toBe(false); + // Undo-to-exhaustion restores the original byte-exact text... + undoAll(d); + expect(d.getText()).toBe(''); - // Redo direction: the coalesced group replays around the remote text. - d.redo(); - expect(d.getText()).toBe('acb'); - }); + // ...and redo-to-exhaustion restores the final text. + redoAll(d); + expect(d.getText()).toBe('acb'); + } + ); - // Non-history edits must neither consume undo entries nor clear the redo - // stack (clearRedo only fires when a new history entry is pushed). Geometry - // keeps every remote edit after the tracked offsets so the surviving - // entries also APPLY correctly today; the mapped-offset failure is split - // into the next test. + // 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!" @@ -1005,12 +1096,17 @@ describe('undo/redo across non-history edits', () => { expect(d.canRedo).toBe(false); }); - // KNOWN BUG: the redo entry correctly survives a non-history edit (previous - // test), but its forward edits are never remapped through it, so redo - // re-inserts at the stale offset (actual today: ">> n!ote" — the "!" lands - // mid-word instead of at the end it was typed at). + // 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( - 'a surviving redo entry applies at offsets mapped through the non-history edit', + '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!" @@ -1018,12 +1114,20 @@ describe('undo/redo across non-history edits', () => { expect(d.getText()).toBe('note'); expect(d.canRedo).toBe(true); - remoteEdit(d, 0, 0, '>> '); // remote prefix while redo is pending + remoteEdit(d, 0, 0, '>> '); // untracked prefix while redo is pending expect(d.getText()).toBe('>> note'); - expect(d.canRedo).toBe(true); + // 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!'); + 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'); } ); }); From e950d118bc2c6dac8a37bf6963fdb82f04cf4806 Mon Sep 17 00:00:00 2001 From: fat Date: Tue, 14 Jul 2026 15:31:22 -0700 Subject: [PATCH 9/9] sick --- packages/diffs/test/README.md | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/packages/diffs/test/README.md b/packages/diffs/test/README.md index 9c7a206d4..1d715b1bb 100644 --- a/packages/diffs/test/README.md +++ b/packages/diffs/test/README.md @@ -136,19 +136,18 @@ order. If you touch one of these areas, consider adding the missing coverage: - **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. + 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