import type { PromptBuffer } from "./buffer.js"; import { segmentPromptText } from "./segmentation.js"; function withCursor(buffer: PromptBuffer, column: number): PromptBuffer { return { ...buffer, cursor: { line: buffer.cursor.line, column } }; } function lineSegments(buffer: PromptBuffer) { const line = buffer.lines[buffer.cursor.line] ?? ""; return segmentPromptText(line, buffer.pasteMarkerIds); } /** Move left one complete grapheme/paste-marker unit in command mode. */ export function moveCommandCursorLeft(buffer: PromptBuffer): PromptBuffer { const previous = lineSegments(buffer) .filter((segment) => segment.end <= buffer.cursor.column) .at(-1); return withCursor(buffer, previous?.start ?? 0); } /** Move right one complete grapheme/paste-marker unit in command mode. */ export function moveCommandCursorRight(buffer: PromptBuffer): PromptBuffer { const next = lineSegments(buffer).find( (segment) => segment.start > buffer.cursor.column, ); return withCursor(buffer, next?.start ?? buffer.cursor.column); } const MATCHING_DELIMITERS: Readonly> = { "(": ")", "[": "]", "{": "}", ")": "(", "]": "[", "}": "{", }; /** Implement vi-match (`%`) with nested delimiter tracking. */ export function moveToMatchingDelimiter(buffer: PromptBuffer): PromptBuffer { const segments = lineSegments(buffer); const index = segments.findIndex( (segment) => segment.start === buffer.cursor.column, ); const source = segments[index]?.text; if (source === undefined) return buffer; const target = MATCHING_DELIMITERS[source]; if (target === undefined) return buffer; const forward = source === "(" || source === "[" || source === "{"; let depth = 0; for ( let candidate = index + (forward ? 1 : -1); candidate >= 0 && candidate < segments.length; candidate += forward ? 1 : -1 ) { const text = segments[candidate]!.text; if (text === source) depth += 1; if (text === target) { if (depth === 0) return withCursor(buffer, segments[candidate]!.start); depth -= 1; } } return buffer; } export type CharacterSearchDirection = "forward" | "backward"; /** Find one grapheme target on the current line for vi f/F/t/T motions. */ export function findCommandCharacter( buffer: PromptBuffer, target: string, direction: CharacterSearchDirection, till = false, ): PromptBuffer { const segments = lineSegments(buffer); const cursorIndex = segments.findIndex( (segment) => segment.start === buffer.cursor.column, ); if (cursorIndex < 0) return buffer; const candidates = direction === "forward" ? segments.slice(cursorIndex + 1) : segments.slice(0, cursorIndex).reverse(); const match = candidates.find((segment) => segment.text === target); if (match === undefined) return buffer; if (!till) return withCursor(buffer, match.start); const matchIndex = segments.indexOf(match); const tillIndex = direction === "forward" ? matchIndex - 1 : matchIndex + 1; return withCursor(buffer, segments[tillIndex]?.start ?? buffer.cursor.column); } type WordClass = "blank" | "word" | "punctuation"; function wordClass(text: string): WordClass { if (/^\s+$/u.test(text)) return "blank"; return /^[\p{L}\p{N}_]+$/u.test(text) ? "word" : "punctuation"; } /** Implement Readline vi-end-word (`E`) with blanks as its only delimiter. */ export function moveToEndOfBigWord(buffer: PromptBuffer): PromptBuffer { const segments = lineSegments(buffer); let index = segments.findIndex( (segment) => segment.start === buffer.cursor.column, ); const startedOnBigWord = index >= 0 && wordClass(segments[index]!.text) !== "blank"; while ( index >= 0 && index < segments.length && wordClass(segments[index]!.text) === "blank" ) index += 1; if (index < 0 || index >= segments.length) return buffer; if ( startedOnBigWord && (index + 1 === segments.length || wordClass(segments[index + 1]!.text) === "blank") ) { index += 1; while ( index < segments.length && wordClass(segments[index]!.text) === "blank" ) index += 1; } if (index >= segments.length) return buffer; while ( index + 1 < segments.length && wordClass(segments[index + 1]!.text) !== "blank" ) index += 1; return withCursor(buffer, segments[index]!.start); } /** Implement Readline vi-next-word (`W`) with blanks as its only delimiter. */ export function moveToNextBigWord(buffer: PromptBuffer): PromptBuffer { const segments = lineSegments(buffer); let index = segments.findIndex( (segment) => segment.start === buffer.cursor.column, ); if (index < 0) return buffer; while ( index < segments.length && wordClass(segments[index]!.text) !== "blank" ) index += 1; while ( index < segments.length && wordClass(segments[index]!.text) === "blank" ) index += 1; return withCursor(buffer, segments[index]?.start ?? buffer.cursor.column); } /** Implement Readline vi-prev-word (`B`) with blanks as its only delimiter. */ export function moveToPreviousBigWord(buffer: PromptBuffer): PromptBuffer { const segments = lineSegments(buffer); let index = segments.findIndex((segment) => segment.start === buffer.cursor.column) - 1; while (index >= 0 && wordClass(segments[index]!.text) === "blank") index -= 1; while (index > 0 && wordClass(segments[index - 1]!.text) !== "blank") index -= 1; return withCursor(buffer, segments[index]?.start ?? 0); } /** Implement Readline vi-next-word (`w`) over complete prompt segments. */ export function moveToNextWord(buffer: PromptBuffer): PromptBuffer { const segments = lineSegments(buffer); let index = segments.findIndex( (segment) => segment.start === buffer.cursor.column, ); if (index < 0) return buffer; const currentClass = wordClass(segments[index]!.text); while ( index < segments.length && wordClass(segments[index]!.text) === currentClass ) index += 1; while ( index < segments.length && wordClass(segments[index]!.text) === "blank" ) index += 1; return withCursor(buffer, segments[index]?.start ?? buffer.cursor.column); } /** Implement Readline vi-end-word (`e`) over complete prompt segments. */ export function moveToEndOfWord(buffer: PromptBuffer): PromptBuffer { const segments = lineSegments(buffer); let index = segments.findIndex( (segment) => segment.start === buffer.cursor.column, ); if (index < 0) return buffer; const startedOnWord = wordClass(segments[index]!.text) === "word"; while (index < segments.length && wordClass(segments[index]!.text) !== "word") index += 1; if (index === segments.length) return buffer; if ( startedOnWord && (index + 1 === segments.length || wordClass(segments[index + 1]!.text) !== "word") ) { index += 1; while ( index < segments.length && wordClass(segments[index]!.text) !== "word" ) index += 1; } if (index === segments.length) return buffer; const currentClass = wordClass(segments[index]!.text); while ( index + 1 < segments.length && wordClass(segments[index + 1]!.text) === currentClass ) index += 1; return withCursor(buffer, segments[index]!.start); } /** Implement Readline vi-prev-word (`b`) over complete prompt segments. */ export function moveToPreviousWord(buffer: PromptBuffer): PromptBuffer { const segments = lineSegments(buffer); let index = segments.findIndex((segment) => segment.start === buffer.cursor.column) - 1; if (index < 0) return withCursor(buffer, 0); while (index >= 0 && wordClass(segments[index]!.text) === "blank") index -= 1; const previousClass = wordClass(segments[index]?.text ?? ""); while (index > 0 && wordClass(segments[index - 1]!.text) === previousClass) index -= 1; return withCursor(buffer, segments[index]?.start ?? 0); } /** Move between logical prompt lines while preserving a safe command cursor. */ export function moveCommandCursorVertical( buffer: PromptBuffer, direction: "up" | "down", ): PromptBuffer { const targetLine = Math.max( 0, Math.min( buffer.lines.length - 1, buffer.cursor.line + (direction === "up" ? -1 : 1), ), ); if (targetLine === buffer.cursor.line) return buffer; const segments = segmentPromptText( buffer.lines[targetLine] ?? "", buffer.pasteMarkerIds, ); const column = segments .filter((segment) => segment.start <= buffer.cursor.column) .at(-1)?.start; return { ...buffer, cursor: { line: targetLine, column: column ?? 0 }, }; } export function moveToCommandLineStart(buffer: PromptBuffer): PromptBuffer { return withCursor(buffer, 0); } /** Implement vi-first-print (`^`) for the current logical prompt line. */ export function moveToCommandFirstPrintable( buffer: PromptBuffer, ): PromptBuffer { const first = lineSegments(buffer).find( (segment) => !/^\s+$/u.test(segment.text), ); return withCursor(buffer, first?.start ?? 0); } /** Move to the final editable segment rather than the insertion-only line end. */ export function moveToCommandLineEnd(buffer: PromptBuffer): PromptBuffer { const last = lineSegments(buffer).at(-1); return withCursor(buffer, last?.start ?? 0); }