/** * Vim Operator Functions * * Pure functions for executing vim operators (delete, change, yank, etc.) */ import { Cursor } from '../utils/Cursor.js' import { firstGrapheme, lastGrapheme } from '../utils/intl.js' import { countCharInString } from '../utils/stringUtils.js' import { isInclusiveMotion, isLinewiseMotion, resolveMotion, } from './motions.js' import { findTextObject } from './textObjects.js' import type { FindType, Operator, RecordedChange, TextObjScope, } from './types.js' /** * Context for operator execution. */ export type OperatorContext = { cursor: Cursor text: string setText: (text: string) => void setOffset: (offset: number) => void enterInsert: (offset: number) => void getRegister: () => string setRegister: (content: string, linewise: boolean) => void getLastFind: () => { type: FindType; char: string } | null setLastFind: (type: FindType, char: string) => void recordChange: (change: RecordedChange) => void } /** * Execute an operator with a simple motion. */ export function executeOperatorMotion( op: Operator, motion: string, count: number, ctx: OperatorContext, ): void { const target = resolveMotion(motion, ctx.cursor, count) if (target.equals(ctx.cursor)) return const range = getOperatorRange(ctx.cursor, target, motion, op, count) applyOperator(op, range.from, range.to, ctx, range.linewise) ctx.recordChange({ type: 'operator', op, motion, count }) } /** * Execute an operator with a find motion. */ export function executeOperatorFind( op: Operator, findType: FindType, char: string, count: number, ctx: OperatorContext, ): void { const targetOffset = ctx.cursor.findCharacter(char, findType, count) if (targetOffset === null) return const target = new Cursor(ctx.cursor.measuredText, targetOffset) const range = getOperatorRangeForFind(ctx.cursor, target, findType) applyOperator(op, range.from, range.to, ctx) ctx.setLastFind(findType, char) ctx.recordChange({ type: 'operatorFind', op, find: findType, char, count }) } /** * Execute an operator with a text object. */ export function executeOperatorTextObj( op: Operator, scope: TextObjScope, objType: string, count: number, ctx: OperatorContext, ): void { const range = findTextObject( ctx.text, ctx.cursor.offset, objType, scope === 'inner', ) if (!range) return applyOperator(op, range.start, range.end, ctx) ctx.recordChange({ type: 'operatorTextObj', op, objType, scope, count }) } /** * Execute a line operation (dd, cc, yy). */ export function executeLineOp( op: Operator, count: number, ctx: OperatorContext, ): void { const text = ctx.text const lines = text.split('\n') // Calculate logical line by counting newlines before cursor offset // (cursor.getPosition() returns wrapped line which is wrong for this) const currentLine = countCharInString(text.slice(0, ctx.cursor.offset), '\n') const linesToAffect = Math.min(count, lines.length - currentLine) const lineStart = ctx.cursor.startOfLogicalLine().offset let lineEnd = lineStart for (let i = 0; i < linesToAffect; i++) { const nextNewline = text.indexOf('\n', lineEnd) lineEnd = nextNewline === -1 ? text.length : nextNewline + 1 } let content = text.slice(lineStart, lineEnd) // Ensure linewise content ends with newline for paste detection if (!content.endsWith('\n')) { content = content + '\n' } ctx.setRegister(content, true) if (op === 'yank') { ctx.setOffset(lineStart) } else if (op === 'delete') { let deleteStart = lineStart const deleteEnd = lineEnd // If deleting to end of file and there's a preceding newline, include it // This ensures deleting the last line doesn't leave a trailing newline if ( deleteEnd === text.length && deleteStart > 0 && text[deleteStart - 1] === '\n' ) { deleteStart -= 1 } const newText = text.slice(0, deleteStart) + text.slice(deleteEnd) ctx.setText(newText || '') const maxOff = Math.max( 0, newText.length - (lastGrapheme(newText).length || 1), ) ctx.setOffset(Math.min(deleteStart, maxOff)) } else if (op === 'change') { // For single line, just clear it if (lines.length === 1) { ctx.setText('') ctx.enterInsert(0) } else { // Delete all affected lines, replace with single empty line, enter insert const beforeLines = lines.slice(0, currentLine) const afterLines = lines.slice(currentLine + linesToAffect) const newText = [...beforeLines, '', ...afterLines].join('\n') ctx.setText(newText) ctx.enterInsert(lineStart) } } ctx.recordChange({ type: 'operator', op, motion: op[0]!, count }) } /** * Execute delete character (x command). */ export function executeX(count: number, ctx: OperatorContext): void { const from = ctx.cursor.offset if (from >= ctx.text.length) return // Advance by graphemes, not code units let endCursor = ctx.cursor for (let i = 0; i < count && !endCursor.isAtEnd(); i++) { endCursor = endCursor.right() } const to = endCursor.offset const deleted = ctx.text.slice(from, to) const newText = ctx.text.slice(0, from) + ctx.text.slice(to) ctx.setRegister(deleted, false) ctx.setText(newText) const maxOff = Math.max( 0, newText.length - (lastGrapheme(newText).length || 1), ) ctx.setOffset(Math.min(from, maxOff)) ctx.recordChange({ type: 'x', count }) } /** * Execute replace character (r command). */ export function executeReplace( char: string, count: number, ctx: OperatorContext, ): void { let offset = ctx.cursor.offset let newText = ctx.text for (let i = 0; i < count && offset < newText.length; i++) { const graphemeLen = firstGrapheme(newText.slice(offset)).length || 1 newText = newText.slice(0, offset) + char + newText.slice(offset + graphemeLen) offset += char.length } ctx.setText(newText) ctx.setOffset(Math.max(0, offset - char.length)) ctx.recordChange({ type: 'replace', char, count }) } /** * Execute toggle case (~ command). */ export function executeToggleCase(count: number, ctx: OperatorContext): void { const startOffset = ctx.cursor.offset if (startOffset >= ctx.text.length) return let newText = ctx.text let offset = startOffset let toggled = 0 while (offset < newText.length && toggled < count) { const grapheme = firstGrapheme(newText.slice(offset)) const graphemeLen = grapheme.length const toggledGrapheme = grapheme === grapheme.toUpperCase() ? grapheme.toLowerCase() : grapheme.toUpperCase() newText = newText.slice(0, offset) + toggledGrapheme + newText.slice(offset + graphemeLen) offset += toggledGrapheme.length toggled++ } ctx.setText(newText) // Cursor moves to position after the last toggled character // At end of line, cursor can be at the "end" position ctx.setOffset(offset) ctx.recordChange({ type: 'toggleCase', count }) } /** * Execute join lines (J command). */ export function executeJoin(count: number, ctx: OperatorContext): void { const text = ctx.text const lines = text.split('\n') const { line: currentLine } = ctx.cursor.getPosition() if (currentLine >= lines.length - 1) return const linesToJoin = Math.min(count, lines.length - currentLine - 1) let joinedLine = lines[currentLine]! const cursorPos = joinedLine.length for (let i = 1; i <= linesToJoin; i++) { const nextLine = (lines[currentLine + i] ?? '').trimStart() if (nextLine.length > 0) { if (!joinedLine.endsWith(' ') && joinedLine.length > 0) { joinedLine += ' ' } joinedLine += nextLine } } const newLines = [ ...lines.slice(0, currentLine), joinedLine, ...lines.slice(currentLine + linesToJoin + 1), ] const newText = newLines.join('\n') ctx.setText(newText) ctx.setOffset(getLineStartOffset(newLines, currentLine) + cursorPos) ctx.recordChange({ type: 'join', count }) } /** * Execute paste (p/P command). */ export function executePaste( after: boolean, count: number, ctx: OperatorContext, ): void { const register = ctx.getRegister() if (!register) return const isLinewise = register.endsWith('\n') const content = isLinewise ? register.slice(0, -1) : register if (isLinewise) { const text = ctx.text const lines = text.split('\n') const { line: currentLine } = ctx.cursor.getPosition() const insertLine = after ? currentLine + 1 : currentLine const contentLines = content.split('\n') const repeatedLines: string[] = [] for (let i = 0; i < count; i++) { repeatedLines.push(...contentLines) } const newLines = [ ...lines.slice(0, insertLine), ...repeatedLines, ...lines.slice(insertLine), ] const newText = newLines.join('\n') ctx.setText(newText) ctx.setOffset(getLineStartOffset(newLines, insertLine)) } else { const textToInsert = content.repeat(count) const insertPoint = after && ctx.cursor.offset < ctx.text.length ? ctx.cursor.measuredText.nextOffset(ctx.cursor.offset) : ctx.cursor.offset const newText = ctx.text.slice(0, insertPoint) + textToInsert + ctx.text.slice(insertPoint) const lastGr = lastGrapheme(textToInsert) const newOffset = insertPoint + textToInsert.length - (lastGr.length || 1) ctx.setText(newText) ctx.setOffset(Math.max(insertPoint, newOffset)) } } /** * Execute indent (>> command). */ export function executeIndent( dir: '>' | '<', count: number, ctx: OperatorContext, ): void { const text = ctx.text const lines = text.split('\n') const { line: currentLine } = ctx.cursor.getPosition() const linesToAffect = Math.min(count, lines.length - currentLine) const indent = ' ' // Two spaces for (let i = 0; i < linesToAffect; i++) { const lineIdx = currentLine + i const line = lines[lineIdx] ?? '' if (dir === '>') { lines[lineIdx] = indent + line } else if (line.startsWith(indent)) { lines[lineIdx] = line.slice(indent.length) } else if (line.startsWith('\t')) { lines[lineIdx] = line.slice(1) } else { // Remove as much leading whitespace as possible up to indent length let removed = 0 let idx = 0 while ( idx < line.length && removed < indent.length && /\s/.test(line[idx]!) ) { removed++ idx++ } lines[lineIdx] = line.slice(idx) } } const newText = lines.join('\n') const currentLineText = lines[currentLine] ?? '' const firstNonBlank = (currentLineText.match(/^\s*/)?.[0] ?? '').length ctx.setText(newText) ctx.setOffset(getLineStartOffset(lines, currentLine) + firstNonBlank) ctx.recordChange({ type: 'indent', dir, count }) } /** * Execute open line (o/O command). */ export function executeOpenLine( direction: 'above' | 'below', ctx: OperatorContext, ): void { const text = ctx.text const lines = text.split('\n') const { line: currentLine } = ctx.cursor.getPosition() const insertLine = direction === 'below' ? currentLine + 1 : currentLine const newLines = [ ...lines.slice(0, insertLine), '', ...lines.slice(insertLine), ] const newText = newLines.join('\n') ctx.setText(newText) ctx.enterInsert(getLineStartOffset(newLines, insertLine)) ctx.recordChange({ type: 'openLine', direction }) } // ============================================================================ // Internal Helpers // ============================================================================ /** * Calculate the offset of a line's start position. */ function getLineStartOffset(lines: string[], lineIndex: number): number { return lines.slice(0, lineIndex).join('\n').length + (lineIndex > 0 ? 1 : 0) } function getOperatorRange( cursor: Cursor, target: Cursor, motion: string, op: Operator, count: number, ): { from: number; to: number; linewise: boolean } { let from = Math.min(cursor.offset, target.offset) let to = Math.max(cursor.offset, target.offset) let linewise = false // Special case: cw/cW changes to end of word, not start of next word if (op === 'change' && (motion === 'w' || motion === 'W')) { // For cw with count, move forward (count-1) words, then find end of that word let wordCursor = cursor for (let i = 0; i < count - 1; i++) { wordCursor = motion === 'w' ? wordCursor.nextVimWord() : wordCursor.nextWORD() } const wordEnd = motion === 'w' ? wordCursor.endOfVimWord() : wordCursor.endOfWORD() to = cursor.measuredText.nextOffset(wordEnd.offset) } else if (isLinewiseMotion(motion)) { // Linewise motions extend to include entire lines linewise = true const text = cursor.text const nextNewline = text.indexOf('\n', to) if (nextNewline === -1) { // Deleting to end of file - include the preceding newline if exists to = text.length if (from > 0 && text[from - 1] === '\n') { from -= 1 } } else { to = nextNewline + 1 } } else if (isInclusiveMotion(motion) && cursor.offset <= target.offset) { to = cursor.measuredText.nextOffset(to) } // Word motions can land inside an [Image #N] chip; extend the range to // cover the whole chip so dw/cw/yw never leave a partial placeholder. from = cursor.snapOutOfImageRef(from, 'start') to = cursor.snapOutOfImageRef(to, 'end') return { from, to, linewise } } /** * Get the range for a find-based operator. * Note: _findType is unused because Cursor.findCharacter already adjusts * the offset for t/T motions. All find types are treated as inclusive here. */ function getOperatorRangeForFind( cursor: Cursor, target: Cursor, _findType: FindType, ): { from: number; to: number } { const from = Math.min(cursor.offset, target.offset) const maxOffset = Math.max(cursor.offset, target.offset) const to = cursor.measuredText.nextOffset(maxOffset) return { from, to } } function applyOperator( op: Operator, from: number, to: number, ctx: OperatorContext, linewise: boolean = false, ): void { let content = ctx.text.slice(from, to) // Ensure linewise content ends with newline for paste detection if (linewise && !content.endsWith('\n')) { content = content + '\n' } ctx.setRegister(content, linewise) if (op === 'yank') { ctx.setOffset(from) } else if (op === 'delete') { const newText = ctx.text.slice(0, from) + ctx.text.slice(to) ctx.setText(newText) const maxOff = Math.max( 0, newText.length - (lastGrapheme(newText).length || 1), ) ctx.setOffset(Math.min(from, maxOff)) } else if (op === 'change') { const newText = ctx.text.slice(0, from) + ctx.text.slice(to) ctx.setText(newText) ctx.enterInsert(from) } } export function executeOperatorG( op: Operator, count: number, ctx: OperatorContext, ): void { // count=1 means no count given, target = end of file // otherwise target = line N const target = count === 1 ? ctx.cursor.startOfLastLine() : ctx.cursor.goToLine(count) if (target.equals(ctx.cursor)) return const range = getOperatorRange(ctx.cursor, target, 'G', op, count) applyOperator(op, range.from, range.to, ctx, range.linewise) ctx.recordChange({ type: 'operator', op, motion: 'G', count }) } export function executeOperatorGg( op: Operator, count: number, ctx: OperatorContext, ): void { // count=1 means no count given, target = first line // otherwise target = line N const target = count === 1 ? ctx.cursor.startOfFirstLine() : ctx.cursor.goToLine(count) if (target.equals(ctx.cursor)) return const range = getOperatorRange(ctx.cursor, target, 'gg', op, count) applyOperator(op, range.from, range.to, ctx, range.linewise) ctx.recordChange({ type: 'operator', op, motion: 'gg', count }) }