import type { PromptBuffer } from "./buffer.js"; import { moveToNextWord } from "./motions.js"; import { segmentPromptText } from "./segmentation.js"; import { replaceRange } from "./transactions.js"; export type OpenLinePlacement = "above" | "below"; /** Open an empty logical line above or below the command cursor line. */ export function openCommandLine( buffer: PromptBuffer, placement: OpenLinePlacement, ): PromptBuffer { const insertionLine = buffer.cursor.line + (placement === "below" ? 1 : 0); return { ...buffer, lines: [ ...buffer.lines.slice(0, insertionLine), "", ...buffer.lines.slice(insertionLine), ], cursor: { line: insertionLine, column: 0 }, }; } /** Implement Readline vi-delete (`x`) for one logical prompt line. */ export function deleteCommandCharacters( buffer: PromptBuffer, count = 1, ): PromptBuffer { if (!Number.isInteger(count) || count < 1) { throw new RangeError("Command deletion count must be a positive integer"); } const line = buffer.lines[buffer.cursor.line]; if (line === undefined) return buffer; const segments = segmentPromptText(line, buffer.pasteMarkerIds); const startIndex = segments.findIndex( (segment) => segment.start === buffer.cursor.column, ); if (startIndex < 0) return buffer; const finalSegment = segments[Math.min(startIndex + count - 1, segments.length - 1)]; if (finalSegment === undefined) return buffer; const result = replaceRange(buffer, { start: buffer.cursor, end: { line: buffer.cursor.line, column: finalSegment.end }, text: "", }); const resultLine = result.lines[result.cursor.line]; if (resultLine === undefined || result.cursor.column < resultLine.length) return result; const last = segmentPromptText(resultLine, result.pasteMarkerIds).at(-1); return last === undefined ? result : { ...result, cursor: { line: result.cursor.line, column: last.start } }; } /** Implement vi delete-word (`dw`) using the next-word motion as an exclusive range. */ export function deleteToNextWord(buffer: PromptBuffer): PromptBuffer { const destination = moveToNextWord(buffer).cursor; if ( destination.line === buffer.cursor.line && destination.column === buffer.cursor.column ) return buffer; return replaceRange(buffer, { start: buffer.cursor, end: destination, text: "", }); } /** Implement vi-replace-char (`r`) over a counted run of complete segments. */ export function replaceCommandCharacters( buffer: PromptBuffer, text: string, count = 1, ): PromptBuffer { const line = buffer.lines[buffer.cursor.line]; if (line === undefined || count < 1) return buffer; const segments = segmentPromptText(line, buffer.pasteMarkerIds); const startIndex = segments.findIndex( (segment) => segment.start === buffer.cursor.column, ); const finalSegment = segments[startIndex + count - 1]; if (startIndex < 0 || finalSegment === undefined) return buffer; const result = replaceRange(buffer, { start: buffer.cursor, end: { line: buffer.cursor.line, column: finalSegment.end }, text: text.repeat(count), }); const replacements = segmentPromptText( text.repeat(count), buffer.pasteMarkerIds, ); return { ...result, cursor: { line: buffer.cursor.line, column: buffer.cursor.column + (replacements.at(-1)?.start ?? 0), }, }; } /** Replace the segment at the cursor, inserting when positioned at line end. */ export function replaceAtCursor( buffer: PromptBuffer, text: string, ): PromptBuffer { const line = buffer.lines[buffer.cursor.line]; if (line === undefined) return buffer; const segment = segmentPromptText(line, buffer.pasteMarkerIds).find( (candidate) => candidate.start === buffer.cursor.column, ); return replaceRange(buffer, { start: buffer.cursor, end: segment === undefined ? buffer.cursor : { line: buffer.cursor.line, column: segment.end }, text, }); } /** Implement vi-change-case (`~`) for the segment at the command cursor. */ export function toggleCommandCharacterCase( buffer: PromptBuffer, count = 1, ): PromptBuffer { const line = buffer.lines[buffer.cursor.line]; if (line === undefined || count < 1) return buffer; const segments = segmentPromptText(line, buffer.pasteMarkerIds); const startIndex = segments.findIndex( (candidate) => candidate.start === buffer.cursor.column, ); if (startIndex < 0) return buffer; const selected = segments.slice(startIndex, startIndex + count); const final = selected.at(-1); if (final === undefined) return buffer; const replacement = selected .map((segment) => { const lower = segment.text.toLocaleLowerCase(); const upper = segment.text.toLocaleUpperCase(); return lower === upper ? segment.text : segment.text === lower ? upper : lower; }) .join(""); if (replacement === selected.map((segment) => segment.text).join("")) return buffer; const result = replaceRange(buffer, { start: buffer.cursor, end: { line: buffer.cursor.line, column: final.end }, text: replacement, }); return { ...result, cursor: buffer.cursor }; } /** Implement Readline vi-rubout (`X`) for the segment before the command cursor. */ export function deleteCommandCharactersBackward( buffer: PromptBuffer, count = 1, ): PromptBuffer { if (buffer.cursor.column === 0 || count < 1) return buffer; const line = buffer.lines[buffer.cursor.line]; if (line === undefined) return buffer; const previous = segmentPromptText(line, buffer.pasteMarkerIds).filter( (segment) => segment.end <= buffer.cursor.column, ); const first = previous[Math.max(0, previous.length - count)]; if (first === undefined) return buffer; return replaceRange(buffer, { start: { line: buffer.cursor.line, column: first.start }, end: buffer.cursor, text: "", }); }