🎯 Use case
This file lives under “vim/”, which covers Vim-style modal editing integrations. On the API surface it exposes OperatorContext, executeOperatorMotion, executeOperatorFind, executeOperatorTextObj, and executeLineOp (and more) — mainly functions, hooks, or classes. It composes internal code from utils, motions, textObjects, and types (relative imports). What the file header says: Vim Operator Functions Pure functions for executing vim operators (delete, change, yank, etc.).
Generated from folder role, exports, dependency roots, and inline comments — not hand-reviewed for every path.
🧠 Inline summary
Vim Operator Functions Pure functions for executing vim operators (delete, change, yank, etc.)
📤 Exports (heuristic)
OperatorContextexecuteOperatorMotionexecuteOperatorFindexecuteOperatorTextObjexecuteLineOpexecuteXexecuteReplaceexecuteToggleCaseexecuteJoinexecutePasteexecuteIndentexecuteOpenLineexecuteOperatorGexecuteOperatorGg
🖥️ Source preview
/**
* 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 })
}