557 lines
16 KiB
TypeScript
557 lines
16 KiB
TypeScript
/**
|
|
* 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 })
|
|
}
|