diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..0368cd56 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,3 @@ +# AGENTS.md + +This repo is not expected, for now, to be imported as a dependency; treat exported internals as pi-vim-local unless documented otherwise. diff --git a/README.md b/README.md index 06d06866..cb8a741f 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,8 @@ Clipboard write mirroring is controlled by `piVim.clipboardMirror`: The setting controls which local register writes cross the OS clipboard boundary. `p` / `P` keep non-mirrored writes local. +Mode colors: `piVim.modeColors` accepts pi theme tokens (`insert`: `borderMuted`, `normal`: `borderAccent`, `ex`: `warning`); missing keys and unknown tokens use defaults. `piVim.syncBorderColorWithMode` defaults `false`; `true` syncs border to mode, overriding Pi's normal thinking-level border signal. + ## wrapping pi-vim Supported: `pi-vim` first, `@jordyvd/pi-image-attachments` second. pi-vim does not wrap previous editors; wrappers decorate in place or forward the CustomEditor surface: lifecycle (`handleInput`, `render`, `invalidate`), text (`getText`, `setText`, `insertTextAtCursor`, `getExpandedText`), callbacks, `actionHandlers`, flags, reads (`getLines`, `getCursor`, `getMode()`). Inverse order, insert delegates, and generic composition are unsupported. @@ -72,7 +74,7 @@ u # undo 2} # jump two paragraphs forward ``` -Mode indicator (`INSERT` / `NORMAL` / `EX`) appears bottom-right, theme-colored. +Mode indicator (`INSERT` / `NORMAL` / `EX`) appears bottom-right, theme-colored and configurable. Requires `@mariozechner/pi-tui >= 0.47.0`. With `pi-tui >= 0.49.3` and DECSCUSR support, cursor shape follows mode; otherwise software cursor remains. diff --git a/biome.json b/biome.json index efd5bea4..5b1d37f7 100644 --- a/biome.json +++ b/biome.json @@ -1,14 +1,7 @@ { "$schema": "https://biomejs.dev/schemas/2.4.8/schema.json", "files": { - "includes": [ - "**", - "!node_modules", - "!.pi", - "!.tree", - "!.tmp", - "!doc" - ], + "includes": ["**", "!node_modules", "!.pi", "!.tree", "!.tmp", "!doc"], "ignoreUnknown": true }, "formatter": { diff --git a/clipboard-policy.ts b/clipboard-policy.ts index 6c7e2da1..96a99e5f 100644 --- a/clipboard-policy.ts +++ b/clipboard-policy.ts @@ -1,73 +1,23 @@ -import { SettingsManager } from "@mariozechner/pi-coding-agent"; - export type ClipboardMirrorPolicy = "all" | "yank" | "never"; export type RegisterWriteSource = "mutation" | "yank"; - export const DEFAULT_CLIPBOARD_MIRROR_POLICY: ClipboardMirrorPolicy = "all"; -export type PiVimSettings = { clipboardMirror?: unknown }; - -type UnknownRecord = Record; - -const missing = Symbol(); - -function formatInvalid(value: unknown) { - const type = value === null ? "null" : Array.isArray(value) ? "array" : typeof value; +function fmt(v: unknown) { + const type = v === null ? "null" : Array.isArray(v) ? "array" : typeof v; try { - return `${JSON.stringify(value) ?? type} (type ${type})`; + return `${JSON.stringify(v) ?? type} (type ${type})`; } catch { return `(type ${type})`; } } -function readSetting(settings: unknown): unknown { - if (typeof settings !== "object" || settings === null || !Object.hasOwn(settings, "piVim")) return missing; - const piVim = (settings as UnknownRecord).piVim; - if (typeof piVim !== "object" || piVim === null || Array.isArray(piVim)) return piVim; - return Object.hasOwn(piVim, "clipboardMirror") ? (piVim as UnknownRecord).clipboardMirror : missing; -} - export function resolveClipboardMirrorPolicy(value: unknown) { if (value === undefined) return { policy: DEFAULT_CLIPBOARD_MIRROR_POLICY }; - - if (typeof value === "string") { - const policy = value.trim().toLowerCase(); - if (policy === "all" || policy === "yank" || policy === "never") { - return { policy: policy as ClipboardMirrorPolicy }; - } - } - + const p = typeof value === "string" ? value.trim().toLowerCase() : ""; + if (p === "all" || p === "yank" || p === "never") + return { policy: p as ClipboardMirrorPolicy }; return { policy: DEFAULT_CLIPBOARD_MIRROR_POLICY, - warning: `Invalid piVim.clipboardMirror ${formatInvalid(value)}; expected all, yank, never. Using all.`, - }; -} - -export function readPiVimClipboardMirrorSetting(globalSettings: unknown, projectSettings: unknown): unknown | undefined { - const project = readSetting(projectSettings); - if (project !== missing) return project; - const global = readSetting(globalSettings); - return global === missing ? undefined : global; -} - -function readPiVimSettingsFromDisk(cwd: string): PiVimSettings { - const settings = SettingsManager.create(cwd); - return { - clipboardMirror: readPiVimClipboardMirrorSetting(settings.getGlobalSettings(), settings.getProjectSettings()), - }; -} - -let piVimSettingsReader = readPiVimSettingsFromDisk; - -export function readPiVimSettings(cwd: string) { - return piVimSettingsReader(cwd); -} - -export function setPiVimSettingsReaderForTests(reader: typeof readPiVimSettingsFromDisk) { - const prev = piVimSettingsReader; - piVimSettingsReader = reader; - - return () => { - piVimSettingsReader = prev; + warning: `Invalid piVim.clipboardMirror ${fmt(value)}; expected all, yank, never.`, }; } diff --git a/index.ts b/index.ts index 2f6deb45..358a3773 100644 --- a/index.ts +++ b/index.ts @@ -1,9 +1,6 @@ import { spawn, spawnSync } from "node:child_process"; -import { - CustomEditor, - type ExtensionAPI, -} from "@mariozechner/pi-coding-agent"; +import { CustomEditor, type ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { CURSOR_MARKER, Key, @@ -11,55 +8,54 @@ import { truncateToWidth, visibleWidth, } from "@mariozechner/pi-tui"; - +import { + type ClipboardMirrorPolicy, + DEFAULT_CLIPBOARD_MIRROR_POLICY, + type RegisterWriteSource, + resolveClipboardMirrorPolicy, +} from "./clipboard-policy.js"; +import { + findCharMotionTarget, + findFirstNonWhitespaceColumn, + findParagraphMotionTarget, + getLineGraphemes, + reverseCharMotion, + type WordMotionClass, +} from "./motions.js"; +import { type ModeColorSettings, readPiVimSettings } from "./settings.js"; +import { + resolveDelimitedTextObjectRange, + resolveWordTextObjectRange, + type TextObjectKind, + type TextObjectRange, + type WordTextObjectClass, +} from "./text-objects.js"; import type { - Mode, CharMotion, + LastCharMotion, + Mode, PendingMotion, PendingOperator, - LastCharMotion, } from "./types.js"; import { - NORMAL_KEYS, CHAR_MOTION_KEYS, - ESC_LEFT, - ESC_RIGHT, - ESC_UP, CTRL_A, CTRL_E, CTRL_K, CTRL_R, CTRL_UNDERSCORE, - NEWLINE, ESC_DOWN, + ESC_LEFT, + ESC_RIGHT, + ESC_UP, + NEWLINE, + NORMAL_KEYS, } from "./types.js"; -import { - reverseCharMotion, - findCharMotionTarget, - findParagraphMotionTarget, - findFirstNonWhitespaceColumn, - getLineGraphemes, - type WordMotionClass, -} from "./motions.js"; import { WordBoundaryCache, type WordMotionDirection, type WordMotionTarget, } from "./word-boundary-cache.js"; -import { - DEFAULT_CLIPBOARD_MIRROR_POLICY, - readPiVimSettings, - resolveClipboardMirrorPolicy, - type ClipboardMirrorPolicy, - type RegisterWriteSource, -} from "./clipboard-policy.js"; -import { - resolveDelimitedTextObjectRange, - resolveWordTextObjectRange, - type TextObjectKind, - type TextObjectRange, - type WordTextObjectClass, -} from "./text-objects.js"; const BRACKETED_PASTE_START = "\x1b[200~"; const BRACKETED_PASTE_END = "\x1b[201~"; @@ -71,12 +67,16 @@ const SOFTWARE_CURSOR_RESETS = ["\x1b[0m", "\x1b[27m"] as const; const INSERT_CURSOR_SHAPE = "\x1b[5 q"; const BLOCK_CURSOR_SHAPE = "\x1b[1 q"; const RESET_CURSOR_SHAPE = "\x1b[0 q"; -// Pi emits OSC52 before its native clipboard fallback. Give that 5s fallback -// a small grace so the parent does not kill the helper and discard stdout. const CLIPBOARD_WRITE_TIMEOUT_MS = PI_NATIVE_CLIPBOARD_TIMEOUT_MS + 500; const CLIPBOARD_SPAWN_FAILURE_LIMIT = 3; const CLIPBOARD_READ_TIMEOUT_MS = 750; const CLIPBOARD_READ_MAX_BUFFER_BYTES = 1024 * 1024; +const MODE_COLORS = { + insert: "borderMuted", + normal: "borderAccent", + ex: "warning", +} as const; +const TOKEN = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/; type EditorSnapshot = { text: string; @@ -101,11 +101,13 @@ type ClipboardWriteFn = (text: string, signal: AbortSignal) => Promise; type ClipboardReadFn = () => string | null; type ClipboardProcess = ReturnType; -type ModeLabelColorizers = { - insert: (s: string) => string; - normal: (s: string) => string; - ex: (s: string) => string; +type ModeColorKey = keyof typeof MODE_COLORS; +type ModeColorizers = Record string>; +type ModalEditorOptions = { + labelColorizers?: ModeColorizers | null; + borderColorizers?: ModeColorizers | null; }; +type ThemeLike = { fg(token: string, text: string): string }; type CursorShapeSequence = | typeof INSERT_CURSOR_SHAPE @@ -120,6 +122,45 @@ type CursorShapeRuntime = { type CursorShapeCleanup = () => void; +function resolveModeColors( + colors?: ModeColorSettings, +): Required { + return { + insert: colors?.insert ?? MODE_COLORS.insert, + normal: colors?.normal ?? MODE_COLORS.normal, + ex: colors?.ex ?? MODE_COLORS.ex, + }; +} +function colorizeWithTheme( + theme: ThemeLike, + token: string, + fallback: string, + text: string, +): string { + const trimmedToken = token.trim(); + if (TOKEN.test(trimmedToken)) { + try { + return theme.fg(trimmedToken, text); + } catch { + return theme.fg(fallback, text); + } + } + return theme.fg(fallback, text); +} +function buildModeColorizers( + theme: ThemeLike, + colors: Required, + transform: (text: string) => string = (text) => text, +): ModeColorizers { + const colorizer = (mode: ModeColorKey) => (text: string) => + colorizeWithTheme(theme, colors[mode], MODE_COLORS[mode], transform(text)); + return { + insert: colorizer("insert"), + normal: colorizer("normal"), + ex: colorizer("ex"), + }; +} + type CursorShapeTuiCandidate = { terminal?: { write?: unknown }; setShowHardwareCursor?: unknown; @@ -135,7 +176,10 @@ function getCursorShapeRuntime(tui: unknown): CursorShapeRuntime | null { const write = terminal.write; const setShowHardwareCursor = candidate.setShowHardwareCursor; - if (typeof write !== "function" || typeof setShowHardwareCursor !== "function") { + if ( + typeof write !== "function" || + typeof setShowHardwareCursor !== "function" + ) { return null; } @@ -178,7 +222,10 @@ function findSoftwareCursorReset( line: string, startIndex: number, ): { index: number; sequence: (typeof SOFTWARE_CURSOR_RESETS)[number] } | null { - let firstReset: { index: number; sequence: (typeof SOFTWARE_CURSOR_RESETS)[number] } | null = null; + let firstReset: { + index: number; + sequence: (typeof SOFTWARE_CURSOR_RESETS)[number]; + } | null = null; for (const sequence of SOFTWARE_CURSOR_RESETS) { const index = line.indexOf(sequence, startIndex); @@ -203,9 +250,11 @@ function stripSoftwareCursorAfterMarker(line: string): string { const reset = findSoftwareCursorReset(line, cursorContentStart); if (!reset) return line; - return line.slice(0, cursorStart) - + line.slice(cursorContentStart, reset.index) - + line.slice(reset.index + reset.sequence.length); + return ( + line.slice(0, cursorStart) + + line.slice(cursorContentStart, reset.index) + + line.slice(reset.index + reset.sequence.length) + ); } type ClipboardCircuitBreaker = { @@ -236,17 +285,21 @@ function isNodeSpawnErrno(error: unknown): boolean { if (!(error instanceof Error)) return false; const candidate = error as SpawnErrnoLike; - return typeof candidate.code === "string" - && candidate.code.length > 0 - && typeof candidate.syscall === "string" - && candidate.syscall.startsWith("spawn"); + return ( + typeof candidate.code === "string" && + candidate.code.length > 0 && + typeof candidate.syscall === "string" && + candidate.syscall.startsWith("spawn") + ); } function isClipboardEnvironmentFailure(error: unknown): boolean { return error instanceof ClipboardSpawnError || isNodeSpawnErrno(error); } -const PI_CODING_AGENT_MODULE_URL = import.meta.resolve("@mariozechner/pi-coding-agent"); +const PI_CODING_AGENT_MODULE_URL = import.meta.resolve( + "@mariozechner/pi-coding-agent", +); const CLIPBOARD_HELPER_SOURCE = ` import { copyToClipboard } from ${JSON.stringify(PI_CODING_AGENT_MODULE_URL)}; @@ -257,10 +310,7 @@ for await (const chunk of process.stdin) { try { await Promise.resolve(copyToClipboard(Buffer.concat(chunks).toString("utf8"))); -} catch { - // Pi clipboard writes are best-effort. Backend failures must not make the - // helper exit non-zero and trip the parent spawn/environment breaker. -} +} catch {} `; const CLIPBOARD_READ_HELPER_SOURCE = ` @@ -316,11 +366,14 @@ function killClipboardProcess(child: ClipboardProcess): void { try { child.kill("SIGKILL"); } catch { - // Best effort only; clipboard mirroring must not affect editing. + return; } } -function writeClipboardInChildProcess(text: string, signal: AbortSignal): Promise { +function writeClipboardInChildProcess( + text: string, + signal: AbortSignal, +): Promise { return new Promise((resolve, reject) => { if (signal.aborted) { reject(getAbortError(signal)); @@ -350,12 +403,20 @@ function writeClipboardInChildProcess(text: string, signal: AbortSignal): Promis } try { - child = spawn(process.execPath, ["--input-type=module", "-e", CLIPBOARD_HELPER_SOURCE], { - stdio: ["pipe", "pipe", "ignore"], - windowsHide: true, - }); + child = spawn( + process.execPath, + ["--input-type=module", "-e", CLIPBOARD_HELPER_SOURCE], + { + stdio: ["pipe", "pipe", "ignore"], + windowsHide: true, + }, + ); } catch (error) { - finish(new ClipboardSpawnError("clipboard helper spawn failed", { cause: error })); + finish( + new ClipboardSpawnError("clipboard helper spawn failed", { + cause: error, + }), + ); return; } @@ -369,7 +430,11 @@ function writeClipboardInChildProcess(text: string, signal: AbortSignal): Promis }); child.once("error", (error) => { - finish(new ClipboardSpawnError("clipboard helper spawn failed", { cause: error })); + finish( + new ClipboardSpawnError("clipboard helper spawn failed", { + cause: error, + }), + ); }); child.once("close", (code) => { @@ -393,7 +458,11 @@ function writeClipboardInChildProcess(text: string, signal: AbortSignal): Promis return; } - finish(new ClipboardSpawnError(`clipboard helper failed with exit code ${code ?? "null"}`)); + finish( + new ClipboardSpawnError( + `clipboard helper failed with exit code ${code ?? "null"}`, + ), + ); }); if (!child.stdin) { @@ -436,7 +505,9 @@ class ClipboardMirror { ) {} setWriteFn(writeFn: ClipboardWriteFn): void { - this.activeController?.abort(createClipboardAbortError("clipboard writer replaced")); + this.activeController?.abort( + createClipboardAbortError("clipboard writer replaced"), + ); this.writeFn = writeFn; resetClipboardCircuitBreaker(); } @@ -446,7 +517,9 @@ class ClipboardMirror { } hasPendingWrite(): boolean { - return this.activeText !== null || this.pendingText !== null || this.draining; + return ( + this.activeText !== null || this.pendingText !== null || this.draining + ); } mirror(text: string): void { @@ -476,7 +549,6 @@ class ClipboardMirror { this.circuitBreaker.consecutiveEnvironmentFailures = 0; } catch (error) { this.recordWriteFailure(error); - // Clipboard mirroring is best-effort; the register is authoritative. } finally { if (this.activeController === controller) { this.activeController = null; @@ -503,13 +575,19 @@ class ClipboardMirror { } this.circuitBreaker.consecutiveEnvironmentFailures += 1; - if (this.circuitBreaker.consecutiveEnvironmentFailures >= CLIPBOARD_SPAWN_FAILURE_LIMIT) { + if ( + this.circuitBreaker.consecutiveEnvironmentFailures >= + CLIPBOARD_SPAWN_FAILURE_LIMIT + ) { this.circuitBreaker.disabled = true; this.pendingText = null; } } - private async writeWithTimeout(text: string, controller: AbortController): Promise { + private async writeWithTimeout( + text: string, + controller: AbortController, + ): Promise { const timeoutError = createClipboardAbortError("clipboard write timed out"); const timeoutId = setTimeout(() => { controller.abort(timeoutError); @@ -548,15 +626,18 @@ export class ModalEditor extends CustomEditor { private readonly redoStack: EditorSnapshot[] = []; private currentTransition: TransitionState = "none"; private onChangeHooked: boolean = false; - private readonly labelColorizers: ModeLabelColorizers | null; + private readonly labelColorizers: ModeColorizers | null; + private readonly borderColorizers: ModeColorizers | null; private readonly cursorShapeRuntime: CursorShapeRuntime | null; private lastCursorShapeSequence: CursorShapeSequence | null = null; - // Unnamed register private unnamedRegister: string = ""; private preferRegisterForPut = false; - private clipboardMirrorPolicy: ClipboardMirrorPolicy = DEFAULT_CLIPBOARD_MIRROR_POLICY; - private readonly clipboardMirror = new ClipboardMirror(writeClipboardInChildProcess); + private clipboardMirrorPolicy: ClipboardMirrorPolicy = + DEFAULT_CLIPBOARD_MIRROR_POLICY; + private readonly clipboardMirror = new ClipboardMirror( + writeClipboardInChildProcess, + ); private clipboardReadFn: ClipboardReadFn = readClipboardInChildProcess; private quitFn: () => void = () => {}; private notifyFn: (message: string) => void = () => {}; @@ -565,18 +646,21 @@ export class ModalEditor extends CustomEditor { tui: CustomEditorConstructorArgs[0], theme: CustomEditorConstructorArgs[1], kb: CustomEditorConstructorArgs[2], - labelColorizers?: ModeLabelColorizers | null, + opts?: ModalEditorOptions, ) { super(tui, theme, kb); this.cursorShapeRuntime = getCursorShapeRuntime(tui); - this.labelColorizers = labelColorizers ?? null; + this.labelColorizers = opts?.labelColorizers ?? null; + this.borderColorizers = opts?.borderColorizers ?? null; + this.installModeBorderColorizer(); } - // Test seams setClipboardFn(fn: (text: string, signal?: AbortSignal) => unknown): void { - this.clipboardMirror.setWriteFn(async (text: string, signal: AbortSignal) => { - await fn(text, signal); - }); + this.clipboardMirror.setWriteFn( + async (text: string, signal: AbortSignal) => { + await fn(text, signal); + }, + ); } setClipboardWriteTimeoutMs(timeoutMs: number): void { this.clipboardMirror.setTimeoutMs(timeoutMs); @@ -590,15 +674,51 @@ export class ModalEditor extends CustomEditor { getClipboardMirrorPolicy(): ClipboardMirrorPolicy { return this.clipboardMirrorPolicy; } - setQuitFn(fn: () => void): void { this.quitFn = fn; } - setNotifyFn(fn: (message: string) => void): void { this.notifyFn = fn; } - getRegister(): string { return this.unnamedRegister; } + setQuitFn(fn: () => void): void { + this.quitFn = fn; + } + setNotifyFn(fn: (message: string) => void): void { + this.notifyFn = fn; + } + getRegister(): string { + return this.unnamedRegister; + } setRegister(text: string): void { this.unnamedRegister = text; this.preferRegisterForPut = false; } - getMode(): Mode { return this.mode; } - getText(): string { return this.getLines().join("\n"); } + getMode(): Mode { + return this.mode; + } + getText(): string { + return this.getLines().join("\n"); + } + + private getActiveMode(): Mode | "ex" { + if (this.pendingExCommand !== null) return "ex"; + return this.mode; + } + + private installModeBorderColorizer(): void { + if (!this.borderColorizers) return; + let base = this.borderColor; + const modeBorderColor = (text: string) => + (this.borderColorizers?.[this.getActiveMode()] ?? base)(text); + // Pi assigns its default border color after extension editor construction. + // Keep a mode-aware getter installed and treat later assignments as the + // fallback/base color, otherwise syncBorderColorWithMode is overwritten in + // real sessions even though direct editor tests pass. + Object.defineProperty(this, "borderColor", { + get: () => modeBorderColor, + set(next: unknown) { + if (typeof next === "function") base = next as typeof base; + }, + }); + } + + private setMode(mode: Mode = "insert"): void { + this.mode = mode; + } override setText(text: string): void { this.clearRedoStack(); @@ -613,14 +733,20 @@ export class ModalEditor extends CustomEditor { }; } - private requireRedoRestoreState( - editor: ModalEditorInternals, - ): { lines: string[]; cursorLine?: number; cursorCol?: number } { + private requireRedoRestoreState(editor: ModalEditorInternals): { + lines: string[]; + cursorLine?: number; + cursorCol?: number; + } { const state = editor.state; if (!state || !Array.isArray(state.lines)) { throw new Error("Redo restore prerequisite: editor state unavailable"); } - return state as { lines: string[]; cursorLine?: number; cursorCol?: number }; + return state as { + lines: string[]; + cursorLine?: number; + cursorCol?: number; + }; } private restoreSnapshot(snapshot: EditorSnapshot): void { @@ -652,9 +778,11 @@ export class ModalEditor extends CustomEditor { } private snapshotChanged(a: EditorSnapshot, b: EditorSnapshot): boolean { - return a.text !== b.text - || a.cursor.line !== b.cursor.line - || a.cursor.col !== b.cursor.col; + return ( + a.text !== b.text || + a.cursor.line !== b.cursor.line || + a.cursor.col !== b.cursor.col + ); } private withTransition( @@ -741,9 +869,7 @@ export class ModalEditor extends CustomEditor { private applySyntheticEdit(mutation: () => void): void { const editor = this as unknown as ModalEditorInternals; if (!editor.state || !Array.isArray(editor.state.lines)) { - throw new Error( - "Synthetic edit prerequisite: editor state unavailable", - ); + throw new Error("Synthetic edit prerequisite: editor state unavailable"); } if (typeof editor.pushUndoSnapshot !== "function") { @@ -760,9 +886,6 @@ export class ModalEditor extends CustomEditor { if (this.getText() === textBefore) return; - // Text changed — push undo boundary for pre-mutation state. - // Briefly swap pre-mutation state in for the snapshot, then - // restore the post-mutation result. const postLines = editor.state.lines.slice(); const postCursorLine = editor.state.cursorLine; const postCursorCol = editor.state.cursorCol; @@ -790,8 +913,9 @@ export class ModalEditor extends CustomEditor { } private clearPendingExCommand(): void { - const shouldDiscardBracketedPasteTail = this.acceptingBracketedPasteInExCommand - || this.pendingEscWhileAcceptingBracketedPasteInExCommand; + const shouldDiscardBracketedPasteTail = + this.acceptingBracketedPasteInExCommand || + this.pendingEscWhileAcceptingBracketedPasteInExCommand; this.pendingExCommand = null; this.acceptingBracketedPasteInExCommand = false; @@ -875,7 +999,10 @@ export class ModalEditor extends CustomEditor { } } - private stripBracketedPasteInNormalMode(data: string): { filtered: string | null; stripped: boolean } { + private stripBracketedPasteInNormalMode(data: string): { + filtered: string | null; + stripped: boolean; + } { let chunk = data; let stripped = false; @@ -898,14 +1025,18 @@ export class ModalEditor extends CustomEditor { } stripped = true; - const end = chunk.indexOf(BRACKETED_PASTE_END, start + BRACKETED_PASTE_START.length); + const end = chunk.indexOf( + BRACKETED_PASTE_END, + start + BRACKETED_PASTE_START.length, + ); if (end === -1) { this.discardingBracketedPasteInNormalMode = true; const leading = chunk.slice(0, start); return { filtered: leading.length > 0 ? leading : null, stripped }; } - chunk = chunk.slice(0, start) + chunk.slice(end + BRACKETED_PASTE_END.length); + chunk = + chunk.slice(0, start) + chunk.slice(end + BRACKETED_PASTE_END.length); if (!chunk) return { filtered: null, stripped }; } } @@ -958,24 +1089,19 @@ export class ModalEditor extends CustomEditor { return; } - if (this.mode === "insert") { - // Shift+Alt+A: go to end of line (like Esc -> A but stay in insert) + if ("insert" === this.mode) { if (matchesKey(data, Key.shiftAlt("a")) || data === "\x1bA") { super.handleInput(CTRL_E); return; } - // Shift+Alt+I: go to start of line (like Esc -> I but stay in insert) if (matchesKey(data, Key.shiftAlt("i")) || data === "\x1bI") { super.handleInput(CTRL_A); return; } - // Alt+o: open new line below (stay in insert mode) if (matchesKey(data, Key.alt("o")) || data === "\x1bo") { this.openLineBelow(); return; } - // Alt+Shift+o: open new line above (stay in insert mode) - // \x1bO is the legacy sequence for Alt+Shift+O (VT100 SS3 prefix in non-Kitty terminals) if (matchesKey(data, Key.shiftAlt("o")) || data === "\x1bO") { this.openLineAbove(); return; @@ -1003,9 +1129,14 @@ export class ModalEditor extends CustomEditor { const replacement = data.repeat(count); const lineStartAbs = this.getAbsoluteIndex(cursor.line, 0); const text = this.getText(); - const newText = text.slice(0, lineStartAbs) + before + replacement + after - + text.slice(lineStartAbs + line.length); - const newCursorAbs = lineStartAbs + before.length + data.length * (count - 1); + const newText = + text.slice(0, lineStartAbs) + + before + + replacement + + after + + text.slice(lineStartAbs + line.length); + const newCursorAbs = + lineStartAbs + before.length + data.length * (count - 1); this.replaceTextInBuffer(newText, newCursorAbs); return; } @@ -1068,32 +1199,42 @@ export class ModalEditor extends CustomEditor { } if ( - this.pendingMotion - || this.pendingTextObject - || this.pendingOperator - || this.prefixCount - || this.operatorCount - || this.pendingG - || this.pendingGCount - || this.pendingReplace + this.pendingMotion || + this.pendingTextObject || + this.pendingOperator || + this.prefixCount || + this.operatorCount || + this.pendingG || + this.pendingGCount || + this.pendingReplace ) { this.clearPendingState(); return; } - if (this.mode === "insert") { + if ("insert" === this.mode) { this.clearUnderlyingPasteStateIfActive(); - this.mode = "normal"; + this.setMode("normal"); } else { super.handleInput("\x1b"); // pass escape to abort agent } } private isEnterLikeInput(data: string): boolean { - return data === "\r" || data === "\n" || matchesKey(data, "enter") || matchesKey(data, "return"); + return ( + data === "\r" || + data === "\n" || + matchesKey(data, "enter") || + matchesKey(data, "return") + ); } private isBackspaceLikeInput(data: string): boolean { - return data === "\x7f" || data === "\x08" || matchesKey(data, "backspace") || matchesKey(data, "ctrl+h"); + return ( + data === "\x7f" || + data === "\x08" || + matchesKey(data, "backspace") || + matchesKey(data, "ctrl+h") + ); } private deleteLastPendingExCommandGrapheme(): void { @@ -1116,10 +1257,10 @@ export class ModalEditor extends CustomEditor { private handlePendingExCommandControlChunk(data: string): boolean { if ( - !data.includes("\r") - && !data.includes("\n") - && !data.includes("\x7f") - && !data.includes("\x08") + !data.includes("\r") && + !data.includes("\n") && + !data.includes("\x7f") && + !data.includes("\x08") ) { return false; } @@ -1216,7 +1357,8 @@ export class ModalEditor extends CustomEditor { if (data.length === 0) return false; for (const char of data) { const codePoint = char.codePointAt(0); - if (codePoint === undefined || codePoint < 32 || codePoint === 127) return false; + if (codePoint === undefined || codePoint < 32 || codePoint === 127) + return false; } return true; } @@ -1253,9 +1395,10 @@ export class ModalEditor extends CustomEditor { if (prefix === null && operator === null) return defaultValue; - const total = prefix !== null && operator !== null - ? prefix * operator - : prefix ?? operator ?? defaultValue; + const total = + prefix !== null && operator !== null + ? prefix * operator + : (prefix ?? operator ?? defaultValue); if (!Number.isFinite(total) || total <= 0) return defaultValue; return Math.min(MAX_COUNT, total); @@ -1290,7 +1433,7 @@ export class ModalEditor extends CustomEditor { } else if (this.pendingOperator === "c") { this.deleteWithCharMotion(pendingMotion, data); this.pendingOperator = null; - this.mode = "insert"; + this.setMode(); } else if (this.pendingOperator === "y") { this.yankWithCharMotion(pendingMotion, data); this.pendingOperator = null; @@ -1319,7 +1462,11 @@ export class ModalEditor extends CustomEditor { if (data === "w" || data === "W") { const semanticClass: WordTextObjectClass = data === "W" ? "WORD" : "word"; const count = this.takeTotalCount(1); - const range = this.getWordObjectRange(pendingTextObject, count, semanticClass); + const range = this.getWordObjectRange( + pendingTextObject, + count, + semanticClass, + ); if (!range || !this.pendingOperator) { this.pendingOperator = null; return; @@ -1357,7 +1504,7 @@ export class ModalEditor extends CustomEditor { if (range.endAbs === range.startAbs) { if (pendingOperator === "c") { this.moveCursorToAbsoluteIndex(range.startAbs); - this.mode = "insert"; + this.setMode(); } return; } @@ -1369,7 +1516,7 @@ export class ModalEditor extends CustomEditor { if (pendingOperator === "c") { this.deleteRangeByAbsolute(range.startAbs, range.endAbs); - this.mode = "insert"; + this.setMode(); return; } @@ -1399,7 +1546,8 @@ export class ModalEditor extends CustomEditor { } if (data === "j" || data === "k") { - const hasDualCount = this.prefixCount.length > 0 && this.operatorCount.length > 0; + const hasDualCount = + this.prefixCount.length > 0 && this.operatorCount.length > 0; const count = this.takeTotalCount(1); const delta = hasDualCount ? Math.max(0, count - 1) : count; this.deleteLinewiseByDelta(data === "j" ? delta : -delta); @@ -1430,20 +1578,18 @@ export class ModalEditor extends CustomEditor { return; } - const hasCount = this.prefixCount.length > 0 || this.operatorCount.length > 0; - const supportsCountedWordMotion = ( - data === "w" - || data === "e" - || data === "b" - || data === "W" - || data === "E" - || data === "B" - ); + const hasCount = + this.prefixCount.length > 0 || this.operatorCount.length > 0; + const supportsCountedWordMotion = + data === "w" || + data === "e" || + data === "b" || + data === "W" || + data === "E" || + data === "B"; const supportsCountedTextObject = data === "i" || data === "a"; if (hasCount && !supportsCountedWordMotion && !supportsCountedTextObject) { - // Counted forms beyond dd, d{count}j/k, d{count}{f/F/t/T}, and - // d{count}{w/e/b/W/E/B}/{i/a}w are out of scope. this.cancelPendingOperator(data); return; } @@ -1459,7 +1605,6 @@ export class ModalEditor extends CustomEditor { return; } - // Invalid motion: cancel operator to avoid sticky surprising deletes. this.cancelPendingOperator(data); } @@ -1484,7 +1629,7 @@ export class ModalEditor extends CustomEditor { this.cutLine(); this.pendingOperator = null; - this.mode = "insert"; + this.setMode(); return; } @@ -1505,7 +1650,7 @@ export class ModalEditor extends CustomEditor { this.replaceTextInBuffer(newText, cursorAbs); } this.pendingOperator = null; - this.mode = "insert"; + this.setMode(); return; } @@ -1514,15 +1659,15 @@ export class ModalEditor extends CustomEditor { return; } - const hasCount = this.prefixCount.length > 0 || this.operatorCount.length > 0; - const supportsCountedWordMotion = ( - data === "w" - || data === "e" - || data === "b" - || data === "W" - || data === "E" - || data === "B" - ); + const hasCount = + this.prefixCount.length > 0 || this.operatorCount.length > 0; + const supportsCountedWordMotion = + data === "w" || + data === "e" || + data === "b" || + data === "W" || + data === "E" || + data === "B"; const supportsCountedTextObject = data === "i" || data === "a"; if (hasCount && !supportsCountedWordMotion && !supportsCountedTextObject) { @@ -1536,16 +1681,14 @@ export class ModalEditor extends CustomEditor { } const motionCount = supportsCountedWordMotion ? this.takeTotalCount(1) : 1; - const effectiveMotion = data === "W" && this.isCursorOnNonWhitespace() - ? "E" - : data; + const effectiveMotion = + data === "W" && this.isCursorOnNonWhitespace() ? "E" : data; if (this.deleteWithMotion(effectiveMotion, motionCount)) { this.pendingOperator = null; - this.mode = "insert"; + this.setMode(); return; } - // Invalid motion: cancel operator to avoid sticky surprising changes. this.cancelPendingOperator(data); } @@ -1605,43 +1748,34 @@ export class ModalEditor extends CustomEditor { return; } - const supportsCountedStandaloneEdit = ( - data === "x" - || data === "r" - || data === "s" - || data === "S" - || data === "D" - || data === "C" - || data === "p" - || data === "P" - || data === "Y" - || data === "J" - || data === "u" - || data === CTRL_UNDERSCORE - || matchesKey(data, "ctrl+_") - || data === CTRL_R - || matchesKey(data, "ctrl+r") - ); - const supportsCountedCharMotion = ( - CHAR_MOTION_KEYS.has(data) - || data === ";" - || data === "," - ); - const supportsCountedWordMotion = ( - data === "w" - || data === "e" - || data === "b" - || data === "W" - || data === "E" - || data === "B" - ); + const supportsCountedStandaloneEdit = + data === "x" || + data === "r" || + data === "s" || + data === "S" || + data === "D" || + data === "C" || + data === "p" || + data === "P" || + data === "Y" || + data === "J" || + data === "u" || + data === CTRL_UNDERSCORE || + matchesKey(data, "ctrl+_") || + data === CTRL_R || + matchesKey(data, "ctrl+r"); + const supportsCountedCharMotion = + CHAR_MOTION_KEYS.has(data) || data === ";" || data === ","; + const supportsCountedWordMotion = + data === "w" || + data === "e" || + data === "b" || + data === "W" || + data === "E" || + data === "B"; const supportsCountedParagraphMotion = data === "{" || data === "}"; - const supportsCountedNav = ( - data === "h" - || data === "j" - || data === "k" - || data === "l" - ); + const supportsCountedNav = + data === "h" || data === "j" || data === "k" || data === "l"; const supportsCountedUnderscore = data === "_"; if (supportsCountedNav) { @@ -1664,13 +1798,12 @@ export class ModalEditor extends CustomEditor { } if ( - !supportsCountedStandaloneEdit - && !supportsCountedCharMotion - && !supportsCountedWordMotion - && !supportsCountedParagraphMotion - && !supportsCountedUnderscore + !supportsCountedStandaloneEdit && + !supportsCountedCharMotion && + !supportsCountedWordMotion && + !supportsCountedParagraphMotion && + !supportsCountedUnderscore ) { - // Unsupported prefixed forms: drop count and keep processing this key. this.prefixCount = ""; this.operatorCount = ""; } @@ -1742,7 +1875,11 @@ export class ModalEditor extends CustomEditor { } if (data === ";" && this.lastCharMotion) { - this.executeCharMotion(this.lastCharMotion.motion, this.lastCharMotion.char, false); + this.executeCharMotion( + this.lastCharMotion.motion, + this.lastCharMotion.char, + false, + ); return; } if (data === "," && this.lastCharMotion) { @@ -1754,7 +1891,11 @@ export class ModalEditor extends CustomEditor { return; } - if (data === "u" || data === CTRL_UNDERSCORE || matchesKey(data, "ctrl+_")) { + if ( + data === "u" || + data === CTRL_UNDERSCORE || + matchesKey(data, "ctrl+_") + ) { this.performUndo(); return; } @@ -1814,7 +1955,6 @@ export class ModalEditor extends CustomEditor { return; } - // Pass control sequences (ctrl+c, etc.) to super, ignore printable chars if (this.isPrintableChunk(data)) return; super.handleInput(data); } @@ -1834,29 +1974,29 @@ export class ModalEditor extends CustomEditor { const seq = NORMAL_KEYS[key]; switch (key) { case "i": - this.mode = "insert"; + this.setMode(); break; case "a": - this.mode = "insert"; + this.setMode(); if (!this.isCursorAtOrPastEol()) { super.handleInput(ESC_RIGHT); } break; case "A": - this.mode = "insert"; + this.setMode(); super.handleInput(CTRL_E); break; case "I": - this.mode = "insert"; + this.setMode(); this.moveCursorToFirstNonWhitespace(); break; case "o": this.openLineBelow(); - this.mode = "insert"; + this.setMode(); break; case "O": this.openLineAbove(); - this.mode = "insert"; + this.setMode(); break; case "D": this.takeTotalCount(1); @@ -1865,16 +2005,16 @@ export class ModalEditor extends CustomEditor { case "C": this.takeTotalCount(1); this.cutToEndOfLine(); - this.mode = "insert"; + this.setMode(); break; case "S": this.takeTotalCount(1); this.cutCurrentLineContent(); - this.mode = "insert"; + this.setMode(); break; case "s": this.cutCharUnderCursor(); - this.mode = "insert"; + this.setMode(); break; case "x": this.cutCharUnderCursor(); @@ -1890,11 +2030,22 @@ export class ModalEditor extends CustomEditor { } } - private executeCharMotion(motion: CharMotion, targetChar: string, saveMotion: boolean = true): void { + private executeCharMotion( + motion: CharMotion, + targetChar: string, + saveMotion: boolean = true, + ): void { const line = this.getLines()[this.getCursor().line] ?? ""; const col = this.getCursor().col; const count = this.takeTotalCount(1); - const targetCol = findCharMotionTarget(line, col, motion, targetChar, !saveMotion, count); + const targetCol = findCharMotionTarget( + line, + col, + motion, + targetChar, + !saveMotion, + count, + ); if (targetCol !== null && saveMotion) { this.lastCharMotion = { motion, char: targetChar }; @@ -1909,7 +2060,12 @@ export class ModalEditor extends CustomEditor { const lines = this.getLines(); const fromLine = this.getCursor().line; const count = this.takeTotalCount(1); - const targetLine = findParagraphMotionTarget(lines, fromLine, direction, count); + const targetLine = findParagraphMotionTarget( + lines, + fromLine, + direction, + count, + ); this.moveCursorToLineStart(targetLine); } @@ -1924,7 +2080,11 @@ export class ModalEditor extends CustomEditor { const state = editor.state; if (!state || !Array.isArray(state.lines)) return false; - if (!Number.isInteger(state.cursorLine) || !Number.isInteger(state.cursorCol)) return false; + if ( + !Number.isInteger(state.cursorLine) || + !Number.isInteger(state.cursorCol) + ) + return false; const cursorLine = state.cursorLine as number; const cursorCol = state.cursorCol as number; @@ -1933,8 +2093,6 @@ export class ModalEditor extends CustomEditor { const target = cursorCol + delta; - // Only short-circuit line-local movement when each grapheme is one code - // unit; otherwise let the base editor keep cursor boundaries valid. if (target < 0 || target > line.length) return false; state.cursorCol = target; @@ -1974,7 +2132,10 @@ export class ModalEditor extends CustomEditor { } const currentLine = state.cursorLine ?? 0; - const targetLine = Math.max(0, Math.min(currentLine + delta, state.lines.length - 1)); + const targetLine = Math.max( + 0, + Math.min(currentLine + delta, state.lines.length - 1), + ); if (targetLine === currentLine) return; const preferredCol = editor.preferredVisualCol ?? state.cursorCol ?? 0; @@ -2078,9 +2239,12 @@ export class ModalEditor extends CustomEditor { if (normalize) { const trimmedRight = right.trimStart(); const leftLastChar = left[left.length - 1]; - const leftEndsWithSpace = leftLastChar !== undefined && /\s/.test(leftLastChar); + const leftEndsWithSpace = + leftLastChar !== undefined && /\s/.test(leftLastChar); const needsSeparator = !leftEndsWithSpace && trimmedRight.length > 0; - joined = needsSeparator ? `${left} ${trimmedRight}` : left + trimmedRight; + joined = needsSeparator + ? `${left} ${trimmedRight}` + : left + trimmedRight; joinPoint = left.length; } else { joined = left + right; @@ -2174,25 +2338,43 @@ export class ModalEditor extends CustomEditor { } else if (target === "start") { const startType = this.charType(text[next], semanticClass); if (startType !== "space") { - while (next < len && this.charType(text[next], semanticClass) === startType) next++; + while ( + next < len && + this.charType(text[next], semanticClass) === startType + ) + next++; } - while (next < len && this.charType(text[next], semanticClass) === "space") next++; + while ( + next < len && + this.charType(text[next], semanticClass) === "space" + ) + next++; } else { if (next < len - 1) next++; - while (next < len && this.charType(text[next], semanticClass) === "space") next++; + while ( + next < len && + this.charType(text[next], semanticClass) === "space" + ) + next++; if (next >= len) { next = len; } else { const t = this.charType(text[next], semanticClass); - while (next < len - 1 && this.charType(text[next + 1], semanticClass) === t) next++; + while ( + next < len - 1 && + this.charType(text[next + 1], semanticClass) === t + ) + next++; } } } else { if (next >= len) next = len - 1; if (next > 0) next--; - while (next > 0 && this.charType(text[next], semanticClass) === "space") next--; + while (next > 0 && this.charType(text[next], semanticClass) === "space") + next--; const t = this.charType(text[next], semanticClass); - while (next > 0 && this.charType(text[next - 1], semanticClass) === t) next--; + while (next > 0 && this.charType(text[next - 1], semanticClass) === t) + next--; } if (next === i) break; @@ -2281,7 +2463,11 @@ export class ModalEditor extends CustomEditor { semanticClass: WordMotionClass = "word", ): boolean { const col = this.getCursor().col; - const targetCol = this.tryFindWordTargetLineLocal(direction, target, semanticClass); + const targetCol = this.tryFindWordTargetLineLocal( + direction, + target, + semanticClass, + ); if (targetCol === null || targetCol === col) return false; this.moveCursorToCol(targetCol); @@ -2297,7 +2483,8 @@ export class ModalEditor extends CustomEditor { const lineIndex = cursor.line; const col = cursor.col; const lineSnapshot = this.getLines()[lineIndex] ?? ""; - const direction: WordMotionDirection = motion === "b" ? "backward" : "forward"; + const direction: WordMotionDirection = + motion === "b" ? "backward" : "forward"; const target: WordMotionTarget = motion === "e" ? "end" : "start"; const steps = Math.max(1, Math.min(MAX_COUNT, count)); @@ -2364,7 +2551,10 @@ export class ModalEditor extends CustomEditor { return true; } - private writeToRegister(text: string, source: RegisterWriteSource = "mutation"): void { + private writeToRegister( + text: string, + source: RegisterWriteSource = "mutation", + ): void { this.unnamedRegister = text; const shouldMirror = text !== "" && this.shouldMirrorRegisterWrite(source); this.preferRegisterForPut = text !== "" && !shouldMirror; @@ -2380,7 +2570,9 @@ export class ModalEditor extends CustomEditor { } private hasMultiCodeUnitGraphemes(line: string): boolean { - return getLineGraphemes(line).some((segment) => segment.end - segment.start > 1); + return getLineGraphemes(line).some( + (segment) => segment.end - segment.start > 1, + ); } private getGraphemeRangeAtCol( @@ -2391,7 +2583,9 @@ export class ModalEditor extends CustomEditor { ): { start: number; end: number } | null { const clampedCol = Math.max(0, Math.min(col, line.length)); const segments = getLineGraphemes(line); - const startIndex = segments.findIndex((segment) => clampedCol < segment.end); + const startIndex = segments.findIndex( + (segment) => clampedCol < segment.end, + ); if (startIndex === -1) return null; let endIndex = startIndex + Math.max(1, count) - 1; @@ -2432,7 +2626,8 @@ export class ModalEditor extends CustomEditor { const text = this.getText(); this.writeToRegister(line.slice(range.start, range.end)); this.replaceTextInBuffer( - text.slice(0, lineStartAbs + range.start) + text.slice(lineStartAbs + range.end), + text.slice(0, lineStartAbs + range.start) + + text.slice(lineStartAbs + range.end), lineStartAbs + range.start, ); } @@ -2443,7 +2638,8 @@ export class ModalEditor extends CustomEditor { const { line, col } = this.getCurrentLineAndCol(); const hasNextLine = cursorLine < lines.length - 1; - const deleted = col < line.length ? line.slice(col) : hasNextLine ? "\n" : ""; + const deleted = + col < line.length ? line.slice(col) : hasNextLine ? "\n" : ""; this.writeToRegister(deleted); super.handleInput(CTRL_K); @@ -2466,7 +2662,10 @@ export class ModalEditor extends CustomEditor { this.cutCurrentLineContent(); } - private getNormalizedLineRange(startLine: number, endLine: number): { start: number; end: number } { + private getNormalizedLineRange( + startLine: number, + endLine: number, + ): { start: number; end: number } { const lines = this.getLines(); const last = Math.max(0, lines.length - 1); const clampedStart = Math.max(0, Math.min(startLine, last)); @@ -2483,7 +2682,10 @@ export class ModalEditor extends CustomEditor { return `${lines.slice(start, end + 1).join("\n")}\n`; } - private getLineDeleteAbsoluteRange(startLine: number, endLine: number): { startAbs: number; endAbs: number } { + private getLineDeleteAbsoluteRange( + startLine: number, + endLine: number, + ): { startAbs: number; endAbs: number } { const lines = this.getLines(); const text = this.getText(); const { start, end } = this.getNormalizedLineRange(startLine, endLine); @@ -2510,7 +2712,10 @@ export class ModalEditor extends CustomEditor { if (lines.length === 0) return; const payload = this.getLinewisePayload(startLine, endLine); - const { startAbs, endAbs } = this.getLineDeleteAbsoluteRange(startLine, endLine); + const { startAbs, endAbs } = this.getLineDeleteAbsoluteRange( + startLine, + endLine, + ); this.writeToRegister(payload); @@ -2519,7 +2724,6 @@ export class ModalEditor extends CustomEditor { const newText = text.slice(0, startAbs) + text.slice(endAbs); this.replaceTextInBuffer(newText, startAbs); - // Ensure cursor is at column 0 of the landing line super.handleInput(CTRL_A); } } @@ -2552,7 +2756,6 @@ export class ModalEditor extends CustomEditor { const col = cursor.col; if (motion === "$") { - // Match D/C behavior exactly, including newline kill at EOL. this.cutToEndOfLine(); return true; } @@ -2563,7 +2766,11 @@ export class ModalEditor extends CustomEditor { } if (motion === "^") { - this.deleteRange(col, findFirstNonWhitespaceColumn(this.getLines()[cursor.line] ?? ""), false); + this.deleteRange( + col, + findFirstNonWhitespaceColumn(this.getLines()[cursor.line] ?? ""), + false, + ); return true; } @@ -2593,7 +2800,11 @@ export class ModalEditor extends CustomEditor { count, wordMotion.semanticClass, ); - this.deleteRangeByAbsolute(currentAbs, targetAbs, wordMotion.motion === "e"); + this.deleteRangeByAbsolute( + currentAbs, + targetAbs, + wordMotion.motion === "e", + ); return true; } @@ -2604,7 +2815,14 @@ export class ModalEditor extends CustomEditor { const line = this.getLines()[this.getCursor().line] ?? ""; const col = this.getCursor().col; const count = this.takeTotalCount(1); - const targetCol = findCharMotionTarget(line, col, motion, targetChar, false, count); + const targetCol = findCharMotionTarget( + line, + col, + motion, + targetChar, + false, + count, + ); if (targetCol === null) return; @@ -2633,7 +2851,8 @@ export class ModalEditor extends CustomEditor { } if (data === "j" || data === "k") { - const hasDualCount = this.prefixCount.length > 0 && this.operatorCount.length > 0; + const hasDualCount = + this.prefixCount.length > 0 && this.operatorCount.length > 0; const count = this.takeTotalCount(1); const delta = hasDualCount ? Math.max(0, count - 1) : count; this.yankLinewiseByDelta(data === "j" ? delta : -delta); @@ -2670,7 +2889,6 @@ export class ModalEditor extends CustomEditor { } if (this.hasPendingCount()) { - // Counted forms beyond yy, y{count}j/k, and y{count}{f/F/t/T} are out of scope. this.cancelPendingOperator(data); return; } @@ -2728,7 +2946,11 @@ export class ModalEditor extends CustomEditor { 1, wordMotion.semanticClass, ); - this.yankRangeByAbsolute(currentAbs, targetAbs, wordMotion.motion === "e"); + this.yankRangeByAbsolute( + currentAbs, + targetAbs, + wordMotion.motion === "e", + ); return true; } @@ -2739,7 +2961,14 @@ export class ModalEditor extends CustomEditor { const line = this.getLines()[this.getCursor().line] ?? ""; const col = this.getCursor().col; const count = this.takeTotalCount(1); - const targetCol = findCharMotionTarget(line, col, motion, targetChar, false, count); + const targetCol = findCharMotionTarget( + line, + col, + motion, + targetChar, + false, + count, + ); if (targetCol === null) return; @@ -2754,17 +2983,24 @@ export class ModalEditor extends CustomEditor { let end = Math.min(rawEnd, line.length); if (inclusive) { - const targetRange = this.getGraphemeRangeAtCol(line, Math.max(col, targetCol), 1); + const targetRange = this.getGraphemeRangeAtCol( + line, + Math.max(col, targetCol), + 1, + ); end = targetRange?.end ?? end; } if (end <= start) return; - // Yank only — no cursor movement, no text mutation this.writeToRegister(line.slice(start, end), "yank"); } - private yankRangeByAbsolute(currentAbs: number, targetAbs: number, inclusive: boolean = false): void { + private yankRangeByAbsolute( + currentAbs: number, + targetAbs: number, + inclusive: boolean = false, + ): void { const text = this.getText(); const start = Math.min(currentAbs, targetAbs); const rawEnd = Math.max(currentAbs, targetAbs) + (inclusive ? 1 : 0); @@ -2773,7 +3009,10 @@ export class ModalEditor extends CustomEditor { this.writeToRegister(text.slice(start, end), "yank"); } - private getCursorFromAbsoluteIndex(text: string, abs: number): { line: number; col: number } { + private getCursorFromAbsoluteIndex( + text: string, + abs: number, + ): { line: number; col: number } { const lines = text.length === 0 ? [""] : text.split("\n"); let remaining = Math.max(0, Math.min(abs, text.length)); for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) { @@ -2814,7 +3053,11 @@ export class ModalEditor extends CustomEditor { editor.tui?.requestRender?.(); } - private deleteRangeByAbsolute(currentAbs: number, targetAbs: number, inclusive: boolean = false): void { + private deleteRangeByAbsolute( + currentAbs: number, + targetAbs: number, + inclusive: boolean = false, + ): void { const text = this.getText(); const start = Math.min(currentAbs, targetAbs); const rawEnd = Math.max(currentAbs, targetAbs) + (inclusive ? 1 : 0); @@ -2866,12 +3109,14 @@ export class ModalEditor extends CustomEditor { const count = this.takeTotalCount(1); const text = this.getPasteRegisterText(); if (!text) return; - const safeCount = Math.min(count, Math.max(1, Math.floor(ModalEditor.PUT_SIZE_LIMIT / text.length))); + const safeCount = Math.min( + count, + Math.max(1, Math.floor(ModalEditor.PUT_SIZE_LIMIT / text.length)), + ); if (text.endsWith("\n")) { const content = text.slice(0, -1); for (let i = 0; i < safeCount; i++) { - // Line-wise: insert new line below and fill it super.handleInput(CTRL_E); super.handleInput(NEWLINE); for (const char of content) { @@ -2881,7 +3126,6 @@ export class ModalEditor extends CustomEditor { return; } - // Character-wise: insert after cursor if (!this.isCursorAtOrPastEol()) { super.handleInput(ESC_RIGHT); } @@ -2896,12 +3140,14 @@ export class ModalEditor extends CustomEditor { const count = this.takeTotalCount(1); const text = this.getPasteRegisterText(); if (!text) return; - const safeCount = Math.min(count, Math.max(1, Math.floor(ModalEditor.PUT_SIZE_LIMIT / text.length))); + const safeCount = Math.min( + count, + Math.max(1, Math.floor(ModalEditor.PUT_SIZE_LIMIT / text.length)), + ); if (text.endsWith("\n")) { const content = text.slice(0, -1); for (let i = 0; i < safeCount; i++) { - // Line-wise: insert new line above and fill it super.handleInput(CTRL_A); super.handleInput(NEWLINE); super.handleInput(ESC_UP); @@ -2912,7 +3158,6 @@ export class ModalEditor extends CustomEditor { return; } - // Character-wise: insert before cursor (just type it) for (let i = 0; i < safeCount; i++) { for (const char of text) { super.handleInput(char === "\n" ? NEWLINE : char); @@ -2920,7 +3165,11 @@ export class ModalEditor extends CustomEditor { } } - private deleteRange(col: number, targetCol: number, inclusive: boolean): void { + private deleteRange( + col: number, + targetCol: number, + inclusive: boolean, + ): void { const cursor = this.getCursor(); const line = this.getLines()[cursor.line] ?? ""; const lineStartAbs = this.getAbsoluteIndex(cursor.line, 0); @@ -2929,7 +3178,11 @@ export class ModalEditor extends CustomEditor { let end = Math.min(rawEnd, line.length); if (inclusive) { - const targetRange = this.getGraphemeRangeAtCol(line, Math.max(col, targetCol), 1); + const targetRange = this.getGraphemeRangeAtCol( + line, + Math.max(col, targetCol), + 1, + ); end = targetRange?.end ?? end; } @@ -2978,7 +3231,7 @@ export class ModalEditor extends CustomEditor { } private getDesiredCursorShapeSequence(): CursorShapeSequence { - return this.mode === "insert" && this.pendingExCommand === null + return "insert" === this.mode && this.pendingExCommand === null ? INSERT_CURSOR_SHAPE : BLOCK_CURSOR_SHAPE; } @@ -3026,7 +3279,8 @@ export class ModalEditor extends CustomEditor { const last = lines.length - 1; const lastLine = lines[last]; if (lastLine && visibleWidth(lastLine) >= visibleWidth(rawLabel)) { - lines[last] = truncateToWidth(lastLine, width - visibleWidth(rawLabel), "") + label; + lines[last] = + truncateToWidth(lastLine, width - visibleWidth(rawLabel), "") + label; } else { lines[last] = label; } @@ -3034,13 +3288,11 @@ export class ModalEditor extends CustomEditor { } private getModeLabelColorizer(): ((s: string) => string) | null { - if (!this.labelColorizers) return null; - if (this.pendingExCommand !== null) return this.labelColorizers.ex; - return this.mode === "insert" ? this.labelColorizers.insert : this.labelColorizers.normal; + return this.labelColorizers?.[this.getActiveMode()] ?? null; } private getModeLabel(): string { - if (this.mode === "insert") return " INSERT "; + if ("insert" === this.mode) return " INSERT "; if (this.pendingExCommand !== null) return ` EX ${this.pendingExCommand}_ `; const prefixCount = this.prefixCount; @@ -3073,21 +3325,29 @@ export default function (pi: ExtensionAPI) { pi.on("session_start", (_event, ctx) => { const piVimSettings = readPiVimSettings(ctx.cwd); - const clipboardMirrorPolicy = resolveClipboardMirrorPolicy(piVimSettings.clipboardMirror); + const clipboardMirrorPolicy = resolveClipboardMirrorPolicy( + piVimSettings.clipboardMirror, + ); if (clipboardMirrorPolicy.warning && ctx.hasUI) { ctx.ui.notify(clipboardMirrorPolicy.warning, "warning"); } const t = ctx.ui.theme; + const modeColors = resolveModeColors(piVimSettings.modeColors); const reverseVideo = (s: string) => `\x1b[7m${s}\x1b[27m`; - const colorizers = t ? { - insert: (s: string) => t.fg("borderMuted", reverseVideo(s)), - normal: (s: string) => t.fg("borderAccent", reverseVideo(s)), - ex: (s: string) => t.fg("warning", reverseVideo(s)), - } : null; + const labelColorizers = t + ? buildModeColorizers(t, modeColors, reverseVideo) + : null; + const borderColorizers = + t && piVimSettings.syncBorderColorWithMode === true + ? buildModeColorizers(t, modeColors) + : null; ctx.ui.setEditorComponent((tui, theme, kb) => { cursorShapeCleanup = enableCursorShapeSupport(tui); - const editor = new ModalEditor(tui, theme, kb, colorizers); + const editor = new ModalEditor(tui, theme, kb, { + labelColorizers, + borderColorizers, + }); editor.setClipboardMirrorPolicy(clipboardMirrorPolicy.policy); editor.setQuitFn(() => ctx.shutdown()); editor.setNotifyFn((message) => ctx.ui.notify(message, "warning")); diff --git a/motions.ts b/motions.ts index 08926d40..dc64ad0c 100644 --- a/motions.ts +++ b/motions.ts @@ -49,7 +49,10 @@ export function isBlankLine(line: string | undefined): boolean { /** * Paragraph start: non-blank line at BOF or after a blank line. */ -export function isParagraphStart(lines: readonly string[], lineIndex: number): boolean { +export function isParagraphStart( + lines: readonly string[], + lineIndex: number, +): boolean { if (!Number.isInteger(lineIndex)) return false; if (lineIndex < 0 || lineIndex >= lines.length) return false; if (isBlankLine(lines[lineIndex])) return false; @@ -60,7 +63,10 @@ export function isParagraphStart(lines: readonly string[], lineIndex: number): b /** * One step of } motion from current line index. */ -export function findNextParagraphStart(lines: readonly string[], fromLine: number): number { +export function findNextParagraphStart( + lines: readonly string[], + fromLine: number, +): number { if (lines.length === 0) return 0; const start = clampLineIndex(lines, fromLine) + 1; @@ -74,7 +80,10 @@ export function findNextParagraphStart(lines: readonly string[], fromLine: numbe /** * One step of { motion from current line index. */ -export function findPrevParagraphStart(lines: readonly string[], fromLine: number): number { +export function findPrevParagraphStart( + lines: readonly string[], + fromLine: number, +): number { if (lines.length === 0) return 0; const start = clampLineIndex(lines, fromLine) - 1; @@ -125,16 +134,21 @@ export function reverseCharMotion(motion: CharMotion): CharMotion { return reverseMap[motion]; } -const GRAPHEME_SEGMENTER = typeof Intl !== "undefined" - && typeof Intl.Segmenter === "function" - ? new Intl.Segmenter(undefined, { granularity: "grapheme" }) - : null; +const GRAPHEME_SEGMENTER = + typeof Intl !== "undefined" && typeof Intl.Segmenter === "function" + ? new Intl.Segmenter(undefined, { granularity: "grapheme" }) + : null; -export function getLineGraphemes(line: string): Array<{ start: number; end: number }> { +export function getLineGraphemes( + line: string, +): Array<{ start: number; end: number }> { const segments: Array<{ start: number; end: number }> = []; if (GRAPHEME_SEGMENTER) { for (const part of GRAPHEME_SEGMENTER.segment(line)) { - segments.push({ start: part.index, end: part.index + part.segment.length }); + segments.push({ + start: part.index, + end: part.index + part.segment.length, + }); } return segments; } @@ -163,7 +177,7 @@ export function findCharMotionTarget( const steps = Number.isFinite(count) && count > 0 ? Math.floor(count) : 1; const graphemes = getLineGraphemes(line); - let currentIndex = graphemes.findIndex(g => col < g.end); + let currentIndex = graphemes.findIndex((g) => col < g.end); if (currentIndex === -1) currentIndex = graphemes.length; for (let i = 0; i < steps; i++) { @@ -179,7 +193,10 @@ export function findCharMotionTarget( if (!g) continue; // Use startsWith to allow matching base chars if targetChar lacks combining marks, // or just exact match since targetChar is typically a full grapheme. - if (line.slice(g.start, g.end) === targetChar || line.slice(g.start, g.end).startsWith(targetChar)) { + if ( + line.slice(g.start, g.end) === targetChar || + line.slice(g.start, g.end).startsWith(targetChar) + ) { found = j; break; } @@ -199,7 +216,10 @@ export function findCharMotionTarget( for (let j = nextIndex; j >= 0; j--) { const g = graphemes[j]; if (!g) continue; - if (line.slice(g.start, g.end) === targetChar || line.slice(g.start, g.end).startsWith(targetChar)) { + if ( + line.slice(g.start, g.end) === targetChar || + line.slice(g.start, g.end).startsWith(targetChar) + ) { found = j; break; } @@ -243,11 +263,13 @@ export function findWordMotionTarget( // Skip current word/punct block if (startType !== CharType.Space) { - while (i < len && getCharType(line[i], semanticClass) === startType) i++; + while (i < len && getCharType(line[i], semanticClass) === startType) + i++; } // Skip whitespace - while (i < len && getCharType(line[i], semanticClass) === CharType.Space) i++; + while (i < len && getCharType(line[i], semanticClass) === CharType.Space) + i++; return i; } @@ -256,7 +278,8 @@ export function findWordMotionTarget( if (i < len - 1) i++; // Skip whitespace forward - while (i < len && getCharType(line[i], semanticClass) === CharType.Space) i++; + while (i < len && getCharType(line[i], semanticClass) === CharType.Space) + i++; // Now at start of next word (or end of line). Find end. if (i >= len) return len; diff --git a/package-lock.json b/package-lock.json index e2ecade2..a78fa052 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pi-vim", - "version": "0.9.0", + "version": "0.10.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-vim", - "version": "0.9.0", + "version": "0.10.0", "license": "MIT", "devDependencies": { "@biomejs/biome": "2.4.8", diff --git a/package.json b/package.json index 10ea3da2..88d1c769 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pi-vim", - "version": "0.9.0", + "version": "0.10.0", "description": "Vim-style modal editing for Pi's TUI editor", "type": "module", "keywords": [ @@ -22,8 +22,8 @@ ], "scripts": { "build": "echo 'nothing to build'", - "format": "biome format --write .", - "lint": "biome lint . && eslint .", + "format": "biome check --write .", + "lint": "biome check . && eslint .", "typecheck": "tsc --noEmit", "test": "node --import tsx/esm --test 'test/**/*.test.ts'", "check": "npm run lint && npm run typecheck && npm run test", diff --git a/script/image-attachments-e2e.ts b/script/image-attachments-e2e.ts index 065d22e0..0b141b66 100644 --- a/script/image-attachments-e2e.ts +++ b/script/image-attachments-e2e.ts @@ -7,12 +7,12 @@ import { dirname, join, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import installPiVim from "../index.js"; +import type { stubKeybindings } from "../test/harness.js"; import { createExtensionApiHarness, stubTheme, stubTui, } from "../test/harness.js"; -import type { stubKeybindings } from "../test/harness.js"; type RuntimeEditorFactory = ( tui: typeof stubTui, @@ -42,7 +42,11 @@ type RuntimeContext = { isIdle(): boolean; ui: { theme: typeof stubTheme; - setWidget(key: string, content: string[] | undefined, options?: { placement?: string }): void; + setWidget( + key: string, + content: string[] | undefined, + options?: { placement?: string }, + ): void; setEditorComponent(factory: RuntimeEditorFactory | undefined): void; getEditorComponent(): RuntimeEditorFactory | undefined; notify(message: string, type: string): void; @@ -178,15 +182,25 @@ function readPackageName(packageJsonPath: string): string | null { function hasPackageName(packageDir: string, expectedName: string): boolean { const packageJsonPath = join(packageDir, "package.json"); - return existsSync(packageJsonPath) && readPackageName(packageJsonPath) === expectedName; + return ( + existsSync(packageJsonPath) && + readPackageName(packageJsonPath) === expectedName + ); } -function findPackageRootInAncestorNodeModules(specifier: string): string | null { +function findPackageRootInAncestorNodeModules( + specifier: string, +): string | null { let dir = projectRoot; while (true) { - const nodeModulesCandidate = join(dir, "node_modules", ...specifier.split("/")); - if (hasPackageName(nodeModulesCandidate, specifier)) return nodeModulesCandidate; + const nodeModulesCandidate = join( + dir, + "node_modules", + ...specifier.split("/"), + ); + if (hasPackageName(nodeModulesCandidate, specifier)) + return nodeModulesCandidate; const parent = dirname(dir); if (parent === dir) break; @@ -197,7 +211,8 @@ function findPackageRootInAncestorNodeModules(specifier: string): string | null } function findPackageRoot(specifier: string): string { - const ancestorNodeModulesPackage = findPackageRootInAncestorNodeModules(specifier); + const ancestorNodeModulesPackage = + findPackageRootInAncestorNodeModules(specifier); if (ancestorNodeModulesPackage) return ancestorNodeModulesPackage; let dir: string; @@ -205,7 +220,9 @@ function findPackageRoot(specifier: string): string { dir = dirname(currentRequire.resolve(specifier)); } catch (error) { if (isRecord(error) && error.code === "MODULE_NOT_FOUND") { - throw new Error(`FAIL-INFRA: unable to locate installed package root for ${specifier}`); + throw new Error( + `FAIL-INFRA: unable to locate installed package root for ${specifier}`, + ); } throw new Error( `FAIL-INFRA: unable to resolve installed package root for ${specifier}: ${formatUnknownError(error)}`, @@ -215,7 +232,10 @@ function findPackageRoot(specifier: string): string { while (true) { const packageJsonPath = join(dir, "package.json"); - if (existsSync(packageJsonPath) && readPackageName(packageJsonPath) === specifier) { + if ( + existsSync(packageJsonPath) && + readPackageName(packageJsonPath) === specifier + ) { return dir; } @@ -224,25 +244,36 @@ function findPackageRoot(specifier: string): string { dir = parent; } - throw new Error(`FAIL-INFRA: unable to locate installed package root for ${specifier}`); + throw new Error( + `FAIL-INFRA: unable to locate installed package root for ${specifier}`, + ); } -function packLocalImageAttachments(packageDir: string, workspace: string): string { +function packLocalImageAttachments( + packageDir: string, + workspace: string, +): string { try { - const output = execFileSync("npm", ["pack", packageDir, "--pack-destination", workspace], { - cwd: workspace, - encoding: "utf8", - env: { - ...createNpmCommandEnv(), - npm_config_ignore_scripts: "true", + const output = execFileSync( + "npm", + ["pack", packageDir, "--pack-destination", workspace], + { + cwd: workspace, + encoding: "utf8", + env: { + ...createNpmCommandEnv(), + npm_config_ignore_scripts: "true", + }, + stdio: ["ignore", "pipe", "pipe"], }, - stdio: ["ignore", "pipe", "pipe"], - }).trim(); + ).trim(); const tarballName = output.split("\n").filter(Boolean).at(-1); if (!tarballName) throw new Error("npm pack did not report a tarball name"); return `file:${join(workspace, tarballName)}`; } catch (error) { - throw new Error(`FAIL-INFRA: unable to pack ${IMAGE_PACKAGE_NAME}: ${formatUnknownError(error)}`); + throw new Error( + `FAIL-INFRA: unable to pack ${IMAGE_PACKAGE_NAME}: ${formatUnknownError(error)}`, + ); } } @@ -293,7 +324,9 @@ function runNpmInstall(workspace: string): void { }); } catch (error) { const output = isRecord(error) - ? [error.stdout, error.stderr].filter((value): value is string => typeof value === "string").join("\n") + ? [error.stdout, error.stderr] + .filter((value): value is string => typeof value === "string") + .join("\n") : ""; throw new Error( `FAIL-INFRA: npm install --ignore-scripts failed${output ? `\n${output}` : ""}`, @@ -302,7 +335,9 @@ function runNpmInstall(workspace: string): void { } async function createWorkspace(): Promise { - const workspace = await mkdtemp(join(tmpdir(), "pi-vim-image-attachments-e2e-")); + const workspace = await mkdtemp( + join(tmpdir(), "pi-vim-image-attachments-e2e-"), + ); const packageJson = { private: true, type: "module", @@ -314,17 +349,22 @@ async function createWorkspace(): Promise { }, }; - await writeFile(join(workspace, "package.json"), `${JSON.stringify(packageJson, null, 2)}\n`); + await writeFile( + join(workspace, "package.json"), + `${JSON.stringify(packageJson, null, 2)}\n`, + ); runNpmInstall(workspace); await writeFile(join(workspace, "fixture.png"), PNG_BYTES); return workspace; } -async function importImageAttachmentsExtension(workspace: string): Promise { +async function importImageAttachmentsExtension( + workspace: string, +): Promise { try { const workspaceRequire = createRequire(join(workspace, "package.json")); const entry = workspaceRequire.resolve(`${IMAGE_PACKAGE_NAME}/index.ts`); - const module = await import(pathToFileURL(entry).href) as unknown; + const module = (await import(pathToFileURL(entry).href)) as unknown; if (!isRecord(module) || typeof module.default !== "function") { throw new Error(`${IMAGE_PACKAGE_NAME} default export is not a function`); @@ -332,7 +372,9 @@ async function importImageAttachmentsExtension(workspace: string): Promise): RuntimeHarness { +function createRuntimeHarness( + cwd: string, + pi: ReturnType, +): RuntimeHarness { let editorFactory: RuntimeEditorFactory | undefined; const widgetCalls: WidgetCall[] = []; const notifications: NotificationCall[] = []; @@ -361,7 +406,11 @@ function createRuntimeHarness(cwd: string, pi: ReturnType { typeText(editor, "Look "); editor.insertTextAtCursor(imagePath); - assertEqual(editor.getText(), "Look [Image #1] ", "text plus image draft text"); + assertEqual( + editor.getText(), + "Look [Image #1] ", + "text plus image draft text", + ); assertWidgetHasAttachment(harness, "text plus image submit"); const submittedText = editor.getExpandedText().trim(); editor.handleInput(SUBMIT_INPUT); - const results = await harness.pi.emit("input", { text: submittedText, images: [] }, harness.ctx); + const results = await harness.pi.emit( + "input", + { text: submittedText, images: [] }, + harness.ctx, + ); assertArrayLength(results, 1, "text plus image input hook result count"); const result = results[0]; assertTransformResult(result, "text plus image input hook result"); - assertEqual(result.text, "Look", "text plus image submit should strip placeholder"); - assertArrayLength(result.images, 1, "text plus image submit should include one image content item"); + assertEqual( + result.text, + "Look", + "text plus image submit should strip placeholder", + ); + assertArrayLength( + result.images, + 1, + "text plus image submit should include one image content item", + ); assertImageContent(result.images[0], "text plus image submit image"); assertWidgetCleared(harness, "text plus image submit"); } @@ -604,13 +724,25 @@ function assertImageOnlySubmit( assertImageAttachmentState(editor, harness, "image-only submit"); editor.handleInput(SUBMIT_INPUT); - assertArrayLength(harness.sentUserMessages, 1, "image-only submit should send one message"); + assertArrayLength( + harness.sentUserMessages, + 1, + "image-only submit should send one message", + ); const message = harness.sentUserMessages[0]; if (!message) fail("image-only submit should capture a message"); - assertArrayLength(message.content, 1, "image-only submit should send one image block"); + assertArrayLength( + message.content, + 1, + "image-only submit should send one image block", + ); assertImageContent(message.content[0], "image-only submit image"); - assertEqual(editor.getText(), "", "image-only submit should clear editor text"); + assertEqual( + editor.getText(), + "", + "image-only submit should clear editor text", + ); assertWidgetCleared(harness, "image-only submit"); } @@ -626,47 +758,84 @@ function assertNormalDeletionClearsDraft( editor.handleInput(key); } - assertEqual(editor.getMode(), "normal", "normal deletion should leave editor in NORMAL mode"); - assertEqual(editor.getText(), "", "normal deletion should remove the placeholder text"); + assertEqual( + editor.getMode(), + "normal", + "normal deletion should leave editor in NORMAL mode", + ); + assertEqual( + editor.getText(), + "", + "normal deletion should remove the placeholder text", + ); assertWidgetCleared(harness, "normal deletion"); } -async function verifySupportedOrder(workspace: string, imageExtension: PiExtension): Promise { +async function verifySupportedOrder( + workspace: string, + imageExtension: PiExtension, +): Promise { const imagePath = join(workspace, "fixture.png"); try { - const surfaceHarness = await installSupportedOrder(workspace, imageExtension); + const surfaceHarness = await installSupportedOrder( + workspace, + imageExtension, + ); assertPiVimSurfaceForLaterDecorator(mountEditor(surfaceHarness)); const modalHarness = await installSupportedOrder(workspace, imageExtension); assertPiVimModalBehavior(mountEditor(modalHarness)); - const directImageHarness = await installSupportedOrder(workspace, imageExtension); + const directImageHarness = await installSupportedOrder( + workspace, + imageExtension, + ); assertImageAttachmentInsertedByDirectInsert( mountEditor(directImageHarness), directImageHarness, imagePath, ); - const bracketedImageHarness = await installSupportedOrder(workspace, imageExtension); + const bracketedImageHarness = await installSupportedOrder( + workspace, + imageExtension, + ); assertImageAttachmentInsertedByBracketedPaste( mountEditor(bracketedImageHarness), bracketedImageHarness, imagePath, ); - const textAndImageHarness = await installSupportedOrder(workspace, imageExtension); + const textAndImageHarness = await installSupportedOrder( + workspace, + imageExtension, + ); await assertTextAndImageSubmit( textAndImageHarness, mountEditor(textAndImageHarness), imagePath, ); - const imageOnlyHarness = await installSupportedOrder(workspace, imageExtension); - assertImageOnlySubmit(imageOnlyHarness, mountEditor(imageOnlyHarness), imagePath); + const imageOnlyHarness = await installSupportedOrder( + workspace, + imageExtension, + ); + assertImageOnlySubmit( + imageOnlyHarness, + mountEditor(imageOnlyHarness), + imagePath, + ); - const deletionHarness = await installSupportedOrder(workspace, imageExtension); - assertNormalDeletionClearsDraft(deletionHarness, mountEditor(deletionHarness), imagePath); + const deletionHarness = await installSupportedOrder( + workspace, + imageExtension, + ); + assertNormalDeletionClearsDraft( + deletionHarness, + mountEditor(deletionHarness), + imagePath, + ); } catch (error) { const message = formatUnknownError(error); if (message.startsWith("cross-package blocker:")) throw error; diff --git a/script/pack-check.ts b/script/pack-check.ts index dd962e09..b36d8bac 100644 --- a/script/pack-check.ts +++ b/script/pack-check.ts @@ -33,6 +33,7 @@ const REQUIRED_FILES = [ "package.json", "index.ts", "motions.ts", + "settings.ts", "types.ts", "word-boundary-cache.ts", ] as const; @@ -48,7 +49,10 @@ const FORBIDDEN_GLOBS = [ "**/report*.md", ] as const; -const FORBIDDEN_REGEX_BY_GLOB: Record<(typeof FORBIDDEN_GLOBS)[number], RegExp> = { +const FORBIDDEN_REGEX_BY_GLOB: Record< + (typeof FORBIDDEN_GLOBS)[number], + RegExp +> = { "doc/**": /^doc\//, "test/**": /^test\//, ".pi/**": /^\.pi\//, @@ -61,10 +65,10 @@ const FORBIDDEN_REGEX_BY_GLOB: Record<(typeof FORBIDDEN_GLOBS)[number], RegExp> const THRESHOLDS = { maxFiles: 12, - // WORD/delimited text objects add a packaged resolver module plus README surface. + // WORD/delimited text objects plus mode-color settings add package surface. // Keep budgets tight enough to catch accidental docs/tests in the package. - maxSize: 31000, - maxUnpackedSize: 136000, + maxSize: 31400, + maxUnpackedSize: 139500, } as const; function compareStrings(a: string, b: string): number { @@ -95,11 +99,15 @@ function runPackDryRun(): PackResult { try { parsed = JSON.parse(rawOutput); } catch (error) { - throw new Error(`Failed to parse npm pack JSON output: ${formatError(error)}`); + throw new Error( + `Failed to parse npm pack JSON output: ${formatError(error)}`, + ); } if (!Array.isArray(parsed) || parsed.length === 0) { - throw new Error("npm pack --dry-run --json returned an unexpected JSON shape (expected non-empty array)"); + throw new Error( + "npm pack --dry-run --json returned an unexpected JSON shape (expected non-empty array)", + ); } const firstResult = parsed[0]; @@ -112,20 +120,32 @@ function runPackDryRun(): PackResult { const unpackedSize = firstResult.unpackedSize; if (!Array.isArray(files)) { - throw new Error("npm pack --dry-run --json is missing required field: files[]"); + throw new Error( + "npm pack --dry-run --json is missing required field: files[]", + ); } if (typeof size !== "number" || !Number.isFinite(size)) { - throw new Error("npm pack --dry-run --json is missing required numeric field: size"); + throw new Error( + "npm pack --dry-run --json is missing required numeric field: size", + ); } if (typeof unpackedSize !== "number" || !Number.isFinite(unpackedSize)) { - throw new Error("npm pack --dry-run --json is missing required numeric field: unpackedSize"); + throw new Error( + "npm pack --dry-run --json is missing required numeric field: unpackedSize", + ); } const packFiles = files.map((entry, index) => { - if (!isObject(entry) || typeof entry.path !== "string" || entry.path.length === 0) { - throw new Error(`npm pack --dry-run --json files[${index}] is missing string field: path`); + if ( + !isObject(entry) || + typeof entry.path !== "string" || + entry.path.length === 0 + ) { + throw new Error( + `npm pack --dry-run --json files[${index}] is missing string field: path`, + ); } return { path: entry.path } satisfies PackFile; @@ -149,11 +169,15 @@ function normalizePath(pathValue: string): string { const normalized = posix.normalize(withoutLeadingDot); if (normalized.length === 0 || normalized === ".") { - throw new Error(`Invalid empty pack path after normalization: ${pathValue}`); + throw new Error( + `Invalid empty pack path after normalization: ${pathValue}`, + ); } if (posix.isAbsolute(normalized)) { - throw new Error(`Pack path must be relative, got absolute path: ${pathValue}`); + throw new Error( + `Pack path must be relative, got absolute path: ${pathValue}`, + ); } if (normalized === ".." || normalized.startsWith("../")) { @@ -164,25 +188,24 @@ function normalizePath(pathValue: string): string { } function normalizePaths(files: PackFile[]): string[] { - return files - .map((file) => normalizePath(file.path)) - .sort(compareStrings); + return files.map((file) => normalizePath(file.path)).sort(compareStrings); } function checkRequired(paths: string[]): string[] { const pathSet = new Set(paths); - return REQUIRED_FILES - .filter((requiredPath) => !pathSet.has(requiredPath)) - .sort(compareStrings); + return REQUIRED_FILES.filter( + (requiredPath) => !pathSet.has(requiredPath), + ).sort(compareStrings); } function matchForbidden(paths: string[]): ForbiddenMatch[] { const matches: ForbiddenMatch[] = []; for (const path of paths) { - const globs = FORBIDDEN_GLOBS - .filter((glob) => FORBIDDEN_REGEX_BY_GLOB[glob].test(path)); + const globs = FORBIDDEN_GLOBS.filter((glob) => + FORBIDDEN_REGEX_BY_GLOB[glob].test(path), + ); if (globs.length > 0) { matches.push({ path, globs }); @@ -196,7 +219,9 @@ function checkThresholds(result: PackResult): string[] { const violations: string[] = []; if (result.files.length > THRESHOLDS.maxFiles) { - violations.push(`files.length ${result.files.length} > ${THRESHOLDS.maxFiles}`); + violations.push( + `files.length ${result.files.length} > ${THRESHOLDS.maxFiles}`, + ); } if (result.size > THRESHOLDS.maxSize) { @@ -204,7 +229,9 @@ function checkThresholds(result: PackResult): string[] { } if (result.unpackedSize > THRESHOLDS.maxUnpackedSize) { - violations.push(`unpackedSize ${result.unpackedSize} > ${THRESHOLDS.maxUnpackedSize}`); + violations.push( + `unpackedSize ${result.unpackedSize} > ${THRESHOLDS.maxUnpackedSize}`, + ); } return violations; @@ -223,12 +250,16 @@ function checkDeterminism(): DeterminismResult { const secondPaths = normalizePaths(secondRun.files); const sameLength = firstPaths.length === secondPaths.length; - const sameEntries = sameLength && firstPaths.every((path, index) => path === secondPaths[index]); + const sameEntries = + sameLength && + firstPaths.every((path, index) => path === secondPaths[index]); if (sameEntries) { return { passed: true, - details: [`Stable file set across two consecutive dry-runs (${firstPaths.length} files)`], + details: [ + `Stable file set across two consecutive dry-runs (${firstPaths.length} files)`, + ], }; } @@ -251,7 +282,11 @@ function checkDeterminism(): DeterminismResult { }; } -function printSummary(result: PackResult, paths: string[], summaries: CheckSummary[]): void { +function printSummary( + result: PackResult, + paths: string[], + summaries: CheckSummary[], +): void { console.log("pack:check summary"); console.log(`- files: ${paths.length}`); console.log(`- size: ${result.size} bytes`); @@ -288,31 +323,36 @@ function main(): void { summaries.push({ name: "required files", passed: missingRequired.length === 0, - details: missingRequired.length === 0 - ? [`All required files present (${REQUIRED_FILES.length})`] - : missingRequired.map((path) => `Missing required file: ${path}`), + details: + missingRequired.length === 0 + ? [`All required files present (${REQUIRED_FILES.length})`] + : missingRequired.map((path) => `Missing required file: ${path}`), }); const forbiddenMatches = matchForbidden(normalizedPaths); summaries.push({ name: "forbidden globs", passed: forbiddenMatches.length === 0, - details: forbiddenMatches.length === 0 - ? ["No forbidden file paths matched"] - : forbiddenMatches.map((match) => `${match.path} matches ${match.globs.join(", ")}`), + details: + forbiddenMatches.length === 0 + ? ["No forbidden file paths matched"] + : forbiddenMatches.map( + (match) => `${match.path} matches ${match.globs.join(", ")}`, + ), }); const thresholdViolations = checkThresholds(packResult); summaries.push({ name: "size thresholds", passed: thresholdViolations.length === 0, - details: thresholdViolations.length === 0 - ? [ - `files.length ${packResult.files.length} <= ${THRESHOLDS.maxFiles}`, - `size ${packResult.size} <= ${THRESHOLDS.maxSize}`, - `unpackedSize ${packResult.unpackedSize} <= ${THRESHOLDS.maxUnpackedSize}`, - ] - : thresholdViolations, + details: + thresholdViolations.length === 0 + ? [ + `files.length ${packResult.files.length} <= ${THRESHOLDS.maxFiles}`, + `size ${packResult.size} <= ${THRESHOLDS.maxSize}`, + `unpackedSize ${packResult.unpackedSize} <= ${THRESHOLDS.maxUnpackedSize}`, + ] + : thresholdViolations, }); printSummary(packResult, normalizedPaths, summaries); @@ -320,7 +360,9 @@ function main(): void { const failedChecks = summaries.filter((summary) => !summary.passed); if (failedChecks.length > 0) { - console.error(`pack:check failed (${failedChecks.length} check${failedChecks.length === 1 ? "" : "s"})`); + console.error( + `pack:check failed (${failedChecks.length} check${failedChecks.length === 1 ? "" : "s"})`, + ); process.exit(1); } diff --git a/script/perf-bench.ts b/script/perf-bench.ts index bff23d0b..75d17c8d 100644 --- a/script/perf-bench.ts +++ b/script/perf-bench.ts @@ -1,6 +1,6 @@ import { spawnSync } from "node:child_process"; -import { performance } from "node:perf_hooks"; import path from "node:path"; +import { performance } from "node:perf_hooks"; import { pathToFileURL } from "node:url"; import { ModalEditor } from "../index.js"; @@ -39,7 +39,10 @@ const stubKeybindings = { function percentile(sorted: number[], p: number): number { if (sorted.length === 0) return 0; - const idx = Math.min(sorted.length - 1, Math.max(0, Math.floor(p * (sorted.length - 1)))); + const idx = Math.min( + sorted.length - 1, + Math.max(0, Math.floor(p * (sorted.length - 1))), + ); return sorted[idx] ?? 0; } @@ -53,7 +56,10 @@ function toStats(samples: number[]): Stats { }; } -function runNodeEval(code: string, extraArgs: string[] = []): { stdout: string; stderr: string } { +function runNodeEval( + code: string, + extraArgs: string[] = [], +): { stdout: string; stderr: string } { const result = spawnSync( process.execPath, ["--import", "tsx/esm", ...extraArgs, "-e", code], @@ -69,7 +75,9 @@ function runNodeEval(code: string, extraArgs: string[] = []): { stdout: string; [ `node child process failed (status=${result.status})`, result.stderr?.trim() ?? "", - ].filter(Boolean).join("\n"), + ] + .filter(Boolean) + .join("\n"), ); } @@ -267,7 +275,10 @@ function runResponsivenessBenchmarks(): Record { samplesCount, ); - const verticalLinesText = Array.from({ length: 320 }, (_, i) => `line_${i}`).join("\n"); + const verticalLinesText = Array.from( + { length: 320 }, + (_, i) => `line_${i}`, + ).join("\n"); metrics["200j"] = benchmarkSingleOpWithReset( () => createEditor(verticalLinesText), () => {}, @@ -374,16 +385,30 @@ function summarizeForTextOutput(data: { const lines: string[] = []; lines.push("startup (median)"); - lines.push(` runtime_only: ${data.startup.runtime_only.stats.median.toFixed(2)} ms`); - lines.push(` host_import: ${data.startup.host_import.stats.median.toFixed(2)} ms`); - lines.push(` extension_import: ${data.startup.extension_import.stats.median.toFixed(2)} ms`); - lines.push(` incremental_extension: ${data.startupIncrementalMs.toFixed(2)} ms`); + lines.push( + ` runtime_only: ${data.startup.runtime_only.stats.median.toFixed(2)} ms`, + ); + lines.push( + ` host_import: ${data.startup.host_import.stats.median.toFixed(2)} ms`, + ); + lines.push( + ` extension_import: ${data.startup.extension_import.stats.median.toFixed(2)} ms`, + ); + lines.push( + ` incremental_extension: ${data.startupIncrementalMs.toFixed(2)} ms`, + ); lines.push(""); lines.push("memory (median heapUsed)"); - lines.push(` host_import: ${Math.round(data.memory.host_import.stats.median).toLocaleString()} bytes`); - lines.push(` extension_import: ${Math.round(data.memory.extension_import.stats.median).toLocaleString()} bytes`); - lines.push(` incremental_extension: ${Math.round(data.memoryIncrementalBytes).toLocaleString()} bytes`); + lines.push( + ` host_import: ${Math.round(data.memory.host_import.stats.median).toLocaleString()} bytes`, + ); + lines.push( + ` extension_import: ${Math.round(data.memory.extension_import.stats.median).toLocaleString()} bytes`, + ); + lines.push( + ` incremental_extension: ${Math.round(data.memoryIncrementalBytes).toLocaleString()} bytes`, + ); lines.push(""); lines.push("responsiveness (median us/op)"); @@ -400,12 +425,20 @@ function main(): void { const startupRuns = 7; const memoryRuns = 5; - const extensionImport = pathToFileURL(path.resolve(repoRoot, "index.ts")).href; + const extensionImport = pathToFileURL( + path.resolve(repoRoot, "index.ts"), + ).href; const startup = { runtime_only: measureStartup("", startupRuns), - host_import: measureStartup(`await import('@mariozechner/pi-coding-agent');`, startupRuns), - extension_import: measureStartup(`await import(${JSON.stringify(extensionImport)});`, startupRuns), + host_import: measureStartup( + `await import('@mariozechner/pi-coding-agent');`, + startupRuns, + ), + extension_import: measureStartup( + `await import(${JSON.stringify(extensionImport)});`, + startupRuns, + ), }; const memory = { @@ -415,8 +448,10 @@ function main(): void { const responsiveness = runResponsivenessBenchmarks(); - const startupIncrementalMs = startup.extension_import.stats.median - startup.host_import.stats.median; - const memoryIncrementalBytes = memory.extension_import.stats.median - memory.host_import.stats.median; + const startupIncrementalMs = + startup.extension_import.stats.median - startup.host_import.stats.median; + const memoryIncrementalBytes = + memory.extension_import.stats.median - memory.host_import.stats.median; const payload = { generatedAt: new Date().toISOString(), diff --git a/settings.ts b/settings.ts new file mode 100644 index 00000000..2575f7b3 --- /dev/null +++ b/settings.ts @@ -0,0 +1,92 @@ +import { SettingsManager } from "@mariozechner/pi-coding-agent"; + +export type ModeColorSettings = { + insert?: string; + normal?: string; + ex?: string; +}; + +export type PiVimSettings = { + clipboardMirror?: unknown; + modeColors?: ModeColorSettings; + syncBorderColorWithMode?: boolean; +}; + +const M = Symbol(), + C = ["insert", "normal", "ex"] as const, + T = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/; +const rec = (v: unknown): v is Record => + typeof v === "object" && v !== null && !Array.isArray(v); + +function get(s: unknown, k: keyof PiVimSettings): unknown { + if (!rec(s) || !Object.hasOwn(s, "piVim")) return M; + const p = s.piVim; + if (!rec(p)) return p; + return Object.hasOwn(p, k) ? p[k] : M; +} + +function colors(v: unknown) { + if (!rec(v)) return; + const r: ModeColorSettings = {}; + for (const k of C) { + const x = v[k], + t = typeof x === "string" ? x.trim() : ""; + if (T.test(t)) r[k] = t; + } + return Object.keys(r)[0] ? r : undefined; +} + +export function readPiVimClipboardMirrorSetting(g: unknown, p: unknown) { + let v = get(p, "clipboardMirror"); + if (v !== M) return v; + v = get(g, "clipboardMirror"); + return v === M ? undefined : v; +} + +export function readPiVimModeColors(g: unknown, p: unknown) { + const v = get(p, "modeColors"); + // Project settings are a whole-setting override. If a project checks in an + // invalid modeColors value, fall back to pi-vim defaults instead of leaking a + // developer's global colors into that project. + if (v !== M) return colors(v); + const w = get(g, "modeColors"); + return colors(w); +} + +export function readPiVimBooleanSetting( + g: unknown, + p: unknown, + k: "syncBorderColorWithMode", +) { + const v = get(p, k); + if (v !== M) return typeof v === "boolean" ? v : undefined; + const w = get(g, k); + return typeof w === "boolean" ? w : undefined; +} + +function disk(cwd: string): PiVimSettings { + const s = SettingsManager.create(cwd), + g = s.getGlobalSettings(), + p = s.getProjectSettings(); + return { + clipboardMirror: readPiVimClipboardMirrorSetting(g, p), + modeColors: readPiVimModeColors(g, p), + syncBorderColorWithMode: readPiVimBooleanSetting( + g, + p, + "syncBorderColorWithMode", + ), + }; +} + +let reader = disk; +export function readPiVimSettings(cwd: string) { + return reader(cwd); +} +export function setPiVimSettingsReaderForTests(next: typeof disk) { + const prev = reader; + reader = next; + return () => { + reader = prev; + }; +} diff --git a/test/clipboard-policy.test.ts b/test/clipboard-policy.test.ts index 50181a54..494e8a16 100644 --- a/test/clipboard-policy.test.ts +++ b/test/clipboard-policy.test.ts @@ -1,9 +1,7 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; - import { DEFAULT_CLIPBOARD_MIRROR_POLICY, - readPiVimClipboardMirrorSetting, resolveClipboardMirrorPolicy, } from "../clipboard-policy.js"; @@ -17,12 +15,16 @@ describe("clipboard mirror policy resolver", () => { it("accepts all supported clipboard mirror policy values", () => { assert.deepEqual(resolveClipboardMirrorPolicy("all"), { policy: "all" }); assert.deepEqual(resolveClipboardMirrorPolicy("yank"), { policy: "yank" }); - assert.deepEqual(resolveClipboardMirrorPolicy("never"), { policy: "never" }); + assert.deepEqual(resolveClipboardMirrorPolicy("never"), { + policy: "never", + }); }); it("normalizes clipboard mirror policy casing and whitespace", () => { assert.deepEqual(resolveClipboardMirrorPolicy("YANK"), { policy: "yank" }); - assert.deepEqual(resolveClipboardMirrorPolicy(" never "), { policy: "never" }); + assert.deepEqual(resolveClipboardMirrorPolicy(" never "), { + policy: "never", + }); }); it("falls back to all and reports invalid clipboard mirror strings", () => { @@ -33,62 +35,20 @@ describe("clipboard mirror policy resolver", () => { assert.match(result.warning ?? "", /all, yank, never/); }); - it("falls back to all and reports non-string clipboard mirror values safely", () => { - const result = resolveClipboardMirrorPolicy({ mode: "yank" }); + it("escapes invalid clipboard mirror strings in warnings", () => { + const result = resolveClipboardMirrorPolicy("delete\n\x1b[31m"); assert.equal(result.policy, "all"); - assert.match(result.warning ?? "", /object/); - assert.match(result.warning ?? "", /all, yank, never/); - }); -}); - -describe("piVim clipboard mirror settings reader", () => { - it("returns undefined when global and project settings are missing", () => { - assert.equal(readPiVimClipboardMirrorSetting(undefined, undefined), undefined); - assert.equal(readPiVimClipboardMirrorSetting(null, null), undefined); - assert.equal(readPiVimClipboardMirrorSetting("bad", 42), undefined); + assert.equal((result.warning ?? "").includes("\n"), false); + assert.equal((result.warning ?? "").includes("\x1b"), false); + assert.match(result.warning ?? "", /"delete\\n\\u001b\[31m"/); }); - it("reads global piVim clipboardMirror when project setting is missing", () => { - assert.equal( - readPiVimClipboardMirrorSetting( - { piVim: { clipboardMirror: "yank" } }, - {}, - ), - "yank", - ); - }); - - it("lets project piVim clipboardMirror override global", () => { - assert.equal( - readPiVimClipboardMirrorSetting( - { piVim: { clipboardMirror: "never" } }, - { piVim: { clipboardMirror: "all" } }, - ), - "all", - ); - }); - - it("treats invalid project clipboardMirror as an override instead of falling back to global", () => { - assert.equal( - readPiVimClipboardMirrorSetting( - { piVim: { clipboardMirror: "yank" } }, - { piVim: { clipboardMirror: null } }, - ), - null, - ); - }); - - it("treats malformed project piVim settings as an override instead of falling back to global", () => { - const setting = readPiVimClipboardMirrorSetting( - { piVim: { clipboardMirror: "yank" } }, - { piVim: "bad" }, - ); - const result = resolveClipboardMirrorPolicy(setting); + it("falls back to all and reports non-string clipboard mirror values safely", () => { + const result = resolveClipboardMirrorPolicy({ mode: "yank" }); - assert.equal(setting, "bad"); assert.equal(result.policy, "all"); - assert.match(result.warning ?? "", /bad/); + assert.match(result.warning ?? "", /object/); assert.match(result.warning ?? "", /all, yank, never/); }); }); diff --git a/test/harness.ts b/test/harness.ts index e13a75a9..c190e7b4 100644 --- a/test/harness.ts +++ b/test/harness.ts @@ -32,11 +32,12 @@ export type CursorShapeTuiShape = { setShowHardwareCursor?: (show: boolean) => void; }; -export type CursorShapeTuiStub = ModalEditorConstructorArgs[0] & CursorShapeTuiShape & { - terminalWrites: string[]; - hardwareCursorValues: boolean[]; - getShowHardwareCursorCalls: number; -}; +export type CursorShapeTuiStub = ModalEditorConstructorArgs[0] & + CursorShapeTuiShape & { + terminalWrites: string[]; + hardwareCursorValues: boolean[]; + getShowHardwareCursorCalls: number; + }; export function createCursorShapeTui( options: CursorShapeTuiOptions = {}, @@ -98,7 +99,11 @@ export function createExtensionApiHarness(): ExtensionApiHarness { handlersFor(event: string): ExtensionHandlerStub[] { return [...(handlers.get(event) ?? [])]; }, - async emit(event: string, payload?: unknown, ctx?: unknown): Promise { + async emit( + event: string, + payload?: unknown, + ctx?: unknown, + ): Promise { const results: unknown[] = []; for (const handler of handlers.get(event) ?? []) { results.push(await handler(payload, ctx)); @@ -152,7 +157,9 @@ export function createEditorWithSpy(initialText: string): { editor.setClipboardFn((text) => clipboardWrites.push(text)); editor.setClipboardReadFn(() => null); - editor.setQuitFn(() => { quitCalls++; }); + editor.setQuitFn(() => { + quitCalls++; + }); editor.setNotifyFn((message) => notifications.push(message)); // Populate buffer in insert mode (editor starts in insert) @@ -167,7 +174,9 @@ export function createEditorWithSpy(initialText: string): { return { editor, clipboardWrites, - get quitCalls() { return quitCalls; }, + get quitCalls() { + return quitCalls; + }, notifications, }; } @@ -190,7 +199,9 @@ export function createMultiLineEditor(text: string): { const editor = new ModalEditor(stubTui, stubTheme, stubKeybindings); editor.setClipboardFn((t) => clipboardWrites.push(t)); editor.setClipboardReadFn(() => null); - editor.setQuitFn(() => { quitCalls++; }); + editor.setQuitFn(() => { + quitCalls++; + }); editor.setNotifyFn((message) => notifications.push(message)); // Type text in insert mode (newlines create new lines) @@ -218,7 +229,9 @@ export function createMultiLineEditor(text: string): { return { editor, clipboardWrites, - get quitCalls() { return quitCalls; }, + get quitCalls() { + return quitCalls; + }, notifications, }; } diff --git a/test/modal-editor.test.ts b/test/modal-editor.test.ts index 700cbaae..a7188c49 100644 --- a/test/modal-editor.test.ts +++ b/test/modal-editor.test.ts @@ -6,15 +6,18 @@ * blocks where state inspection requires nuance. */ +import assert from "node:assert/strict"; import { spawn } from "node:child_process"; import { readFile } from "node:fs/promises"; import { describe, it } from "node:test"; -import assert from "node:assert/strict"; import { CURSOR_MARKER, visibleWidth } from "@mariozechner/pi-tui"; -import type { WordMotionClass } from "../motions.js"; -import type { WordMotionDirection, WordMotionTarget } from "../word-boundary-cache.js"; import installPiVim, { ModalEditor } from "../index.js"; -import { setPiVimSettingsReaderForTests } from "../clipboard-policy.js"; +import type { WordMotionClass } from "../motions.js"; +import { setPiVimSettingsReaderForTests } from "../settings.js"; +import type { + WordMotionDirection, + WordMotionTarget, +} from "../word-boundary-cache.js"; import { createCursorShapeTui, createEditorWithSpy, @@ -59,16 +62,22 @@ type ModalEditorTestInternals = { pushUndoSnapshot?: (() => void) | undefined; }; -type FindWordTargetInTextArgs = Parameters; -type TryFindTargetArgs = Parameters; +type FindWordTargetInTextArgs = Parameters< + ModalEditorTestInternals["findWordTargetInText"] +>; +type TryFindTargetArgs = Parameters< + ModalEditorWordBoundaryCacheInternals["tryFindTarget"] +>; type EditorFactory = ( tui: ConstructorParameters[0], theme: ConstructorParameters[1], keybindings: ConstructorParameters[2], ) => ModalEditor; +type Theme = ConstructorParameters[1]; type NotificationCall = { message: string; type: string }; +type ThemeFgCall = { token: string; text: string }; function getRawEditor(editor: ModalEditor): ModalEditorTestInternals { return editor as unknown as ModalEditorTestInternals; @@ -136,21 +145,42 @@ type DecoratedCall = | { method: "handleInput"; data: string } | { method: "setText"; text: string }; -function assertWrapperFacingSurface(editor: ModalEditor): asserts editor is WrapperFacingEditor { +function assertWrapperFacingSurface( + editor: ModalEditor, +): asserts editor is WrapperFacingEditor { const candidate = editor as WrapperFacingEditor; for (const method of WRAPPER_FACING_METHODS) { - assert.equal(typeof candidate[method], "function", `${method} should be a function`); + assert.equal( + typeof candidate[method], + "function", + `${method} should be a function`, + ); } for (const field of WRAPPER_FACING_FIELDS) { assert.ok(field in candidate, `${field} should exist`); } - assert.ok(candidate.actionHandlers instanceof Map, "actionHandlers should be a Map"); - assert.equal(typeof candidate.focused, "boolean", "focused should be a boolean"); - assert.equal(typeof candidate.disableSubmit, "boolean", "disableSubmit should be a boolean"); - assert.equal(typeof candidate.borderColor, "function", "borderColor should be a function"); + assert.ok( + candidate.actionHandlers instanceof Map, + "actionHandlers should be a Map", + ); + assert.equal( + typeof candidate.focused, + "boolean", + "focused should be a boolean", + ); + assert.equal( + typeof candidate.disableSubmit, + "boolean", + "disableSubmit should be a boolean", + ); + assert.equal( + typeof candidate.borderColor, + "function", + "borderColor should be a function", + ); } function decorateLikeImageAttachments(editor: ModalEditor): DecoratedCall[] { @@ -192,7 +222,11 @@ function assertNoCursorShapeSequences(lines: string[]): void { } } -function setInternalCursor(editor: ModalEditor, cursorCol: number, cursorLine: number = 0): void { +function setInternalCursor( + editor: ModalEditor, + cursorCol: number, + cursorLine: number = 0, +): void { const internal = editor as unknown as { state?: { cursorLine?: number; cursorCol?: number }; preferredVisualCol?: number | null; @@ -221,7 +255,28 @@ type InstalledExtension = { readonly sessionEndHandlerCount: number; }; -async function installExtensionWithEditorFactory(): Promise { +function createRecordingTheme( + rejectedTokens: readonly string[] = [], +): Theme & { fgCalls: ThemeFgCall[] } { + const fgCalls: ThemeFgCall[] = []; + const rejected = new Set(rejectedTokens); + return { + borderColor: (s: string) => s, + fg: (token: string, text: string) => { + fgCalls.push({ token, text }); + if (rejected.has(token)) { + throw new Error(`unknown theme token: ${token}`); + } + return `<${token}>${text}`; + }, + bold: (s: string) => s, + fgCalls, + } as unknown as Theme & { fgCalls: ThemeFgCall[] }; +} + +async function installExtensionWithEditorFactory( + theme: Theme = stubTheme, +): Promise { const pi = createExtensionApiHarness(); let editorFactory: EditorFactory | null = null; let notificationCalls = 0; @@ -231,7 +286,7 @@ async function installExtensionWithEditorFactory(): Promise cwd: process.cwd(), hasUI: true, ui: { - theme: stubTheme, + theme, setEditorComponent(factory: EditorFactory): void { editorFactory = factory; }, @@ -302,7 +357,11 @@ function nextImmediate(): Promise { return new Promise((resolve) => setImmediate(resolve)); } -function withTimeout(promise: Promise, timeoutMs: number, message: string): Promise { +function withTimeout( + promise: Promise, + timeoutMs: number, + message: string, +): Promise { let timeoutId: ReturnType | undefined; const timeout = new Promise((_, reject) => { timeoutId = setTimeout(() => reject(new Error(message)), timeoutMs); @@ -324,9 +383,16 @@ type HelperRunResult = { const CLIPBOARD_HELPER_TEST_TIMEOUT_MS = 5_000; -async function getClipboardHelperSourceWithMock(mockModuleSource: string): Promise { - const indexSource = await readFile(new URL("../index.ts", import.meta.url), "utf8"); - const match = /const CLIPBOARD_HELPER_SOURCE = `([\s\S]*?)`;/.exec(indexSource); +async function getClipboardHelperSourceWithMock( + mockModuleSource: string, +): Promise { + const indexSource = await readFile( + new URL("../index.ts", import.meta.url), + "utf8", + ); + const match = /const CLIPBOARD_HELPER_SOURCE = `([\s\S]*?)`;/.exec( + indexSource, + ); assert.ok(match, "CLIPBOARD_HELPER_SOURCE not found"); assert.ok(match[1], "CLIPBOARD_HELPER_SOURCE was empty"); @@ -340,20 +406,46 @@ async function getClipboardHelperSourceWithMock(mockModuleSource: string): Promi const replacementImportLine = `import { copyToClipboard } from ${JSON.stringify(mockModuleUrl)};`; const helperSource = match[1]; - assert.equal(helperSource.includes(helperImportLine), true, "clipboard helper import not found"); - - const mockedSource = helperSource.replace(helperImportLine, replacementImportLine); - - assert.notEqual(mockedSource, helperSource, "clipboard helper import was not replaced"); - assert.equal(mockedSource.includes(helperImportLine), false, "real clipboard helper import remains"); - assert.equal(mockedSource.includes(replacementImportLine), true, "mock clipboard import missing"); + assert.equal( + helperSource.includes(helperImportLine), + true, + "clipboard helper import not found", + ); + + const mockedSource = helperSource.replace( + helperImportLine, + replacementImportLine, + ); + + assert.notEqual( + mockedSource, + helperSource, + "clipboard helper import was not replaced", + ); + assert.equal( + mockedSource.includes(helperImportLine), + false, + "real clipboard helper import remains", + ); + assert.equal( + mockedSource.includes(replacementImportLine), + true, + "mock clipboard import missing", + ); return mockedSource; } -async function getClipboardReadHelperSourceWithMock(mockClipboardExpression: string): Promise { - const indexSource = await readFile(new URL("../index.ts", import.meta.url), "utf8"); - const match = /const CLIPBOARD_READ_HELPER_SOURCE = `([\s\S]*?)`;/.exec(indexSource); +async function getClipboardReadHelperSourceWithMock( + mockClipboardExpression: string, +): Promise { + const indexSource = await readFile( + new URL("../index.ts", import.meta.url), + "utf8", + ); + const match = /const CLIPBOARD_READ_HELPER_SOURCE = `([\s\S]*?)`;/.exec( + indexSource, + ); assert.ok(match, "CLIPBOARD_READ_HELPER_SOURCE not found"); assert.ok(match[1], "CLIPBOARD_READ_HELPER_SOURCE was empty"); @@ -366,20 +458,42 @@ async function getClipboardReadHelperSourceWithMock(mockClipboardExpression: str const clipboardLine = 'const clipboard = require("@mariozechner/clipboard");'; const replacement = `const clipboard = ${mockClipboardExpression};`; const helperSource = match[1]; - const mockedSource = helperSource.replace(`${requireLine}\n${clipboardLine}`, replacement); - - assert.notEqual(mockedSource, helperSource, "clipboard read helper require was not replaced"); - assert.equal(mockedSource.includes(clipboardLine), false, "real clipboard read helper require remains"); - assert.equal(mockedSource.includes(replacement), true, "mock clipboard object missing"); + const mockedSource = helperSource.replace( + `${requireLine}\n${clipboardLine}`, + replacement, + ); + + assert.notEqual( + mockedSource, + helperSource, + "clipboard read helper require was not replaced", + ); + assert.equal( + mockedSource.includes(clipboardLine), + false, + "real clipboard read helper require remains", + ); + assert.equal( + mockedSource.includes(replacement), + true, + "mock clipboard object missing", + ); return mockedSource; } -function runClipboardHelperSource(source: string, input: string): Promise { +function runClipboardHelperSource( + source: string, + input: string, +): Promise { return new Promise((resolve, reject) => { - const child = spawn(process.execPath, ["--input-type=module", "-e", source], { - stdio: ["pipe", "pipe", "pipe"], - }); + const child = spawn( + process.execPath, + ["--input-type=module", "-e", source], + { + stdio: ["pipe", "pipe", "pipe"], + }, + ); const stdoutChunks: Buffer[] = []; const stderrChunks: Buffer[] = []; let settled = false; @@ -407,7 +521,11 @@ function runClipboardHelperSource(source: string, input: string): Promise { @@ -486,14 +604,34 @@ function assertRedoRoundTrip(options: { sendKeys(editor, keys); assert.equal(editor.getText(), expectedText, `text after [${keys.join("")}]`); - assert.deepEqual(editor.getCursor(), expectedCursor, `cursor after [${keys.join("")}]`); - assert.equal(editor.getRegister(), expectedRegister, `register after [${keys.join("")}]`); + assert.deepEqual( + editor.getCursor(), + expectedCursor, + `cursor after [${keys.join("")}]`, + ); + assert.equal( + editor.getRegister(), + expectedRegister, + `register after [${keys.join("")}]`, + ); sendKeys(editor, ["u", "\x12"]); - assert.equal(editor.getText(), expectedText, `redo text after [${keys.join("")}]`); - assert.deepEqual(editor.getCursor(), expectedCursor, `redo cursor after [${keys.join("")}]`); - assert.equal(editor.getRegister(), expectedRegister, `redo register after [${keys.join("")}]`); + assert.equal( + editor.getText(), + expectedText, + `redo text after [${keys.join("")}]`, + ); + assert.deepEqual( + editor.getCursor(), + expectedCursor, + `redo cursor after [${keys.join("")}]`, + ); + assert.equal( + editor.getRegister(), + expectedRegister, + `redo register after [${keys.join("")}]`, + ); } function makeGeneratedLineFixtures(count: number): string[] { @@ -507,7 +645,8 @@ function makeGeneratedLineFixtures(count: number): string[] { const punct = ["-", "--", "::", ".", ",", "!?", "#"]; const spaces = [" ", " ", " ", "\t"]; const fixtures = ["", " ", "---", "a", "a b", "foo--bar"]; - const pick = (values: readonly string[]): string => values[next() % values.length] ?? ""; + const pick = (values: readonly string[]): string => + values[next() % values.length] ?? ""; for (let i = 0; i < count; i++) { const parts: string[] = []; @@ -574,6 +713,37 @@ function createEditorAtBufferEnd(text: string): ModalEditor { return editor; } +function assertInsertBorderAfterModeChangingCommand( + fixtureText: string, + commandKeys: string[], +): void { + const editor = new ModalEditor(stubTui, stubTheme, stubKeybindings, { + borderColorizers: { + insert: (s: string) => `${s}`, + normal: (s: string) => `${s}`, + ex: (s: string) => `${s}`, + }, + }); + + for (const char of fixtureText) { + editor.handleInput(char); + } + editor.handleInput("\x1b"); + + sendKeys(editor, commandKeys); + + assert.equal( + editor.getMode(), + "insert", + `mode after [${commandKeys.join("")}]`, + ); + assert.equal( + editor.borderColor("x"), + "x", + `border after [${commandKeys.join("")}]`, + ); +} + // --------------------------------------------------------------------------- // Wrapper-facing editor surface // --------------------------------------------------------------------------- @@ -654,11 +824,16 @@ describe("mode transitions", () => { it("kitty ctrl+[ in normal mode forwards escape upward", () => { const { editor } = createEditorWithSpy("hello"); - const customEditorProto = Object.getPrototypeOf(Object.getPrototypeOf(editor)); + const customEditorProto = Object.getPrototypeOf( + Object.getPrototypeOf(editor), + ); const originalHandleInput = customEditorProto.handleInput; let forwardedEscapeCount = 0; - customEditorProto.handleInput = function (this: unknown, data: string): unknown { + customEditorProto.handleInput = function ( + this: unknown, + data: string, + ): unknown { if (data === "\x1b") forwardedEscapeCount++; return originalHandleInput.call(this, data); }; @@ -773,7 +948,7 @@ describe("ex mini-mode", () => { it("renders EX labels with the EX-specific colorizer", () => { const calls: string[] = []; - const editor = new ModalEditor(stubTui, stubTheme, stubKeybindings, { + const colorizers = { insert: (s: string) => { calls.push(`insert:${s}`); return `\x1b[32m${s}\x1b[39m`; @@ -786,6 +961,9 @@ describe("ex mini-mode", () => { calls.push(`ex:${s}`); return `\x1b[35m${s}\x1b[39m`; }, + }; + const editor = new ModalEditor(stubTui, stubTheme, stubKeybindings, { + labelColorizers: colorizers, }); editor.handleInput("\x1b"); @@ -807,7 +985,9 @@ describe("ex mini-mode", () => { assert.equal(session.editor.getMode(), "normal"); assert.equal(session.editor.getText(), "hello"); assert.deepEqual(session.editor.getCursor(), { line: 0, col: 0 }); - assert.deepEqual(session.notifications, ["Prompt is not empty; use :q! to quit anyway"]); + assert.deepEqual(session.notifications, [ + "Prompt is not empty; use :q! to quit anyway", + ]); }); it(":qa refuses to quit when prompt has non-whitespace text", () => { @@ -817,7 +997,9 @@ describe("ex mini-mode", () => { assert.equal(session.quitCalls, 0); assert.equal(session.editor.getText(), "hello"); - assert.deepEqual(session.notifications, ["Prompt is not empty; use :qa! to quit anyway"]); + assert.deepEqual(session.notifications, [ + "Prompt is not empty; use :qa! to quit anyway", + ]); }); it(":q requests quit when prompt is empty", () => { @@ -912,7 +1094,16 @@ describe("ex mini-mode", () => { it("split bracketed paste payload is accepted in ex mini-mode", () => { const session = createEditorWithSpy("hello"); - sendKeys(session.editor, [":", "\x1b[200~", "q", "a", "!", "\x1b", "[201~", "\r"]); + sendKeys(session.editor, [ + ":", + "\x1b[200~", + "q", + "a", + "!", + "\x1b", + "[201~", + "\r", + ]); assert.equal(session.quitCalls, 1); assert.equal(session.editor.getMode(), "normal"); @@ -933,11 +1124,16 @@ describe("ex mini-mode", () => { it("newline submit in split bracketed paste discards the trailing paste marker", () => { const session = createEditorWithSpy("hello"); - const customEditorProto = Object.getPrototypeOf(Object.getPrototypeOf(session.editor)); + const customEditorProto = Object.getPrototypeOf( + Object.getPrototypeOf(session.editor), + ); const originalHandleInput = customEditorProto.handleInput; let forwardedEscapeCount = 0; - customEditorProto.handleInput = function (this: unknown, data: string): unknown { + customEditorProto.handleInput = function ( + this: unknown, + data: string, + ): unknown { if (data === "\x1b") forwardedEscapeCount++; return originalHandleInput.call(this, data); }; @@ -1004,11 +1200,17 @@ describe("ex mini-mode", () => { describe("clipboard mirror policy settings", () => { it("applies clipboardMirror=never from settings", async () => { - const restore = setPiVimSettingsReaderForTests(() => ({ clipboardMirror: "never" })); + const restore = setPiVimSettingsReaderForTests(() => ({ + clipboardMirror: "never", + })); try { const extension = await installExtensionWithEditorFactory(); - const editor = extension.editorFactory(stubTui, stubTheme, stubKeybindings); + const editor = extension.editorFactory( + stubTui, + stubTheme, + stubKeybindings, + ); assert.equal(editor.getClipboardMirrorPolicy(), "never"); assert.equal(extension.notificationCalls, 0); @@ -1018,11 +1220,17 @@ describe("clipboard mirror policy settings", () => { }); it("falls back to all and warns for invalid clipboardMirror", async () => { - const restore = setPiVimSettingsReaderForTests(() => ({ clipboardMirror: "delete" })); + const restore = setPiVimSettingsReaderForTests(() => ({ + clipboardMirror: "delete", + })); try { const extension = await installExtensionWithEditorFactory(); - const editor = extension.editorFactory(stubTui, stubTheme, stubKeybindings); + const editor = extension.editorFactory( + stubTui, + stubTheme, + stubKeybindings, + ); assert.equal(editor.getClipboardMirrorPolicy(), "all"); assert.equal(extension.notificationCalls, 1); @@ -1039,6 +1247,279 @@ describe("clipboard mirror policy settings", () => { }); }); +describe("mode color settings", () => { + const reverseInsertLabel = "\x1b[7m INSERT \x1b[27m"; + + it("mode label uses default insert, normal, and EX mode color tokens", async () => { + const theme = createRecordingTheme(); + const restore = setPiVimSettingsReaderForTests(() => ({})); + + try { + const extension = await installExtensionWithEditorFactory(theme); + const editor = extension.editorFactory( + stubTui, + stubTheme, + stubKeybindings, + ); + + editor.render(80); + sendKeys(editor, ["\x1b"]); + editor.render(80); + sendKeys(editor, [":"]); + editor.render(80); + + assert.deepEqual( + theme.fgCalls.map((call) => call.token), + ["borderMuted", "borderAccent", "warning"], + ); + } finally { + restore(); + } + }); + + it("mode label uses a custom insert mode color token", async () => { + const theme = createRecordingTheme(); + const restore = setPiVimSettingsReaderForTests(() => ({ + modeColors: { insert: "primary" }, + })); + + try { + const extension = await installExtensionWithEditorFactory(theme); + const editor = extension.editorFactory( + stubTui, + stubTheme, + stubKeybindings, + ); + + editor.render(80); + + assert.deepEqual(theme.fgCalls, [ + { token: "primary", text: reverseInsertLabel }, + ]); + } finally { + restore(); + } + }); + + it("mode label partial mode color overrides preserve default tokens", async () => { + const theme = createRecordingTheme(); + const restore = setPiVimSettingsReaderForTests(() => ({ + modeColors: { insert: "primary" }, + })); + + try { + const extension = await installExtensionWithEditorFactory(theme); + const editor = extension.editorFactory( + stubTui, + stubTheme, + stubKeybindings, + ); + + editor.render(80); + sendKeys(editor, ["\x1b"]); + editor.render(80); + sendKeys(editor, [":"]); + editor.render(80); + + assert.deepEqual( + theme.fgCalls.map((call) => call.token), + ["primary", "borderAccent", "warning"], + ); + } finally { + restore(); + } + }); + + it("mode label falls back when the EX mode color token is unknown", async () => { + const theme = createRecordingTheme(["unknownToken"]); + const restore = setPiVimSettingsReaderForTests(() => ({ + modeColors: { ex: "unknownToken" }, + })); + + try { + const extension = await installExtensionWithEditorFactory(theme); + const editor = extension.editorFactory( + stubTui, + stubTheme, + stubKeybindings, + ); + + sendKeys(editor, ["\x1b", ":"]); + + assert.doesNotThrow(() => editor.render(80)); + assert.deepEqual( + theme.fgCalls.map((call) => call.token), + ["unknownToken", "warning"], + ); + } finally { + restore(); + } + }); + + it("mode label passes reverse-video text to theme.fg", async () => { + const theme = createRecordingTheme(); + const restore = setPiVimSettingsReaderForTests(() => ({})); + + try { + const extension = await installExtensionWithEditorFactory(theme); + const editor = extension.editorFactory( + stubTui, + stubTheme, + stubKeybindings, + ); + + editor.render(80); + + assert.deepEqual(theme.fgCalls, [ + { token: "borderMuted", text: reverseInsertLabel }, + ]); + } finally { + restore(); + } + }); + + for (const [name, settings] of [ + ["absent", {}], + ["false", { syncBorderColorWithMode: false }], + ] as const) { + it(`syncBorderColorWithMode ${name} keeps the original border color reference`, async () => { + const theme = createRecordingTheme(); + const restore = setPiVimSettingsReaderForTests(() => settings); + + try { + const extension = await installExtensionWithEditorFactory(theme); + const editor = extension.editorFactory( + stubTui, + stubTheme, + stubKeybindings, + ); + const originalBorderColor = editor.borderColor; + + sendKeys(editor, ["\x1b", ":", "\x1b", "i"]); + + assert.equal(editor.borderColor, originalBorderColor); + } finally { + restore(); + } + }); + } + + it("syncBorderColorWithMode true syncs border color across core transitions", async () => { + const theme = createRecordingTheme(); + const restore = setPiVimSettingsReaderForTests(() => ({ + modeColors: { + insert: "insertToken", + normal: "normalToken", + ex: "exToken", + }, + syncBorderColorWithMode: true, + })); + + try { + const extension = await installExtensionWithEditorFactory(theme); + const editor = extension.editorFactory( + stubTui, + stubTheme, + stubKeybindings, + ); + const originalBorderColor = editor.borderColor; + + assert.equal( + editor.borderColor("border"), + "border", + ); + + sendKeys(editor, ["\x1b"]); + assert.equal( + editor.borderColor("border"), + "border", + ); + + sendKeys(editor, [":"]); + assert.equal(editor.borderColor("border"), "border"); + + sendKeys(editor, ["\x1b"]); + assert.equal( + editor.borderColor("border"), + "border", + ); + + sendKeys(editor, ["i"]); + assert.equal( + editor.borderColor("border"), + "border", + ); + assert.equal(editor.borderColor, originalBorderColor); + } finally { + restore(); + } + }); + + it("syncBorderColorWithMode true survives Pi host borderColor assignment", async () => { + const theme = createRecordingTheme(); + const restore = setPiVimSettingsReaderForTests(() => ({ + modeColors: { + insert: "insertToken", + normal: "normalToken", + ex: "exToken", + }, + syncBorderColorWithMode: true, + })); + + try { + const extension = await installExtensionWithEditorFactory(theme); + const editor = extension.editorFactory( + stubTui, + stubTheme, + stubKeybindings, + ); + const defaultEditorBorderColor = (text: string) => + `${text}`; + + // Pi's InteractiveMode.setCustomEditorComponent copies the default + // editor's borderColor onto the extension editor after the factory + // returns. The mode-aware border hook must survive that assignment. + editor.borderColor = defaultEditorBorderColor; + assert.equal( + editor.borderColor("border"), + "border", + ); + + sendKeys(editor, ["\x1b"]); + assert.equal( + editor.borderColor("border"), + "border", + ); + + sendKeys(editor, [":"]); + assert.equal(editor.borderColor("border"), "border"); + } finally { + restore(); + } + }); + + for (const [name, commandKeys] of [ + ["i", ["i"]], + ["a", ["a"]], + ["A", ["A"]], + ["I", ["I"]], + ["o", ["o"]], + ["O", ["O"]], + ["C", ["C"]], + ["S", ["S"]], + ["s", ["s"]], + ["cc", ["c", "c"]], + ["cw", ["c", "w"]], + ["ct space", ["c", "t", " "]], + ] as const) { + it(`border updates for mode-changing commands: ${name}`, () => { + assertInsertBorderAfterModeChangingCommand("alpha beta", [ + ...commandKeys, + ]); + }); + } +}); + describe("cursor shape lifecycle", () => { it("registers cleanup on session_shutdown and not session_end", async () => { const extension = await installExtensionWithEditorFactory(); @@ -1055,7 +1536,10 @@ describe("cursor shape lifecycle", () => { const originalSetShowHardwareCursor = tui.setShowHardwareCursor; assert.ok(originalWrite, "expected terminal.write test stub"); - assert.ok(originalSetShowHardwareCursor, "expected setShowHardwareCursor test stub"); + assert.ok( + originalSetShowHardwareCursor, + "expected setShowHardwareCursor test stub", + ); tui.terminal.write = (data: string) => { operations.push(`write:${data}`); @@ -1092,7 +1576,10 @@ describe("cursor shape lifecycle", () => { const originalSetShowHardwareCursor = tui.setShowHardwareCursor; assert.ok(originalWrite, "expected terminal.write test stub"); - assert.ok(originalSetShowHardwareCursor, "expected setShowHardwareCursor test stub"); + assert.ok( + originalSetShowHardwareCursor, + "expected setShowHardwareCursor test stub", + ); tui.terminal.write = (data: string) => { operations.push(`write:${data}`); @@ -1264,8 +1751,14 @@ describe("cursor shape rendering", () => { const lines = editor.render(20); - assert.equal(lines.some((line) => line.includes(CURSOR_MARKER)), false); - assert.equal(lines.some((line) => line.includes(SOFTWARE_CURSOR_SPACE)), true); + assert.equal( + lines.some((line) => line.includes(CURSOR_MARKER)), + false, + ); + assert.equal( + lines.some((line) => line.includes(SOFTWARE_CURSOR_SPACE)), + true, + ); assert.deepEqual(tui.terminalWrites, []); assertNoCursorShapeSequences(lines); }); @@ -1311,12 +1804,14 @@ describe("delete operator — dw / de / db / d$ / d0 / dd", () => { }); it("clipboard helper treats Pi copyToClipboard throws as best-effort", async () => { - const helperSource = await getClipboardHelperSourceWithMock([ - "export function copyToClipboard(text) {", - " process.stdout.write(\"copy:\" + text);", - " throw new Error(\"clipboard backend failed\");", - "}", - ].join("\n")); + const helperSource = await getClipboardHelperSourceWithMock( + [ + "export function copyToClipboard(text) {", + ' process.stdout.write("copy:" + text);', + ' throw new Error("clipboard backend failed");', + "}", + ].join("\n"), + ); const result = await runClipboardHelperSource(helperSource, "payload"); @@ -1326,12 +1821,14 @@ describe("delete operator — dw / de / db / d$ / d0 / dd", () => { }); it("clipboard read helper treats no text as an empty successful read", async () => { - const helperSource = await getClipboardReadHelperSourceWithMock([ - "{", - " async hasText() { return false; },", - " async getText() { throw new Error(\"No string found\"); },", - "}", - ].join("\n")); + const helperSource = await getClipboardReadHelperSourceWithMock( + [ + "{", + " async hasText() { return false; },", + ' async getText() { throw new Error("No string found"); },', + "}", + ].join("\n"), + ); const result = await runClipboardHelperSource(helperSource, ""); @@ -1347,9 +1844,13 @@ describe("delete operator — dw / de / db / d$ / d0 / dd", () => { editor.setClipboardFn(async (text, signal) => { events.push(`start:${text}`); - signal?.addEventListener("abort", () => { - events.push(`abort:${text}`); - }, { once: true }); + signal?.addEventListener( + "abort", + () => { + events.push(`abort:${text}`); + }, + { once: true }, + ); if (text === "foo ") { await activeWrite.promise; @@ -1377,9 +1878,13 @@ describe("delete operator — dw / de / db / d$ / d0 / dd", () => { editor.setClipboardFn(async (text, signal) => { events.push(`start:${text}`); - signal?.addEventListener("abort", () => { - events.push(`abort:${text}`); - }, { once: true }); + signal?.addEventListener( + "abort", + () => { + events.push(`abort:${text}`); + }, + { once: true }, + ); if (text === "foo ") { await firstWrite.promise; @@ -1411,26 +1916,34 @@ describe("delete operator — dw / de / db / d$ / d0 / dd", () => { const events: string[] = []; editor.setClipboardWriteTimeoutMs(5); - editor.setClipboardFn((text, signal) => new Promise((resolve, reject) => { - events.push(`start:${text}`); - signal?.addEventListener("abort", () => { - const reason = signal.reason instanceof Error - ? signal.reason.message - : String(signal.reason); - events.push(`abort:${text}:${reason}`); - reject(signal.reason ?? new Error("clipboard aborted")); - }, { once: true }); - - if (text === "foo ") { - return; - } - - events.push(`end:${text}`); - if (text === "baz ") { - finalWrite.resolve(); - } - resolve(); - })); + editor.setClipboardFn( + (text, signal) => + new Promise((resolve, reject) => { + events.push(`start:${text}`); + signal?.addEventListener( + "abort", + () => { + const reason = + signal.reason instanceof Error + ? signal.reason.message + : String(signal.reason); + events.push(`abort:${text}:${reason}`); + reject(signal.reason ?? new Error("clipboard aborted")); + }, + { once: true }, + ); + + if (text === "foo ") { + return; + } + + events.push(`end:${text}`); + if (text === "baz ") { + finalWrite.resolve(); + } + resolve(); + }), + ); sendKeys(editor, ["d", "w", "d", "w", "d", "w"]); await withTimeout( @@ -1456,20 +1969,23 @@ describe("delete operator — dw / de / db / d$ / d0 / dd", () => { const aborts = new Map(expectedRegisters.map((text) => [text, deferred()])); editor.setClipboardWriteTimeoutMs(0); - editor.setClipboardFn((text, signal) => new Promise((_resolve, reject) => { - attempts.push(text); - const onAbort = () => { - aborts.get(text)?.resolve(); - reject(createSpawnErrno("late spawn after timeout")); - }; - - if (signal?.aborted) { - onAbort(); - return; - } - - signal?.addEventListener("abort", onAbort, { once: true }); - })); + editor.setClipboardFn( + (text, signal) => + new Promise((_resolve, reject) => { + attempts.push(text); + const onAbort = () => { + aborts.get(text)?.resolve(); + reject(createSpawnErrno("late spawn after timeout")); + }; + + if (signal?.aborted) { + onAbort(); + return; + } + + signal?.addEventListener("abort", onAbort, { once: true }); + }), + ); for (const expectedRegister of expectedRegisters) { sendKeys(editor, ["d", "w"]); @@ -1820,7 +2336,11 @@ describe("linewise operators and counts", () => { assert.equal(editor.getText(), "foo bar", `${scenario.name} text`); assert.equal(editor.getRegister(), "seed", `${scenario.name} register`); - assert.deepEqual(editor.getCursor(), beforeCursor, `${scenario.name} cursor`); + assert.deepEqual( + editor.getCursor(), + beforeCursor, + `${scenario.name} cursor`, + ); } }); @@ -1914,9 +2434,7 @@ describe("buffer motions — gg / G", () => { it("gg reaches line 0 across wrapped logical lines", () => { const wrappedLine = "x".repeat(200); - const editor = createEditorAtBufferEnd( - `top\n${wrappedLine}\nbottom`, - ); + const editor = createEditorAtBufferEnd(`top\n${wrappedLine}\nbottom`); sendKeys(editor, ["g", "g"]); @@ -1998,7 +2516,8 @@ describe("first non-whitespace motion — ^", () => { }); describe("paragraph motions — { / }", () => { - const paragraphFixture = "alpha one\nalpha two\n\n \nbeta one\nbeta two\n\ngamma one\n\n "; + const paragraphFixture = + "alpha one\nalpha two\n\n \nbeta one\nbeta two\n\ngamma one\n\n "; it("} moves to next paragraph start at column 0", () => { const { editor } = createMultiLineEditor(paragraphFixture); @@ -2488,8 +3007,12 @@ describe("WORD text objects — iW / aW", () => { }); it("d2iW and d2aW count WORDs using word-object whitespace policy", () => { - const { editor: inner } = createEditorWithSpy("foo path/to-file --flag=value bar"); - const { editor: around } = createEditorWithSpy("foo path/to-file --flag=value bar"); + const { editor: inner } = createEditorWithSpy( + "foo path/to-file --flag=value bar", + ); + const { editor: around } = createEditorWithSpy( + "foo path/to-file --flag=value bar", + ); setInternalCursor(inner, 4); sendKeys(inner, ["d", "2", "i", "W"]); @@ -2539,49 +3062,65 @@ describe("quote text objects", () => { it("supports double-quote text objects on the current quoted string", () => { const scenarios = [ { - name: "ci\"", - keys: ["c", "i", "\""], - expectedText: "say \"\" now", + name: 'ci"', + keys: ["c", "i", '"'], + expectedText: 'say "" now', expectedRegister: "hello", expectedMode: "insert", expectedCursor: { line: 0, col: 5 }, }, { - name: "di\"", - keys: ["d", "i", "\""], - expectedText: "say \"\" now", + name: 'di"', + keys: ["d", "i", '"'], + expectedText: 'say "" now', expectedRegister: "hello", expectedMode: "normal", expectedCursor: { line: 0, col: 5 }, }, { - name: "yi\"", - keys: ["y", "i", "\""], - expectedText: "say \"hello\" now", + name: 'yi"', + keys: ["y", "i", '"'], + expectedText: 'say "hello" now', expectedRegister: "hello", expectedMode: "normal", expectedCursor: { line: 0, col: 6 }, }, { - name: "ca\"", - keys: ["c", "a", "\""], + name: 'ca"', + keys: ["c", "a", '"'], expectedText: "say now", - expectedRegister: "\"hello\"", + expectedRegister: '"hello"', expectedMode: "insert", expectedCursor: { line: 0, col: 4 }, }, ]; for (const scenario of scenarios) { - const { editor } = createEditorWithSpy("say \"hello\" now"); + const { editor } = createEditorWithSpy('say "hello" now'); setInternalCursor(editor, 6); sendKeys(editor, scenario.keys); - assert.equal(editor.getText(), scenario.expectedText, `${scenario.name} text`); - assert.equal(editor.getRegister(), scenario.expectedRegister, `${scenario.name} register`); - assert.equal(editor.getMode(), scenario.expectedMode, `${scenario.name} mode`); - assert.deepEqual(editor.getCursor(), scenario.expectedCursor, `${scenario.name} cursor`); + assert.equal( + editor.getText(), + scenario.expectedText, + `${scenario.name} text`, + ); + assert.equal( + editor.getRegister(), + scenario.expectedRegister, + `${scenario.name} register`, + ); + assert.equal( + editor.getMode(), + scenario.expectedMode, + `${scenario.name} mode`, + ); + assert.deepEqual( + editor.getCursor(), + scenario.expectedCursor, + `${scenario.name} cursor`, + ); } }); @@ -2607,7 +3146,11 @@ describe("quote text objects", () => { sendKeys(editor, scenario.keys); - assert.equal(editor.getText(), scenario.expectedText, `${scenario.name} text`); + assert.equal( + editor.getText(), + scenario.expectedText, + `${scenario.name} text`, + ); assert.equal(editor.getRegister(), "hello", `${scenario.name} register`); } }); @@ -2617,20 +3160,20 @@ describe("quote text objects", () => { const { editor } = createEditorWithSpy(initial); setInternalCursor(editor, 14); - sendKeys(editor, ["d", "i", "\""]); + sendKeys(editor, ["d", "i", '"']); assert.equal(editor.getText(), String.raw`say \"not\" "" now`); assert.equal(editor.getRegister(), "yes"); }); it("does not pair quotes across logical lines", () => { - const initial = "say \"hello\nworld\" now"; + const initial = 'say "hello\nworld" now'; const { editor } = createMultiLineEditor(initial); const beforeCursor = { line: 0, col: 5 }; editor.setRegister("seed"); setInternalCursor(editor, beforeCursor.col, beforeCursor.line); - sendKeys(editor, ["d", "i", "\""]); + sendKeys(editor, ["d", "i", '"']); assert.equal(editor.getText(), initial); assert.equal(editor.getRegister(), "seed"); @@ -2639,47 +3182,51 @@ describe("quote text objects", () => { it("empty inner quotes no-op for delete and yank", () => { const scenarios = [ - { name: "delete", keys: ["d", "i", "\""] }, - { name: "yank", keys: ["y", "i", "\""] }, + { name: "delete", keys: ["d", "i", '"'] }, + { name: "yank", keys: ["y", "i", '"'] }, ]; for (const scenario of scenarios) { - const { editor } = createEditorWithSpy("say \"\" now"); + const { editor } = createEditorWithSpy('say "" now'); const beforeCursor = { line: 0, col: 4 }; editor.setRegister("seed"); setInternalCursor(editor, beforeCursor.col, beforeCursor.line); sendKeys(editor, scenario.keys); - assert.equal(editor.getText(), "say \"\" now", `${scenario.name} text`); + assert.equal(editor.getText(), 'say "" now', `${scenario.name} text`); assert.equal(editor.getRegister(), "seed", `${scenario.name} register`); - assert.deepEqual(editor.getCursor(), beforeCursor, `${scenario.name} cursor`); + assert.deepEqual( + editor.getCursor(), + beforeCursor, + `${scenario.name} cursor`, + ); assert.equal(editor.getMode(), "normal", `${scenario.name} mode`); } }); it("empty inner quote change enters insert at the inner start", () => { - const { editor } = createEditorWithSpy("say \"\" now"); + const { editor } = createEditorWithSpy('say "" now'); editor.setRegister("seed"); setInternalCursor(editor, 4); - sendKeys(editor, ["c", "i", "\""]); + sendKeys(editor, ["c", "i", '"']); - assert.equal(editor.getText(), "say \"\" now"); + assert.equal(editor.getText(), 'say "" now'); assert.equal(editor.getRegister(), "seed"); assert.equal(editor.getMode(), "insert"); assert.deepEqual(editor.getCursor(), { line: 0, col: 5 }); }); it("counted quote text objects cancel without mutation or register writes", () => { - const { editor } = createEditorWithSpy("say \"hello\" now"); + const { editor } = createEditorWithSpy('say "hello" now'); const beforeCursor = { line: 0, col: 6 }; editor.setRegister("seed"); setInternalCursor(editor, beforeCursor.col, beforeCursor.line); - sendKeys(editor, ["d", "2", "i", "\""]); + sendKeys(editor, ["d", "2", "i", '"']); - assert.equal(editor.getText(), "say \"hello\" now"); + assert.equal(editor.getText(), 'say "hello" now'); assert.equal(editor.getRegister(), "seed"); assert.deepEqual(editor.getCursor(), beforeCursor); assert.equal(editor.getMode(), "normal"); @@ -2741,10 +3288,26 @@ describe("bracket text objects", () => { setInternalCursor(editor, scenario.cursorCol); sendKeys(editor, scenario.keys); - assert.equal(editor.getText(), scenario.expectedText, `${scenario.name} text`); - assert.equal(editor.getRegister(), scenario.expectedRegister, `${scenario.name} register`); - assert.equal(editor.getMode(), scenario.expectedMode, `${scenario.name} mode`); - assert.deepEqual(editor.getCursor(), scenario.expectedCursor, `${scenario.name} cursor`); + assert.equal( + editor.getText(), + scenario.expectedText, + `${scenario.name} text`, + ); + assert.equal( + editor.getRegister(), + scenario.expectedRegister, + `${scenario.name} register`, + ); + assert.equal( + editor.getMode(), + scenario.expectedMode, + `${scenario.name} mode`, + ); + assert.deepEqual( + editor.getCursor(), + scenario.expectedCursor, + `${scenario.name} cursor`, + ); } }); @@ -2793,7 +3356,11 @@ describe("bracket text objects", () => { setInternalCursor(editor, scenario.cursorCol); sendKeys(editor, scenario.keys); - assert.equal(editor.getText(), scenario.expectedText, `${scenario.name} text`); + assert.equal( + editor.getText(), + scenario.expectedText, + `${scenario.name} text`, + ); assert.equal(editor.getRegister(), "foo", `${scenario.name} register`); } }); @@ -2853,7 +3420,11 @@ describe("bracket text objects", () => { assert.equal(editor.getText(), "call() now", `${scenario.name} text`); assert.equal(editor.getRegister(), "seed", `${scenario.name} register`); - assert.deepEqual(editor.getCursor(), beforeCursor, `${scenario.name} cursor`); + assert.deepEqual( + editor.getCursor(), + beforeCursor, + `${scenario.name} cursor`, + ); assert.equal(editor.getMode(), "normal", `${scenario.name} mode`); } }); @@ -2897,7 +3468,11 @@ describe("bracket text objects", () => { assert.equal(editor.getText(), scenario.initial, `${scenario.name} text`); assert.equal(editor.getRegister(), "seed", `${scenario.name} register`); - assert.deepEqual(editor.getCursor(), beforeCursor, `${scenario.name} cursor`); + assert.deepEqual( + editor.getCursor(), + beforeCursor, + `${scenario.name} cursor`, + ); assert.equal(editor.getMode(), "normal", `${scenario.name} mode`); } }); @@ -2915,11 +3490,11 @@ describe("delimited text objects at end of line", () => { }); it("resolves quote objects from $ on a non-final line", () => { - const { editor } = createMultiLineEditor("say \"hi\"\nnext"); + const { editor } = createMultiLineEditor('say "hi"\nnext'); - sendKeys(editor, ["$", "d", "i", "\""]); + sendKeys(editor, ["$", "d", "i", '"']); - assert.equal(editor.getText(), "say \"\"\nnext"); + assert.equal(editor.getText(), 'say ""\nnext'); assert.equal(editor.getRegister(), "hi"); assert.deepEqual(editor.getCursor(), { line: 0, col: 5 }); }); @@ -2937,10 +3512,10 @@ describe("delimited text objects at end of line", () => { }, { name: "quote", - initial: "before\nsay \"hi\"", + initial: 'before\nsay "hi"', cursorLine: 1, - keys: ["$", "d", "i", "\""], - expectedText: "before\nsay \"\"", + keys: ["$", "d", "i", '"'], + expectedText: 'before\nsay ""', expectedRegister: "hi", expectedCursor: { line: 1, col: 5 }, }, @@ -2952,16 +3527,28 @@ describe("delimited text objects at end of line", () => { setInternalCursor(editor, 0, scenario.cursorLine); sendKeys(editor, scenario.keys); - assert.equal(editor.getText(), scenario.expectedText, `${scenario.name} text`); - assert.equal(editor.getRegister(), scenario.expectedRegister, `${scenario.name} register`); - assert.deepEqual(editor.getCursor(), scenario.expectedCursor, `${scenario.name} cursor`); + assert.equal( + editor.getText(), + scenario.expectedText, + `${scenario.name} text`, + ); + assert.equal( + editor.getRegister(), + scenario.expectedRegister, + `${scenario.name} register`, + ); + assert.deepEqual( + editor.getCursor(), + scenario.expectedCursor, + `${scenario.name} cursor`, + ); } }); it("cancels delimiter objects from a final empty trailing-newline line", () => { const scenarios = [ { name: "bracket", keys: ["d", "i", "("] }, - { name: "quote", keys: ["c", "i", "\""] }, + { name: "quote", keys: ["c", "i", '"'] }, ]; for (const scenario of scenarios) { @@ -2974,14 +3561,18 @@ describe("delimited text objects at end of line", () => { assert.equal(editor.getText(), "call(foo)\n", `${scenario.name} text`); assert.equal(editor.getRegister(), "seed", `${scenario.name} register`); - assert.deepEqual(editor.getCursor(), beforeCursor, `${scenario.name} cursor`); + assert.deepEqual( + editor.getCursor(), + beforeCursor, + `${scenario.name} cursor`, + ); assert.equal(editor.getMode(), "normal", `${scenario.name} mode`); } }); it("cancels delimiter objects in an empty buffer", () => { const scenarios = [ - { name: "delete quote", keys: ["d", "i", "\""] }, + { name: "delete quote", keys: ["d", "i", '"'] }, { name: "change bracket", keys: ["c", "i", "("] }, ]; @@ -2994,7 +3585,11 @@ describe("delimited text objects at end of line", () => { assert.equal(editor.getText(), "", `${scenario.name} text`); assert.equal(editor.getRegister(), "seed", `${scenario.name} register`); - assert.deepEqual(editor.getCursor(), beforeCursor, `${scenario.name} cursor`); + assert.deepEqual( + editor.getCursor(), + beforeCursor, + `${scenario.name} cursor`, + ); assert.equal(editor.getMode(), "normal", `${scenario.name} mode`); } }); @@ -3015,25 +3610,49 @@ describe("text object cancellation hardening", () => { sendKeys(editor, scenario.keys); - assert.equal(editor.getText(), "foo bar", `${scenario.name} cancellation text`); - assert.equal(editor.getRegister(), "seed", `${scenario.name} cancellation register`); - assert.deepEqual(editor.getCursor(), beforeCursor, `${scenario.name} cancellation cursor`); - assert.equal(editor.getMode(), "normal", `${scenario.name} cancellation mode`); + assert.equal( + editor.getText(), + "foo bar", + `${scenario.name} cancellation text`, + ); + assert.equal( + editor.getRegister(), + "seed", + `${scenario.name} cancellation register`, + ); + assert.deepEqual( + editor.getCursor(), + beforeCursor, + `${scenario.name} cancellation cursor`, + ); + assert.equal( + editor.getMode(), + "normal", + `${scenario.name} cancellation mode`, + ); sendKeys(editor, ["x"]); - assert.equal(editor.getText(), "oo bar", `${scenario.name} next key text`); - assert.equal(editor.getRegister(), "f", `${scenario.name} next key register`); + assert.equal( + editor.getText(), + "oo bar", + `${scenario.name} next key text`, + ); + assert.equal( + editor.getRegister(), + "f", + `${scenario.name} next key register`, + ); } }); it("unmatched delimiters cancel without mutation or register writes", () => { const scenarios = [ { - name: "di\"", - initial: "say \"hello", + name: 'di"', + initial: 'say "hello', cursorCol: 5, - keys: ["d", "i", "\""], + keys: ["d", "i", '"'], }, { name: "ci(", @@ -3059,19 +3678,23 @@ describe("text object cancellation hardening", () => { assert.equal(editor.getText(), scenario.initial, `${scenario.name} text`); assert.equal(editor.getRegister(), "seed", `${scenario.name} register`); - assert.deepEqual(editor.getCursor(), beforeCursor, `${scenario.name} cursor`); + assert.deepEqual( + editor.getCursor(), + beforeCursor, + `${scenario.name} cursor`, + ); assert.equal(editor.getMode(), "normal", `${scenario.name} mode`); } }); it("unmatched delimiter cancellation is not sticky", () => { - const initial = "say \"hello"; + const initial = 'say "hello'; const { editor } = createEditorWithSpy(initial); const beforeCursor = { line: 0, col: 5 }; editor.setRegister("seed"); setInternalCursor(editor, beforeCursor.col, beforeCursor.line); - sendKeys(editor, ["d", "i", "\""]); + sendKeys(editor, ["d", "i", '"']); assert.equal(editor.getText(), initial); assert.equal(editor.getRegister(), "seed"); @@ -3079,17 +3702,17 @@ describe("text object cancellation hardening", () => { sendKeys(editor, ["x"]); - assert.equal(editor.getText(), "say \"ello"); + assert.equal(editor.getText(), 'say "ello'); assert.equal(editor.getRegister(), "h"); }); it("counted delimited examples cancel without mutation or register writes", () => { const scenarios = [ { - name: "d2i\"", - initial: "say \"hello\" now", + name: 'd2i"', + initial: 'say "hello" now', cursorCol: 6, - keys: ["d", "2", "i", "\""], + keys: ["d", "2", "i", '"'], }, { name: "2ci(", @@ -3115,7 +3738,11 @@ describe("text object cancellation hardening", () => { assert.equal(editor.getText(), scenario.initial, `${scenario.name} text`); assert.equal(editor.getRegister(), "seed", `${scenario.name} register`); - assert.deepEqual(editor.getCursor(), beforeCursor, `${scenario.name} cursor`); + assert.deepEqual( + editor.getCursor(), + beforeCursor, + `${scenario.name} cursor`, + ); assert.equal(editor.getMode(), "normal", `${scenario.name} mode`); } }); @@ -3146,7 +3773,11 @@ describe("text object cancellation hardening", () => { assert.equal(editor.getText(), scenario.initial, `${scenario.name} text`); assert.equal(editor.getRegister(), "seed", `${scenario.name} register`); - assert.deepEqual(editor.getCursor(), beforeCursor, `${scenario.name} cursor`); + assert.deepEqual( + editor.getCursor(), + beforeCursor, + `${scenario.name} cursor`, + ); assert.equal(editor.getMode(), "normal", `${scenario.name} mode`); } }); @@ -3622,7 +4253,9 @@ describe("word motion path selection", () => { for (const scenario of scenarios) { const { editor } = createEditorWithSpy("foo-bar baz"); const raw = getRawEditor(editor); - const original = raw.wordBoundaryCache.tryFindTarget.bind(raw.wordBoundaryCache); + const original = raw.wordBoundaryCache.tryFindTarget.bind( + raw.wordBoundaryCache, + ); let seenSemanticClass: string | null = null; raw.wordBoundaryCache.tryFindTarget = (...args: TryFindTargetArgs) => { @@ -3634,7 +4267,11 @@ describe("word motion path selection", () => { sendKeys(editor, scenario.setup); } sendKeys(editor, [scenario.motion]); - assert.equal(seenSemanticClass, "WORD", `${scenario.motion} should use WORD class`); + assert.equal( + seenSemanticClass, + "WORD", + `${scenario.motion} should use WORD class`, + ); } }); @@ -3708,7 +4345,12 @@ describe("word motion path selection", () => { }); it("W/E at EOL and B at BOL fall back to canonical absolute scanner", () => { - const scenarios: Array<{ name: string; initial: string; setup: string[]; motion: string }> = [ + const scenarios: Array<{ + name: string; + initial: string; + setup: string[]; + motion: string; + }> = [ { name: "W@EOL", initial: "foo\nbar", setup: ["$"], motion: "W" }, { name: "E@EOL", initial: "foo\nbar", setup: ["$"], motion: "E" }, { name: "B@BOL", initial: "foo\nbar", setup: ["j", "0"], motion: "B" }, @@ -3737,26 +4379,27 @@ describe("word motion path selection", () => { describe("operator word-motion path selection", () => { it("line-local d/c/y + w/e/b avoid canonical absolute scanner", () => { - const scenarios: Array<{ name: string; initial: string; keys: string[] }> = [ - { name: "dw", initial: "alpha beta", keys: ["d", "w"] }, - { name: "de", initial: "alpha beta", keys: ["d", "e"] }, - { name: "db", initial: "alpha beta", keys: ["w", "d", "b"] }, - { name: "cw", initial: "alpha beta", keys: ["c", "w"] }, - { name: "ce", initial: "alpha beta", keys: ["c", "e"] }, - { name: "cb", initial: "alpha beta", keys: ["w", "c", "b"] }, - { name: "yw", initial: "alpha beta", keys: ["y", "w"] }, - { name: "ye", initial: "alpha beta", keys: ["y", "e"] }, - { name: "yb", initial: "alpha beta", keys: ["w", "y", "b"] }, - { name: "dW", initial: "alpha-beta gamma", keys: ["d", "W"] }, - { name: "dE", initial: "alpha-beta gamma", keys: ["d", "E"] }, - { name: "dB", initial: "alpha-beta gamma", keys: ["W", "d", "B"] }, - { name: "cW", initial: "alpha-beta gamma", keys: ["c", "W"] }, - { name: "cE", initial: "alpha-beta gamma", keys: ["c", "E"] }, - { name: "cB", initial: "alpha-beta gamma", keys: ["W", "c", "B"] }, - { name: "yW", initial: "alpha-beta gamma", keys: ["y", "W"] }, - { name: "yE", initial: "alpha-beta gamma", keys: ["y", "E"] }, - { name: "yB", initial: "alpha-beta gamma", keys: ["W", "y", "B"] }, - ]; + const scenarios: Array<{ name: string; initial: string; keys: string[] }> = + [ + { name: "dw", initial: "alpha beta", keys: ["d", "w"] }, + { name: "de", initial: "alpha beta", keys: ["d", "e"] }, + { name: "db", initial: "alpha beta", keys: ["w", "d", "b"] }, + { name: "cw", initial: "alpha beta", keys: ["c", "w"] }, + { name: "ce", initial: "alpha beta", keys: ["c", "e"] }, + { name: "cb", initial: "alpha beta", keys: ["w", "c", "b"] }, + { name: "yw", initial: "alpha beta", keys: ["y", "w"] }, + { name: "ye", initial: "alpha beta", keys: ["y", "e"] }, + { name: "yb", initial: "alpha beta", keys: ["w", "y", "b"] }, + { name: "dW", initial: "alpha-beta gamma", keys: ["d", "W"] }, + { name: "dE", initial: "alpha-beta gamma", keys: ["d", "E"] }, + { name: "dB", initial: "alpha-beta gamma", keys: ["W", "d", "B"] }, + { name: "cW", initial: "alpha-beta gamma", keys: ["c", "W"] }, + { name: "cE", initial: "alpha-beta gamma", keys: ["c", "E"] }, + { name: "cB", initial: "alpha-beta gamma", keys: ["W", "c", "B"] }, + { name: "yW", initial: "alpha-beta gamma", keys: ["y", "W"] }, + { name: "yE", initial: "alpha-beta gamma", keys: ["y", "E"] }, + { name: "yB", initial: "alpha-beta gamma", keys: ["W", "y", "B"] }, + ]; for (const scenario of scenarios) { const { editor } = createEditorWithSpy(scenario.initial); @@ -3775,23 +4418,24 @@ describe("operator word-motion path selection", () => { }); it("cross-line operator word motions fall back to canonical scanner", () => { - const scenarios: Array<{ name: string; initial: string; keys: string[] }> = [ - { name: "dw@EOL", initial: "foo\nbar", keys: ["$", "d", "w"] }, - { name: "cw@EOL", initial: "foo\nbar", keys: ["$", "c", "w"] }, - { name: "yw@EOL", initial: "foo\nbar", keys: ["$", "y", "w"] }, - { name: "db@BOL", initial: "foo\nbar", keys: ["j", "0", "d", "b"] }, - { name: "cb@BOL", initial: "foo\nbar", keys: ["j", "0", "c", "b"] }, - { name: "yb@BOL", initial: "foo\nbar", keys: ["j", "0", "y", "b"] }, - { name: "dW@EOL", initial: "foo\nbar", keys: ["$", "d", "W"] }, - { name: "cW@EOL", initial: "foo\nbar", keys: ["$", "c", "W"] }, - { name: "yW@EOL", initial: "foo\nbar", keys: ["$", "y", "W"] }, - { name: "dE@EOL", initial: "foo\nbar", keys: ["$", "d", "E"] }, - { name: "cE@EOL", initial: "foo\nbar", keys: ["$", "c", "E"] }, - { name: "yE@EOL", initial: "foo\nbar", keys: ["$", "y", "E"] }, - { name: "dB@BOL", initial: "foo\nbar", keys: ["j", "0", "d", "B"] }, - { name: "cB@BOL", initial: "foo\nbar", keys: ["j", "0", "c", "B"] }, - { name: "yB@BOL", initial: "foo\nbar", keys: ["j", "0", "y", "B"] }, - ]; + const scenarios: Array<{ name: string; initial: string; keys: string[] }> = + [ + { name: "dw@EOL", initial: "foo\nbar", keys: ["$", "d", "w"] }, + { name: "cw@EOL", initial: "foo\nbar", keys: ["$", "c", "w"] }, + { name: "yw@EOL", initial: "foo\nbar", keys: ["$", "y", "w"] }, + { name: "db@BOL", initial: "foo\nbar", keys: ["j", "0", "d", "b"] }, + { name: "cb@BOL", initial: "foo\nbar", keys: ["j", "0", "c", "b"] }, + { name: "yb@BOL", initial: "foo\nbar", keys: ["j", "0", "y", "b"] }, + { name: "dW@EOL", initial: "foo\nbar", keys: ["$", "d", "W"] }, + { name: "cW@EOL", initial: "foo\nbar", keys: ["$", "c", "W"] }, + { name: "yW@EOL", initial: "foo\nbar", keys: ["$", "y", "W"] }, + { name: "dE@EOL", initial: "foo\nbar", keys: ["$", "d", "E"] }, + { name: "cE@EOL", initial: "foo\nbar", keys: ["$", "c", "E"] }, + { name: "yE@EOL", initial: "foo\nbar", keys: ["$", "y", "E"] }, + { name: "dB@BOL", initial: "foo\nbar", keys: ["j", "0", "d", "B"] }, + { name: "cB@BOL", initial: "foo\nbar", keys: ["j", "0", "c", "B"] }, + { name: "yB@BOL", initial: "foo\nbar", keys: ["j", "0", "y", "B"] }, + ]; for (const scenario of scenarios) { const { editor } = createMultiLineEditor(scenario.initial); @@ -3811,7 +4455,11 @@ describe("operator word-motion path selection", () => { }); describe("word-motion fast path differential", () => { - const assertFastEqualsCanonical = (initial: string, keys: string[], label: string): void => { + const assertFastEqualsCanonical = ( + initial: string, + keys: string[], + label: string, + ): void => { const fast = runScenario(initial, keys, "fast"); const canonical = runScenario(initial, keys, "canonical"); assert.deepEqual(fast, canonical, label); @@ -3864,23 +4512,44 @@ describe("word-motion fast path differential", () => { }); it("matches canonical behavior on cross-line uppercase WORD scenarios", () => { - const scenarios: Array<{ name: string; initial: string; keys: string[] }> = [ - { name: "W@EOL", initial: "foo\nbar", keys: ["$", "W", "x"] }, - { name: "2W@EOL", initial: "foo\nbar baz", keys: ["$", "2", "W", "x"] }, - { name: "E@EOL", initial: "foo\nbar", keys: ["$", "E", "x"] }, - { name: "2E@EOL", initial: "foo\nbar baz", keys: ["$", "2", "E", "x"] }, - { name: "B@BOL", initial: "foo\nbar", keys: ["j", "0", "B", "x"] }, - { name: "2B@BOL", initial: "foo bar\nbaz", keys: ["j", "0", "2", "B", "x"] }, - { name: "dW@EOL", initial: "foo\nbar", keys: ["$", "d", "W"] }, - { name: "cW@EOL", initial: "foo\nbar", keys: ["$", "c", "W", "X", "\x1b"] }, - { name: "yW@EOL", initial: "foo\nbar", keys: ["$", "y", "W", "p"] }, - { name: "dE@EOL", initial: "foo\nbar", keys: ["$", "d", "E"] }, - { name: "cE@EOL", initial: "foo\nbar", keys: ["$", "c", "E", "X", "\x1b"] }, - { name: "yE@EOL", initial: "foo\nbar", keys: ["$", "y", "E", "p"] }, - { name: "dB@BOL", initial: "foo\nbar", keys: ["j", "0", "d", "B"] }, - { name: "cB@BOL", initial: "foo\nbar", keys: ["j", "0", "c", "B", "X", "\x1b"] }, - { name: "yB@BOL", initial: "foo\nbar", keys: ["j", "0", "y", "B", "p"] }, - ]; + const scenarios: Array<{ name: string; initial: string; keys: string[] }> = + [ + { name: "W@EOL", initial: "foo\nbar", keys: ["$", "W", "x"] }, + { name: "2W@EOL", initial: "foo\nbar baz", keys: ["$", "2", "W", "x"] }, + { name: "E@EOL", initial: "foo\nbar", keys: ["$", "E", "x"] }, + { name: "2E@EOL", initial: "foo\nbar baz", keys: ["$", "2", "E", "x"] }, + { name: "B@BOL", initial: "foo\nbar", keys: ["j", "0", "B", "x"] }, + { + name: "2B@BOL", + initial: "foo bar\nbaz", + keys: ["j", "0", "2", "B", "x"], + }, + { name: "dW@EOL", initial: "foo\nbar", keys: ["$", "d", "W"] }, + { + name: "cW@EOL", + initial: "foo\nbar", + keys: ["$", "c", "W", "X", "\x1b"], + }, + { name: "yW@EOL", initial: "foo\nbar", keys: ["$", "y", "W", "p"] }, + { name: "dE@EOL", initial: "foo\nbar", keys: ["$", "d", "E"] }, + { + name: "cE@EOL", + initial: "foo\nbar", + keys: ["$", "c", "E", "X", "\x1b"], + }, + { name: "yE@EOL", initial: "foo\nbar", keys: ["$", "y", "E", "p"] }, + { name: "dB@BOL", initial: "foo\nbar", keys: ["j", "0", "d", "B"] }, + { + name: "cB@BOL", + initial: "foo\nbar", + keys: ["j", "0", "c", "B", "X", "\x1b"], + }, + { + name: "yB@BOL", + initial: "foo\nbar", + keys: ["j", "0", "y", "B", "p"], + }, + ]; for (const scenario of scenarios) { assertFastEqualsCanonical(scenario.initial, scenario.keys, scenario.name); @@ -3889,7 +4558,11 @@ describe("word-motion fast path differential", () => { }); describe("word-motion guard boundary regressions", () => { - const assertFastEqualsCanonical = (initial: string, keys: string[], label: string): void => { + const assertFastEqualsCanonical = ( + initial: string, + keys: string[], + label: string, + ): void => { const fast = runScenario(initial, keys, "fast"); const canonical = runScenario(initial, keys, "canonical"); assert.deepEqual(fast, canonical, label); @@ -3897,33 +4570,93 @@ describe("word-motion guard boundary regressions", () => { it("matches canonical behavior at EOL/BOL + punctuation/whitespace/empty boundaries", () => { const cases: Array<{ label: string; initial: string; keys: string[] }> = [ - { label: "EOL cross-line dw", initial: "foo\nbar", keys: ["$", "d", "w"] }, - { label: "BOL cross-line yb", initial: "foo\nbar", keys: ["j", "0", "y", "b"] }, - { label: "EOL cross-line dW", initial: "foo\nbar", keys: ["$", "d", "W"] }, - { label: "EOL cross-line yE", initial: "foo\nbar", keys: ["$", "y", "E", "p"] }, - { label: "BOL cross-line cB", initial: "foo\nbar", keys: ["j", "0", "c", "B", "X", "\x1b"] }, - { label: "punctuation run (word)", initial: "foo---bar", keys: ["w", "x"] }, - { label: "punctuation run (WORD)", initial: "foo---bar", keys: ["W", "x"] }, - { label: "whitespace run (word)", initial: "foo bar", keys: ["w", "x"] }, - { label: "whitespace run (WORD)", initial: "foo bar", keys: ["W", "x"] }, + { + label: "EOL cross-line dw", + initial: "foo\nbar", + keys: ["$", "d", "w"], + }, + { + label: "BOL cross-line yb", + initial: "foo\nbar", + keys: ["j", "0", "y", "b"], + }, + { + label: "EOL cross-line dW", + initial: "foo\nbar", + keys: ["$", "d", "W"], + }, + { + label: "EOL cross-line yE", + initial: "foo\nbar", + keys: ["$", "y", "E", "p"], + }, + { + label: "BOL cross-line cB", + initial: "foo\nbar", + keys: ["j", "0", "c", "B", "X", "\x1b"], + }, + { + label: "punctuation run (word)", + initial: "foo---bar", + keys: ["w", "x"], + }, + { + label: "punctuation run (WORD)", + initial: "foo---bar", + keys: ["W", "x"], + }, + { + label: "whitespace run (word)", + initial: "foo bar", + keys: ["w", "x"], + }, + { + label: "whitespace run (WORD)", + initial: "foo bar", + keys: ["W", "x"], + }, { label: "empty line (word)", initial: "", keys: ["w", "d", "w"] }, { label: "empty line (WORD)", initial: "", keys: ["W", "d", "W"] }, - { label: "blank-middle-line W", initial: "foo\n\nbar", keys: ["$", "W", "x"] }, - { label: "blank-middle-line B", initial: "foo\n\nbar", keys: ["j", "j", "0", "B", "x"] }, - { label: "WORD punctuation + whitespace boundary", initial: "foo--bar baz", keys: ["W", "E", "x"] }, + { + label: "blank-middle-line W", + initial: "foo\n\nbar", + keys: ["$", "W", "x"], + }, + { + label: "blank-middle-line B", + initial: "foo\n\nbar", + keys: ["j", "j", "0", "B", "x"], + }, + { + label: "WORD punctuation + whitespace boundary", + initial: "foo--bar baz", + keys: ["W", "E", "x"], + }, ]; for (const testCase of cases) { - assertFastEqualsCanonical(testCase.initial, testCase.keys, testCase.label); + assertFastEqualsCanonical( + testCase.initial, + testCase.keys, + testCase.label, + ); } }); it("keeps insert-mode behavior unaffected", () => { - assertFastEqualsCanonical("hello", ["i", "X", "Y", "\x1b", "x"], "insert mode"); + assertFastEqualsCanonical( + "hello", + ["i", "X", "Y", "\x1b", "x"], + "insert mode", + ); }); it("keeps non-word command behavior unaffected", () => { - assertFastEqualsCanonical("foo", ["x", "P", "f", "o", "x"], "non-word commands"); + assertFastEqualsCanonical( + "foo", + ["x", "P", "f", "o", "x"], + "non-word commands", + ); }); }); @@ -4355,7 +5088,15 @@ describe("put — line-wise", () => { const { editor } = createMultiLineEditor("aaa\nbbb\nccc\nddd"); sendKeys(editor, ["3", "Y", "G", "p"]); const lines = editor.getText().split("\n"); - assert.deepStrictEqual(lines, ["aaa", "bbb", "ccc", "ddd", "aaa", "bbb", "ccc"]); + assert.deepStrictEqual(lines, [ + "aaa", + "bbb", + "ccc", + "ddd", + "aaa", + "bbb", + "ccc", + ]); }); it("yy then p: duplicates line below", () => { @@ -4380,7 +5121,8 @@ describe("undo / redo — u / ctrl+r", () => { const before = editor.getText(); sendKeys(editor, ["u"]); assert.ok( - !editor.getText().includes("uhello") && editor.getText().length <= before.length, + !editor.getText().includes("uhello") && + editor.getText().length <= before.length, "u must not be inserted as a literal character and text must not grow", ); }); @@ -4641,10 +5383,7 @@ describe("undo / redo — u / ctrl+r", () => { }); describe("central invalidation hook", () => { - function seedStaleRedo(options: { - initial: string; - multiLine?: boolean; - }): { + function seedStaleRedo(options: { initial: string; multiLine?: boolean }): { editor: ReturnType["editor"]; staleRedoText: string; } { @@ -4656,7 +5395,11 @@ describe("undo / redo — u / ctrl+r", () => { sendKeys(editor, ["x"]); const staleRedoText = editor.getText(); sendKeys(editor, ["u"]); - assert.equal(editor.getText(), initial, "redo setup should restore initial text"); + assert.equal( + editor.getText(), + initial, + "redo setup should restore initial text", + ); return { editor, staleRedoText }; } @@ -4710,10 +5453,18 @@ describe("undo / redo — u / ctrl+r", () => { }); sendKeys(editor, scenario.keys); - assert.equal(editor.getText(), scenario.expectedText, `${scenario.name} mutates text`); + assert.equal( + editor.getText(), + scenario.expectedText, + `${scenario.name} mutates text`, + ); sendKeys(editor, ["\x12"]); - assert.equal(editor.getText(), scenario.expectedText, `${scenario.name} clears redo`); + assert.equal( + editor.getText(), + scenario.expectedText, + `${scenario.name} clears redo`, + ); } }); @@ -4730,10 +5481,18 @@ describe("undo / redo — u / ctrl+r", () => { assert.equal(editor.getText(), "bcd", "undo transition checkpoint"); sendKeys(editor, ["u"]); - assert.equal(editor.getText(), "abcd", "undo transition keeps redo stack"); + assert.equal( + editor.getText(), + "abcd", + "undo transition keeps redo stack", + ); sendKeys(editor, ["\x12", "\x12"]); - assert.equal(editor.getText(), "cd", "undo transition keeps both redo entries"); + assert.equal( + editor.getText(), + "cd", + "undo transition keeps both redo entries", + ); }, }, { @@ -4744,10 +5503,18 @@ describe("undo / redo — u / ctrl+r", () => { assert.equal(editor.getText(), "abcd", "redo transition setup"); sendKeys(editor, ["2", "\x12"]); - assert.equal(editor.getText(), "cd", "redo transition keeps stepwise redo"); + assert.equal( + editor.getText(), + "cd", + "redo transition keeps stepwise redo", + ); sendKeys(editor, ["u"]); - assert.equal(editor.getText(), "bcd", "redo transition keeps undo boundaries"); + assert.equal( + editor.getText(), + "bcd", + "redo transition keeps undo boundaries", + ); }, }, ]; @@ -4770,41 +5537,69 @@ describe("undo / redo — u / ctrl+r", () => { name: "navigation", run: (editor, staleRedoText) => { sendKeys(editor, ["l", "h", "\x12"]); - assert.equal(editor.getText(), staleRedoText, "navigation preserves redo"); + assert.equal( + editor.getText(), + staleRedoText, + "navigation preserves redo", + ); }, }, { name: "yank", run: (editor, staleRedoText) => { sendKeys(editor, ["y", "y", "\x12"]); - assert.equal(editor.getText(), staleRedoText, "yank preserves redo"); + assert.equal( + editor.getText(), + staleRedoText, + "yank preserves redo", + ); }, }, { name: "failed motion", run: (editor, staleRedoText) => { sendKeys(editor, ["f", "z", "\x12"]); - assert.equal(editor.getText(), staleRedoText, "failed motion preserves redo"); + assert.equal( + editor.getText(), + staleRedoText, + "failed motion preserves redo", + ); }, }, { name: "mode toggle", run: (editor, staleRedoText) => { sendKeys(editor, ["i", "\x1b", "\x12"]); - assert.equal(editor.getText(), staleRedoText, "mode toggle preserves redo"); + assert.equal( + editor.getText(), + staleRedoText, + "mode toggle preserves redo", + ); }, }, { name: "no-op redo", run: (editor, staleRedoText) => { sendKeys(editor, ["\x12"]); - assert.equal(editor.getText(), staleRedoText, "redo setup should replay once"); + assert.equal( + editor.getText(), + staleRedoText, + "redo setup should replay once", + ); sendKeys(editor, ["\x12"]); - assert.equal(editor.getText(), staleRedoText, "no-op redo does not mutate"); + assert.equal( + editor.getText(), + staleRedoText, + "no-op redo does not mutate", + ); sendKeys(editor, ["u", "\x12"]); - assert.equal(editor.getText(), staleRedoText, "no-op redo keeps history intact"); + assert.equal( + editor.getText(), + staleRedoText, + "no-op redo keeps history intact", + ); }, }, ]; @@ -4891,7 +5686,10 @@ describe("undo / redo — u / ctrl+r", () => { sendKeys(editor, ["i"]); // → insert mode assert.equal(editor.getMode(), "insert"); sendKeys(editor, ["u"]); - assert.ok(editor.getText().includes("u"), "u in insert mode must insert character"); + assert.ok( + editor.getText().includes("u"), + "u in insert mode must insert character", + ); }); it("undo does not self-invalidate redo stack", () => { @@ -5018,10 +5816,7 @@ describe("undo / redo — u / ctrl+r", () => { raw.pushUndoSnapshot = undefined; try { - assert.throws( - () => sendKeys(editor, ["\x12"]), - /pushUndoSnapshot/i, - ); + assert.throws(() => sendKeys(editor, ["\x12"]), /pushUndoSnapshot/i); } finally { raw.pushUndoSnapshot = saved; } @@ -5289,8 +6084,8 @@ describe("operator cancellation", () => { it("Escape cancels pending operator without mutation", () => { const { editor } = createEditorWithSpy("hello"); const before = editor.getText(); - sendKeys(editor, ["d"]); // pendingOperator = 'd' - sendKeys(editor, ["\x1b"]); // cancel + sendKeys(editor, ["d"]); // pendingOperator = 'd' + sendKeys(editor, ["\x1b"]); // cancel assert.equal(editor.getText(), before); assert.equal(editor.getMode(), "normal"); }); @@ -5298,15 +6093,15 @@ describe("operator cancellation", () => { it("Escape cancels pending motion without mutation", () => { const { editor } = createEditorWithSpy("hello"); const before = editor.getText(); - sendKeys(editor, ["f"]); // pendingMotion = 'f' - sendKeys(editor, ["\x1b"]); // cancel + sendKeys(editor, ["f"]); // pendingMotion = 'f' + sendKeys(editor, ["\x1b"]); // cancel assert.equal(editor.getText(), before); }); it("unrecognised key after d operator cancels cleanly", () => { const { editor } = createEditorWithSpy("hello"); const before = editor.getText(); - sendKeys(editor, ["d", "z"]); // 'z' is not a valid motion + sendKeys(editor, ["d", "z"]); // 'z' is not a valid motion assert.equal(editor.getText(), before); }); @@ -5370,11 +6165,16 @@ describe("operator cancellation", () => { it("double-escape recovery does not forward escape upward", () => { const { editor } = createEditorWithSpy("foo bar"); - const customEditorProto = Object.getPrototypeOf(Object.getPrototypeOf(editor)); + const customEditorProto = Object.getPrototypeOf( + Object.getPrototypeOf(editor), + ); const originalHandleInput = customEditorProto.handleInput; let forwardedEscapeCount = 0; - customEditorProto.handleInput = function (this: unknown, data: string): unknown { + customEditorProto.handleInput = function ( + this: unknown, + data: string, + ): unknown { if (data === "\x1b") forwardedEscapeCount++; return originalHandleInput.call(this, data); }; @@ -5478,9 +6278,11 @@ describe("additional count combinations", () => { describe("surrogate pair / buffer replacement regression", () => { it("dd deletes only the current line when it contains surrogate pairs", () => { const { editor } = createEditorWithSpy(""); - (editor as unknown as { - state: { lines: string[]; cursorLine: number; cursorCol: number }; - }).state = { + ( + editor as unknown as { + state: { lines: string[]; cursorLine: number; cursorCol: number }; + } + ).state = { lines: ["😀x", "keep"], cursorLine: 0, cursorCol: 0, @@ -5492,9 +6294,11 @@ describe("surrogate pair / buffer replacement regression", () => { it("9x on multiline buffer does not cross newline", () => { const { editor } = createEditorWithSpy(""); - (editor as unknown as { - state: { lines: string[]; cursorLine: number; cursorCol: number }; - }).state = { + ( + editor as unknown as { + state: { lines: string[]; cursorLine: number; cursorCol: number }; + } + ).state = { lines: ["ab", "cd"], cursorLine: 0, cursorCol: 0, diff --git a/test/motions.test.ts b/test/motions.test.ts index bc7e053e..fd40459c 100644 --- a/test/motions.test.ts +++ b/test/motions.test.ts @@ -2,17 +2,17 @@ * Unit tests for motions.ts — pure functions, no DOM/pi-tui dependency. */ -import { describe, it } from "node:test"; import assert from "node:assert/strict"; +import { describe, it } from "node:test"; import { - findWordMotionTarget, findCharMotionTarget, findFirstNonWhitespaceColumn, - isBlankLine, - isParagraphStart, findNextParagraphStart, - findPrevParagraphStart, findParagraphMotionTarget, + findPrevParagraphStart, + findWordMotionTarget, + isBlankLine, + isParagraphStart, } from "../motions.js"; import { WordBoundaryCache } from "../word-boundary-cache.js"; @@ -27,7 +27,8 @@ function makeGeneratedLineFixtures(count: number): string[] { const punct = ["-", "--", "::", ".", ",", "!?", "#"]; const spaces = [" ", " ", " ", "\t"]; const fixtures = ["", " ", "---", "a", "a b", "foo--bar"]; - const pick = (values: readonly string[]): string => values[next() % values.length] ?? ""; + const pick = (values: readonly string[]): string => + values[next() % values.length] ?? ""; for (let i = 0; i < count; i++) { const parts: string[] = []; @@ -230,7 +231,10 @@ describe("WordBoundaryCache", () => { const cache = new WordBoundaryCache(); assert.equal(cache.tryFindTarget("abc", -1, "forward", "start"), null); - assert.equal(cache.tryFindTarget("abc", Number.NaN, "forward", "start"), null); + assert.equal( + cache.tryFindTarget("abc", Number.NaN, "forward", "start"), + null, + ); }); }); @@ -241,10 +245,9 @@ describe("WordBoundaryCache differential", () => { for (const line of fixtures) { for (let col = 0; col <= line.length; col++) { - const cases: Array<[ - direction: "forward" | "backward", - target: "start" | "end", - ]> = [ + const cases: Array< + [direction: "forward" | "backward", target: "start" | "end"] + > = [ ["forward", "start"], ["forward", "end"], ["backward", "start"], @@ -252,7 +255,13 @@ describe("WordBoundaryCache differential", () => { for (const [direction, target] of cases) { for (const semanticClass of ["word", "WORD"] as const) { - const fast = cache.tryFindTarget(line, col, direction, target, semanticClass); + const fast = cache.tryFindTarget( + line, + col, + direction, + target, + semanticClass, + ); const canonical = findWordMotionTarget( line, col, diff --git a/test/settings.test.ts b/test/settings.test.ts new file mode 100644 index 00000000..303df44d --- /dev/null +++ b/test/settings.test.ts @@ -0,0 +1,263 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + readPiVimBooleanSetting, + readPiVimClipboardMirrorSetting, + readPiVimModeColors, +} from "../settings.js"; + +describe("piVim mode color settings reader", () => { + it("returns undefined when mode colors are missing", () => { + assert.equal(readPiVimModeColors(undefined, undefined), undefined); + assert.equal(readPiVimModeColors({ piVim: {} }, { piVim: {} }), undefined); + }); + + it("reads partial mode color settings", () => { + assert.deepEqual( + readPiVimModeColors( + { piVim: { modeColors: { insert: " borderMuted " } } }, + {}, + ), + { insert: "borderMuted" }, + ); + }); + + it("reads all three mode color settings", () => { + assert.deepEqual( + readPiVimModeColors( + { + piVim: { + modeColors: { + insert: "muted", + normal: "primary", + ex: "warning", + }, + }, + }, + {}, + ), + { insert: "muted", normal: "primary", ex: "warning" }, + ); + }); + + it("drops non-string mode color leaves", () => { + assert.deepEqual( + readPiVimModeColors( + { + piVim: { modeColors: { insert: "muted", normal: 42, ex: "warning" } }, + }, + {}, + ), + { insert: "muted", ex: "warning" }, + ); + }); + + it("drops malformed mode color tokens", () => { + assert.deepEqual( + readPiVimModeColors( + { + piVim: { + modeColors: { + insert: "red;evil", + normal: "_bad", + ex: "warn-ing_1", + }, + }, + }, + {}, + ), + { ex: "warn-ing_1" }, + ); + }); + + it("lets project modeColors override global as a setting", () => { + assert.deepEqual( + readPiVimModeColors( + { + piVim: { + modeColors: { + insert: "globalInsert", + normal: "globalNormal", + ex: "globalEx", + }, + }, + }, + { piVim: { modeColors: { ex: "projectEx" } } }, + ), + { ex: "projectEx" }, + ); + }); + + it("does not fall back to global modeColors when project leaves are invalid", () => { + assert.deepEqual( + readPiVimModeColors( + { + piVim: { + modeColors: { + insert: "globalInsert", + normal: "globalNormal", + ex: "globalEx", + }, + }, + }, + { + piVim: { + modeColors: { + insert: "projectInsert", + normal: 42, + ex: "red;evil", + }, + }, + }, + ), + { insert: "projectInsert" }, + ); + }); + + it("treats malformed project modeColors as an override", () => { + assert.equal( + readPiVimModeColors( + { piVim: { modeColors: { insert: "globalInsert" } } }, + { piVim: { modeColors: null } }, + ), + undefined, + ); + }); +}); + +describe("piVim boolean settings reader", () => { + it("returns undefined when boolean setting is missing", () => { + assert.equal( + readPiVimBooleanSetting(undefined, undefined, "syncBorderColorWithMode"), + undefined, + ); + assert.equal( + readPiVimBooleanSetting( + { piVim: {} }, + { piVim: {} }, + "syncBorderColorWithMode", + ), + undefined, + ); + }); + + it("reads true and false boolean settings", () => { + assert.equal( + readPiVimBooleanSetting( + { piVim: { syncBorderColorWithMode: true } }, + {}, + "syncBorderColorWithMode", + ), + true, + ); + assert.equal( + readPiVimBooleanSetting( + { piVim: { syncBorderColorWithMode: false } }, + {}, + "syncBorderColorWithMode", + ), + false, + ); + }); + + it("ignores invalid boolean settings", () => { + assert.equal( + readPiVimBooleanSetting( + { piVim: { syncBorderColorWithMode: "true" } }, + {}, + "syncBorderColorWithMode", + ), + undefined, + ); + assert.equal( + readPiVimBooleanSetting( + { piVim: { syncBorderColorWithMode: 1 } }, + {}, + "syncBorderColorWithMode", + ), + undefined, + ); + assert.equal( + readPiVimBooleanSetting( + { piVim: { syncBorderColorWithMode: null } }, + {}, + "syncBorderColorWithMode", + ), + undefined, + ); + }); + + it("lets project boolean settings override global", () => { + assert.equal( + readPiVimBooleanSetting( + { piVim: { syncBorderColorWithMode: true } }, + { piVim: { syncBorderColorWithMode: false } }, + "syncBorderColorWithMode", + ), + false, + ); + }); + + it("treats invalid project boolean settings as an override", () => { + assert.equal( + readPiVimBooleanSetting( + { piVim: { syncBorderColorWithMode: true } }, + { piVim: { syncBorderColorWithMode: "false" } }, + "syncBorderColorWithMode", + ), + undefined, + ); + }); +}); + +describe("piVim clipboard mirror settings reader", () => { + it("returns undefined when global and project settings are missing", () => { + assert.equal( + readPiVimClipboardMirrorSetting(undefined, undefined), + undefined, + ); + assert.equal(readPiVimClipboardMirrorSetting(null, null), undefined); + assert.equal(readPiVimClipboardMirrorSetting("bad", 42), undefined); + }); + + it("reads global piVim clipboardMirror when project setting is missing", () => { + assert.equal( + readPiVimClipboardMirrorSetting( + { piVim: { clipboardMirror: "yank" } }, + {}, + ), + "yank", + ); + }); + + it("lets project piVim clipboardMirror override global", () => { + assert.equal( + readPiVimClipboardMirrorSetting( + { piVim: { clipboardMirror: "never" } }, + { piVim: { clipboardMirror: "all" } }, + ), + "all", + ); + }); + + it("treats invalid project clipboardMirror as an override instead of falling back to global", () => { + assert.equal( + readPiVimClipboardMirrorSetting( + { piVim: { clipboardMirror: "yank" } }, + { piVim: { clipboardMirror: null } }, + ), + null, + ); + }); + + it("treats malformed project piVim settings as an override instead of falling back to global", () => { + assert.equal( + readPiVimClipboardMirrorSetting( + { piVim: { clipboardMirror: "yank" } }, + { piVim: "bad" }, + ), + "bad", + ); + }); +}); diff --git a/test/text-objects.test.ts b/test/text-objects.test.ts index 1d2f299d..b4602aa5 100644 --- a/test/text-objects.test.ts +++ b/test/text-objects.test.ts @@ -1,5 +1,5 @@ -import { describe, it } from "node:test"; import assert from "node:assert/strict"; +import { describe, it } from "node:test"; import { isEscapedDelimiter, normalizeDelimiterKey, @@ -49,10 +49,13 @@ describe("resolveWordTextObjectRange", () => { }); it("uses contiguous non-whitespace runs for WORD semantics", () => { - assert.deepEqual(resolveWordTextObjectRange("path/to-file", 0, 5, "i", 1, "WORD"), { - startAbs: 0, - endAbs: 12, - }); + assert.deepEqual( + resolveWordTextObjectRange("path/to-file", 0, 5, "i", 1, "WORD"), + { + startAbs: 0, + endAbs: 12, + }, + ); }); it("does not cross newline boundaries", () => { @@ -70,10 +73,10 @@ describe("resolveWordTextObjectRange", () => { describe("normalizeDelimiterKey", () => { it("normalizes quote delimiter keys", () => { - assert.deepEqual(normalizeDelimiterKey("\""), { + assert.deepEqual(normalizeDelimiterKey('"'), { type: "quote", - open: "\"", - close: "\"", + open: '"', + close: '"', }); assert.deepEqual(normalizeDelimiterKey("'"), { type: "quote", @@ -100,11 +103,15 @@ describe("normalizeDelimiterKey", () => { ]; for (const bracketCase of cases) { - assert.deepEqual(normalizeDelimiterKey(bracketCase.key), { - type: "bracket", - open: bracketCase.open, - close: bracketCase.close, - }, bracketCase.key); + assert.deepEqual( + normalizeDelimiterKey(bracketCase.key), + { + type: "bracket", + open: bracketCase.open, + close: bracketCase.close, + }, + bracketCase.key, + ); } }); @@ -119,7 +126,7 @@ describe("resolveQuoteObjectRange", () => { { name: "double quotes", text: 'say "hello" now', - quote: "\"", + quote: '"', cursorAbs: 6, inner: { startAbs: 5, endAbs: 10 }, around: { startAbs: 4, endAbs: 11 }, @@ -145,11 +152,21 @@ describe("resolveQuoteObjectRange", () => { for (const quoteCase of cases) { it(`resolves inside and around ${quoteCase.name}`, () => { assert.deepEqual( - resolveQuoteObjectRange(quoteCase.text, quoteCase.cursorAbs, "i", quoteCase.quote), + resolveQuoteObjectRange( + quoteCase.text, + quoteCase.cursorAbs, + "i", + quoteCase.quote, + ), quoteCase.inner, ); assert.deepEqual( - resolveDelimitedTextObjectRange(quoteCase.text, quoteCase.cursorAbs, "a", quoteCase.quote), + resolveDelimitedTextObjectRange( + quoteCase.text, + quoteCase.cursorAbs, + "a", + quoteCase.quote, + ), quoteCase.around, ); }); @@ -158,11 +175,11 @@ describe("resolveQuoteObjectRange", () => { it("counts the cursor on either quote delimiter as contained", () => { const text = 'say "hello" now'; - assert.deepEqual(resolveDelimitedTextObjectRange(text, 4, "i", "\""), { + assert.deepEqual(resolveDelimitedTextObjectRange(text, 4, "i", '"'), { startAbs: 5, endAbs: 10, }); - assert.deepEqual(resolveDelimitedTextObjectRange(text, 10, "a", "\""), { + assert.deepEqual(resolveDelimitedTextObjectRange(text, 10, "a", '"'), { startAbs: 4, endAbs: 11, }); @@ -171,13 +188,13 @@ describe("resolveQuoteObjectRange", () => { it("ignores escaped quotes with an odd number of preceding backslashes", () => { const text = String.raw`\"skip\" "yes"`; - assert.equal(text[1], "\""); - assert.equal(text[7], "\""); - assert.equal(text[9], "\""); - assert.equal(text[13], "\""); + assert.equal(text[1], '"'); + assert.equal(text[7], '"'); + assert.equal(text[9], '"'); + assert.equal(text[13], '"'); assert.equal(isEscapedDelimiter(text, 1), true); assert.equal(isEscapedDelimiter(text, 7), true); - assert.deepEqual(resolveDelimitedTextObjectRange(text, 10, "i", "\""), { + assert.deepEqual(resolveDelimitedTextObjectRange(text, 10, "i", '"'), { startAbs: 10, endAbs: 13, }); @@ -203,7 +220,7 @@ describe("resolveQuoteObjectRange", () => { ]; for (const quoteCase of cases) { - const firstQuote = quoteCase.text.indexOf("\""); + const firstQuote = quoteCase.text.indexOf('"'); const startAbs = quoteCase.text.indexOf("yes"); assert.notEqual(firstQuote, -1, `${quoteCase.name} first quote`); @@ -214,7 +231,7 @@ describe("resolveQuoteObjectRange", () => { quoteCase.name, ); assert.deepEqual( - resolveDelimitedTextObjectRange(quoteCase.text, startAbs, "i", "\""), + resolveDelimitedTextObjectRange(quoteCase.text, startAbs, "i", '"'), { startAbs, endAbs: startAbs + "yes".length, @@ -227,8 +244,8 @@ describe("resolveQuoteObjectRange", () => { it("does not cross newline boundaries", () => { const text = '"one\n"two"'; - assert.equal(resolveDelimitedTextObjectRange(text, 2, "i", "\""), null); - assert.deepEqual(resolveDelimitedTextObjectRange(text, 6, "i", "\""), { + assert.equal(resolveDelimitedTextObjectRange(text, 2, "i", '"'), null); + assert.deepEqual(resolveDelimitedTextObjectRange(text, 6, "i", '"'), { startAbs: 6, endAbs: 9, }); @@ -237,11 +254,11 @@ describe("resolveQuoteObjectRange", () => { it("returns an empty inner range for empty quotes", () => { const text = 'say "" now'; - assert.deepEqual(resolveDelimitedTextObjectRange(text, 4, "i", "\""), { + assert.deepEqual(resolveDelimitedTextObjectRange(text, 4, "i", '"'), { startAbs: 5, endAbs: 5, }); - assert.deepEqual(resolveDelimitedTextObjectRange(text, 5, "a", "\""), { + assert.deepEqual(resolveDelimitedTextObjectRange(text, 5, "a", '"'), { startAbs: 4, endAbs: 6, }); @@ -306,24 +323,33 @@ describe("resolveBracketObjectRange", () => { const innerPairStart = target.indexOf("{inner}"); const cursorAbs = targetStartAbs + target.indexOf("inner"); - assert.deepEqual(resolveDelimitedTextObjectRange(text, cursorAbs, "a", "{"), { - startAbs: targetStartAbs + innerPairStart, - endAbs: targetStartAbs + innerPairStart + "{inner}".length, - }); + assert.deepEqual( + resolveDelimitedTextObjectRange(text, cursorAbs, "a", "{"), + { + startAbs: targetStartAbs + innerPairStart, + endAbs: targetStartAbs + innerPairStart + "{inner}".length, + }, + ); }); it("keeps mixed-bracket matching lexical for the selected delimiter type", () => { const text = "outer { [ value } still ]"; const cursorAbs = text.indexOf("value"); - assert.deepEqual(resolveDelimitedTextObjectRange(text, cursorAbs, "a", "{"), { - startAbs: text.indexOf("{"), - endAbs: text.indexOf("}") + 1, - }); - assert.deepEqual(resolveDelimitedTextObjectRange(text, cursorAbs, "a", "["), { - startAbs: text.indexOf("["), - endAbs: text.indexOf("]") + 1, - }); + assert.deepEqual( + resolveDelimitedTextObjectRange(text, cursorAbs, "a", "{"), + { + startAbs: text.indexOf("{"), + endAbs: text.indexOf("}") + 1, + }, + ); + assert.deepEqual( + resolveDelimitedTextObjectRange(text, cursorAbs, "a", "["), + { + startAbs: text.indexOf("["), + endAbs: text.indexOf("]") + 1, + }, + ); }); it("returns an empty inner range for empty brackets", () => { @@ -340,7 +366,13 @@ describe("resolveBracketObjectRange", () => { }); it("returns null for unmatched brackets", () => { - assert.equal(resolveDelimitedTextObjectRange("call(foo", 5, "i", "("), null); - assert.equal(resolveDelimitedTextObjectRange("call(foo)", 5, "i", "["), null); + assert.equal( + resolveDelimitedTextObjectRange("call(foo", 5, "i", "("), + null, + ); + assert.equal( + resolveDelimitedTextObjectRange("call(foo)", 5, "i", "["), + null, + ); }); }); diff --git a/text-objects.ts b/text-objects.ts index 4d551434..4701b28e 100644 --- a/text-objects.ts +++ b/text-objects.ts @@ -34,10 +34,14 @@ function clampCursorAbs(text: string, cursorAbs: number): number { return Math.max(0, Math.min(normalized, text.length - 1)); } -function findLogicalLineBounds(line: string, cursorCol: number): { start: number; end: number } { +function findLogicalLineBounds( + line: string, + cursorCol: number, +): { start: number; end: number } { if (line.length === 0) return { start: 0, end: 0 }; - const previousSearchStart = line[cursorCol] === "\n" ? cursorCol - 1 : cursorCol; + const previousSearchStart = + line[cursorCol] === "\n" ? cursorCol - 1 : cursorCol; const start = line.lastIndexOf("\n", previousSearchStart) + 1; const nextNewline = line.indexOf("\n", cursorCol); @@ -47,7 +51,10 @@ function findLogicalLineBounds(line: string, cursorCol: number): { start: number }; } -function findCurrentLineBounds(text: string, cursorAbs: number): { startAbs: number; endAbs: number } { +function findCurrentLineBounds( + text: string, + cursorAbs: number, +): { startAbs: number; endAbs: number } { const cursor = clampCursorAbs(text, cursorAbs); const bounds = findLogicalLineBounds(text, cursor); @@ -57,7 +64,10 @@ function findCurrentLineBounds(text: string, cursorAbs: number): { startAbs: num }; } -function isWordTextObjectChar(ch: string | undefined, semanticClass: WordTextObjectClass): boolean { +function isWordTextObjectChar( + ch: string | undefined, + semanticClass: WordTextObjectClass, +): boolean { if (ch === undefined) return false; if (semanticClass === "WORD") return !/\s/.test(ch); return /\w/.test(ch); @@ -68,7 +78,7 @@ function isWhitespace(ch: string | undefined): boolean { } export function normalizeDelimiterKey(key: string): DelimiterSpec | null { - if (key === "\"" || key === "'" || key === "`") { + if (key === '"' || key === "'" || key === "`") { return { type: "quote", open: key, @@ -104,7 +114,8 @@ export function normalizeDelimiterKey(key: string): DelimiterSpec | null { } export function isEscapedDelimiter(text: string, index: number): boolean { - if (!Number.isInteger(index) || index <= 0 || index >= text.length) return false; + if (!Number.isInteger(index) || index <= 0 || index >= text.length) + return false; let backslashCount = 0; for (let i = index - 1; i >= 0 && text[i] === "\\"; i--) { @@ -140,7 +151,10 @@ export function resolveQuoteObjectRange( const closeIndex = index; if (openIndex <= cursor && cursor <= closeIndex) { - if (bestPair === null || closeIndex - openIndex < bestPair.close - bestPair.open) { + if ( + bestPair === null || + closeIndex - openIndex < bestPair.close - bestPair.open + ) { bestPair = { open: openIndex, close: closeIndex }; } } @@ -189,7 +203,10 @@ export function resolveBracketObjectRange( if (openIndex === undefined) continue; if (openIndex <= cursor && cursor <= index) { - if (bestPair === null || index - openIndex < bestPair.close - bestPair.open) { + if ( + bestPair === null || + index - openIndex < bestPair.close - bestPair.open + ) { bestPair = { open: openIndex, close: index }; } } @@ -224,7 +241,13 @@ export function resolveDelimitedTextObjectRange( } if (spec.type === "bracket") { - return resolveBracketObjectRange(text, cursorAbs, kind, spec.open, spec.close); + return resolveBracketObjectRange( + text, + cursorAbs, + kind, + spec.open, + spec.close, + ); } return null; @@ -244,11 +267,10 @@ export function resolveWordTextObjectRange( const bounds = findLogicalLineBounds(line, cursor); if (bounds.start >= bounds.end) return null; - const hasWordChar = (idx: number) => ( - idx >= bounds.start - && idx < bounds.end - && isWordTextObjectChar(line[idx], semanticClass) - ); + const hasWordChar = (idx: number) => + idx >= bounds.start && + idx < bounds.end && + isWordTextObjectChar(line[idx], semanticClass); let col = Math.max(bounds.start, Math.min(cursor, bounds.end - 1)); @@ -275,7 +297,8 @@ export function resolveWordTextObjectRange( let remaining = normalizeCount(count) - 1; while (remaining > 0) { let nextWordStart = end; - while (nextWordStart < bounds.end && !hasWordChar(nextWordStart)) nextWordStart++; + while (nextWordStart < bounds.end && !hasWordChar(nextWordStart)) + nextWordStart++; if (nextWordStart >= bounds.end) break; let nextWordEnd = nextWordStart + 1; diff --git a/tsconfig.json b/tsconfig.json index 6a76ce66..ce33e060 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,9 +9,5 @@ "allowJs": false, "types": ["node"] }, - "include": [ - "*.ts", - "script/**/*.ts", - "test/**/*.ts" - ] + "include": ["*.ts", "script/**/*.ts", "test/**/*.ts"] } diff --git a/word-boundary-cache.ts b/word-boundary-cache.ts index 88557887..544569dc 100644 --- a/word-boundary-cache.ts +++ b/word-boundary-cache.ts @@ -52,7 +52,7 @@ function buildWordBoundaryData( charTypes[i] = getCharType(line[i], semanticClass); } - for (let runStart = 0; runStart < len;) { + for (let runStart = 0; runStart < len; ) { const runType = charTypes[runStart] ?? CharType.Space; let runEnd = runStart; while (runEnd + 1 < len && charTypes[runEnd + 1] === runType) { @@ -153,9 +153,10 @@ export class WordBoundaryCache { private readonly maxEntries: number; constructor(maxEntries: number = DEFAULT_MAX_CACHE_ENTRIES) { - this.maxEntries = Number.isInteger(maxEntries) && maxEntries > 0 - ? maxEntries - : DEFAULT_MAX_CACHE_ENTRIES; + this.maxEntries = + Number.isInteger(maxEntries) && maxEntries > 0 + ? maxEntries + : DEFAULT_MAX_CACHE_ENTRIES; } private makeCacheKey(line: string, semanticClass: WordMotionClass): string {