init claude-code
This commit is contained in:
@@ -0,0 +1,556 @@
|
||||
/**
|
||||
* 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 })
|
||||
}
|
||||
Reference in New Issue
Block a user