import { isValidPosition, normalizePromptText, type Position, type PromptBuffer, } from "./buffer.js"; import { isSegmentBoundary, segmentPromptText } from "./segmentation.js"; /** An end-exclusive range in logical prompt coordinates. */ export type TextRange = { start: Position; end: Position; }; export type ReplaceRange = TextRange & { text: string; }; export type EditTransaction = { type: "replace-range"; range: TextRange; text: string; }; function comparePositions(left: Position, right: Position): number { if (left.line !== right.line) return left.line - right.line; return left.column - right.column; } function isCursorSafePosition( buffer: PromptBuffer, position: Position, ): boolean { if (!isValidPosition(buffer, position)) return false; const line = buffer.lines[position.line]; return ( line !== undefined && isSegmentBoundary( segmentPromptText(line, buffer.pasteMarkerIds), position.column, ) ); } function assertValidRange(buffer: PromptBuffer, range: TextRange): void { if ( !isCursorSafePosition(buffer, range.start) || !isCursorSafePosition(buffer, range.end) ) { throw new RangeError("Edit range must use cursor-safe prompt boundaries"); } if (comparePositions(range.start, range.end) > 0) { throw new RangeError("Edit range start must not be after its end"); } } /** * Replace an end-exclusive logical range and place the cursor immediately * after the replacement. This is the only text mutation primitive exposed to * higher-level vi commands. */ export function replaceRange( buffer: PromptBuffer, replacement: ReplaceRange, ): PromptBuffer { const { start, end, text } = replacement; assertValidRange(buffer, { start, end }); const startLine = buffer.lines[start.line]; const endLine = buffer.lines[end.line]; if (startLine === undefined || endLine === undefined) { throw new RangeError("Edit range is outside the prompt buffer"); } const prefix = startLine.slice(0, start.column); const suffix = endLine.slice(end.column); const insertedLines = normalizePromptText(text).split("\n"); const firstInsertedLine = insertedLines[0]; const lastInsertedLine = insertedLines.at(-1); if (firstInsertedLine === undefined || lastInsertedLine === undefined) { throw new Error("Replacement text must contain at least one logical line"); } const lines = [ ...buffer.lines.slice(0, start.line), prefix + firstInsertedLine, ...insertedLines.slice(1, -1), ...(insertedLines.length === 1 ? [] : [lastInsertedLine]), ...buffer.lines.slice(end.line + 1), ]; const finalLine = start.line + insertedLines.length - 1; const cursorColumn = insertedLines.length === 1 ? prefix.length + firstInsertedLine.length : lastInsertedLine.length; const resultLines = [...lines]; resultLines[finalLine] = `${resultLines[finalLine] ?? ""}${suffix}`; return { lines: resultLines, cursor: { line: finalLine, column: cursorColumn }, }; } /** Apply a typed atomic edit transaction. */ export function applyEditTransaction( buffer: PromptBuffer, transaction: EditTransaction, ): PromptBuffer { switch (transaction.type) { case "replace-range": return replaceRange(buffer, { ...transaction.range, text: transaction.text, }); } }