mirror of
https://github.com/codeaashu/claude-code.git
synced 2026-04-08 22:28:48 +03:00
claude-code
This commit is contained in:
83
src/vim/motions.ts
Normal file
83
src/vim/motions.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Vim Motion Functions
|
||||
*
|
||||
* Pure functions for resolving vim motions to cursor positions.
|
||||
*/
|
||||
|
||||
import type { Cursor } from '../utils/Cursor.js'
|
||||
|
||||
/**
|
||||
* Resolve a motion to a target cursor position.
|
||||
* Does not modify anything - pure calculation.
|
||||
*/
|
||||
export function resolveMotion(
|
||||
key: string,
|
||||
cursor: Cursor,
|
||||
count: number,
|
||||
): Cursor {
|
||||
let result = cursor
|
||||
for (let i = 0; i < count; i++) {
|
||||
const next = applySingleMotion(key, result)
|
||||
if (next.equals(result)) break
|
||||
result = next
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a single motion step.
|
||||
*/
|
||||
function applySingleMotion(key: string, cursor: Cursor): Cursor {
|
||||
switch (key) {
|
||||
case 'h':
|
||||
return cursor.left()
|
||||
case 'l':
|
||||
return cursor.right()
|
||||
case 'j':
|
||||
return cursor.downLogicalLine()
|
||||
case 'k':
|
||||
return cursor.upLogicalLine()
|
||||
case 'gj':
|
||||
return cursor.down()
|
||||
case 'gk':
|
||||
return cursor.up()
|
||||
case 'w':
|
||||
return cursor.nextVimWord()
|
||||
case 'b':
|
||||
return cursor.prevVimWord()
|
||||
case 'e':
|
||||
return cursor.endOfVimWord()
|
||||
case 'W':
|
||||
return cursor.nextWORD()
|
||||
case 'B':
|
||||
return cursor.prevWORD()
|
||||
case 'E':
|
||||
return cursor.endOfWORD()
|
||||
case '0':
|
||||
return cursor.startOfLogicalLine()
|
||||
case '^':
|
||||
return cursor.firstNonBlankInLogicalLine()
|
||||
case '$':
|
||||
return cursor.endOfLogicalLine()
|
||||
case 'G':
|
||||
return cursor.startOfLastLine()
|
||||
default:
|
||||
return cursor
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a motion is inclusive (includes character at destination).
|
||||
*/
|
||||
export function isInclusiveMotion(key: string): boolean {
|
||||
return 'eE$'.includes(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a motion is linewise (operates on full lines when used with operators).
|
||||
* Note: gj/gk are characterwise exclusive per `:help gj`, not linewise.
|
||||
*/
|
||||
export function isLinewiseMotion(key: string): boolean {
|
||||
return 'jkG'.includes(key) || key === 'gg'
|
||||
}
|
||||
|
||||
557
src/vim/operators.ts
Normal file
557
src/vim/operators.ts
Normal file
@@ -0,0 +1,557 @@
|
||||
/**
|
||||
* 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 })
|
||||
}
|
||||
|
||||
187
src/vim/textObjects.ts
Normal file
187
src/vim/textObjects.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Vim Text Object Finding
|
||||
*
|
||||
* Functions for finding text object boundaries (iw, aw, i", a(, etc.)
|
||||
*/
|
||||
|
||||
import {
|
||||
isVimPunctuation,
|
||||
isVimWhitespace,
|
||||
isVimWordChar,
|
||||
} from '../utils/Cursor.js'
|
||||
import { getGraphemeSegmenter } from '../utils/intl.js'
|
||||
|
||||
export type TextObjectRange = { start: number; end: number } | null
|
||||
|
||||
/**
|
||||
* Delimiter pairs for text objects.
|
||||
*/
|
||||
const PAIRS: Record<string, [string, string]> = {
|
||||
'(': ['(', ')'],
|
||||
')': ['(', ')'],
|
||||
b: ['(', ')'],
|
||||
'[': ['[', ']'],
|
||||
']': ['[', ']'],
|
||||
'{': ['{', '}'],
|
||||
'}': ['{', '}'],
|
||||
B: ['{', '}'],
|
||||
'<': ['<', '>'],
|
||||
'>': ['<', '>'],
|
||||
'"': ['"', '"'],
|
||||
"'": ["'", "'"],
|
||||
'`': ['`', '`'],
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a text object at the given position.
|
||||
*/
|
||||
export function findTextObject(
|
||||
text: string,
|
||||
offset: number,
|
||||
objectType: string,
|
||||
isInner: boolean,
|
||||
): TextObjectRange {
|
||||
if (objectType === 'w')
|
||||
return findWordObject(text, offset, isInner, isVimWordChar)
|
||||
if (objectType === 'W')
|
||||
return findWordObject(text, offset, isInner, ch => !isVimWhitespace(ch))
|
||||
|
||||
const pair = PAIRS[objectType]
|
||||
if (pair) {
|
||||
const [open, close] = pair
|
||||
return open === close
|
||||
? findQuoteObject(text, offset, open, isInner)
|
||||
: findBracketObject(text, offset, open, close, isInner)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function findWordObject(
|
||||
text: string,
|
||||
offset: number,
|
||||
isInner: boolean,
|
||||
isWordChar: (ch: string) => boolean,
|
||||
): TextObjectRange {
|
||||
// Pre-segment into graphemes for grapheme-safe iteration
|
||||
const graphemes: Array<{ segment: string; index: number }> = []
|
||||
for (const { segment, index } of getGraphemeSegmenter().segment(text)) {
|
||||
graphemes.push({ segment, index })
|
||||
}
|
||||
|
||||
// Find which grapheme index the offset falls in
|
||||
let graphemeIdx = graphemes.length - 1
|
||||
for (let i = 0; i < graphemes.length; i++) {
|
||||
const g = graphemes[i]!
|
||||
const nextStart =
|
||||
i + 1 < graphemes.length ? graphemes[i + 1]!.index : text.length
|
||||
if (offset >= g.index && offset < nextStart) {
|
||||
graphemeIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const graphemeAt = (idx: number): string => graphemes[idx]?.segment ?? ''
|
||||
const offsetAt = (idx: number): number =>
|
||||
idx < graphemes.length ? graphemes[idx]!.index : text.length
|
||||
const isWs = (idx: number): boolean => isVimWhitespace(graphemeAt(idx))
|
||||
const isWord = (idx: number): boolean => isWordChar(graphemeAt(idx))
|
||||
const isPunct = (idx: number): boolean => isVimPunctuation(graphemeAt(idx))
|
||||
|
||||
let startIdx = graphemeIdx
|
||||
let endIdx = graphemeIdx
|
||||
|
||||
if (isWord(graphemeIdx)) {
|
||||
while (startIdx > 0 && isWord(startIdx - 1)) startIdx--
|
||||
while (endIdx < graphemes.length && isWord(endIdx)) endIdx++
|
||||
} else if (isWs(graphemeIdx)) {
|
||||
while (startIdx > 0 && isWs(startIdx - 1)) startIdx--
|
||||
while (endIdx < graphemes.length && isWs(endIdx)) endIdx++
|
||||
return { start: offsetAt(startIdx), end: offsetAt(endIdx) }
|
||||
} else if (isPunct(graphemeIdx)) {
|
||||
while (startIdx > 0 && isPunct(startIdx - 1)) startIdx--
|
||||
while (endIdx < graphemes.length && isPunct(endIdx)) endIdx++
|
||||
}
|
||||
|
||||
if (!isInner) {
|
||||
// Include surrounding whitespace
|
||||
if (endIdx < graphemes.length && isWs(endIdx)) {
|
||||
while (endIdx < graphemes.length && isWs(endIdx)) endIdx++
|
||||
} else if (startIdx > 0 && isWs(startIdx - 1)) {
|
||||
while (startIdx > 0 && isWs(startIdx - 1)) startIdx--
|
||||
}
|
||||
}
|
||||
|
||||
return { start: offsetAt(startIdx), end: offsetAt(endIdx) }
|
||||
}
|
||||
|
||||
function findQuoteObject(
|
||||
text: string,
|
||||
offset: number,
|
||||
quote: string,
|
||||
isInner: boolean,
|
||||
): TextObjectRange {
|
||||
const lineStart = text.lastIndexOf('\n', offset - 1) + 1
|
||||
const lineEnd = text.indexOf('\n', offset)
|
||||
const effectiveEnd = lineEnd === -1 ? text.length : lineEnd
|
||||
const line = text.slice(lineStart, effectiveEnd)
|
||||
const posInLine = offset - lineStart
|
||||
|
||||
const positions: number[] = []
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
if (line[i] === quote) positions.push(i)
|
||||
}
|
||||
|
||||
// Pair quotes correctly: 0-1, 2-3, 4-5, etc.
|
||||
for (let i = 0; i < positions.length - 1; i += 2) {
|
||||
const qs = positions[i]!
|
||||
const qe = positions[i + 1]!
|
||||
if (qs <= posInLine && posInLine <= qe) {
|
||||
return isInner
|
||||
? { start: lineStart + qs + 1, end: lineStart + qe }
|
||||
: { start: lineStart + qs, end: lineStart + qe + 1 }
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function findBracketObject(
|
||||
text: string,
|
||||
offset: number,
|
||||
open: string,
|
||||
close: string,
|
||||
isInner: boolean,
|
||||
): TextObjectRange {
|
||||
let depth = 0
|
||||
let start = -1
|
||||
|
||||
for (let i = offset; i >= 0; i--) {
|
||||
if (text[i] === close && i !== offset) depth++
|
||||
else if (text[i] === open) {
|
||||
if (depth === 0) {
|
||||
start = i
|
||||
break
|
||||
}
|
||||
depth--
|
||||
}
|
||||
}
|
||||
if (start === -1) return null
|
||||
|
||||
depth = 0
|
||||
let end = -1
|
||||
for (let i = start + 1; i < text.length; i++) {
|
||||
if (text[i] === open) depth++
|
||||
else if (text[i] === close) {
|
||||
if (depth === 0) {
|
||||
end = i
|
||||
break
|
||||
}
|
||||
depth--
|
||||
}
|
||||
}
|
||||
if (end === -1) return null
|
||||
|
||||
return isInner ? { start: start + 1, end } : { start, end: end + 1 }
|
||||
}
|
||||
|
||||
491
src/vim/transitions.ts
Normal file
491
src/vim/transitions.ts
Normal file
@@ -0,0 +1,491 @@
|
||||
/**
|
||||
* Vim State Transition Table
|
||||
*
|
||||
* This is the scannable source of truth for state transitions.
|
||||
* To understand what happens in any state, look up that state's transition function.
|
||||
*/
|
||||
|
||||
import { resolveMotion } from './motions.js'
|
||||
import {
|
||||
executeIndent,
|
||||
executeJoin,
|
||||
executeLineOp,
|
||||
executeOpenLine,
|
||||
executeOperatorFind,
|
||||
executeOperatorG,
|
||||
executeOperatorGg,
|
||||
executeOperatorMotion,
|
||||
executeOperatorTextObj,
|
||||
executePaste,
|
||||
executeReplace,
|
||||
executeToggleCase,
|
||||
executeX,
|
||||
type OperatorContext,
|
||||
} from './operators.js'
|
||||
import {
|
||||
type CommandState,
|
||||
FIND_KEYS,
|
||||
type FindType,
|
||||
isOperatorKey,
|
||||
isTextObjScopeKey,
|
||||
MAX_VIM_COUNT,
|
||||
OPERATORS,
|
||||
type Operator,
|
||||
SIMPLE_MOTIONS,
|
||||
TEXT_OBJ_SCOPES,
|
||||
TEXT_OBJ_TYPES,
|
||||
type TextObjScope,
|
||||
} from './types.js'
|
||||
|
||||
/**
|
||||
* Context passed to transition functions.
|
||||
*/
|
||||
export type TransitionContext = OperatorContext & {
|
||||
onUndo?: () => void
|
||||
onDotRepeat?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a transition.
|
||||
*/
|
||||
export type TransitionResult = {
|
||||
next?: CommandState
|
||||
execute?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Main transition function. Dispatches based on current state type.
|
||||
*/
|
||||
export function transition(
|
||||
state: CommandState,
|
||||
input: string,
|
||||
ctx: TransitionContext,
|
||||
): TransitionResult {
|
||||
switch (state.type) {
|
||||
case 'idle':
|
||||
return fromIdle(input, ctx)
|
||||
case 'count':
|
||||
return fromCount(state, input, ctx)
|
||||
case 'operator':
|
||||
return fromOperator(state, input, ctx)
|
||||
case 'operatorCount':
|
||||
return fromOperatorCount(state, input, ctx)
|
||||
case 'operatorFind':
|
||||
return fromOperatorFind(state, input, ctx)
|
||||
case 'operatorTextObj':
|
||||
return fromOperatorTextObj(state, input, ctx)
|
||||
case 'find':
|
||||
return fromFind(state, input, ctx)
|
||||
case 'g':
|
||||
return fromG(state, input, ctx)
|
||||
case 'operatorG':
|
||||
return fromOperatorG(state, input, ctx)
|
||||
case 'replace':
|
||||
return fromReplace(state, input, ctx)
|
||||
case 'indent':
|
||||
return fromIndent(state, input, ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Shared Input Handling
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Handle input that's valid in both idle and count states.
|
||||
* Returns null if input is not recognized.
|
||||
*/
|
||||
function handleNormalInput(
|
||||
input: string,
|
||||
count: number,
|
||||
ctx: TransitionContext,
|
||||
): TransitionResult | null {
|
||||
if (isOperatorKey(input)) {
|
||||
return { next: { type: 'operator', op: OPERATORS[input], count } }
|
||||
}
|
||||
|
||||
if (SIMPLE_MOTIONS.has(input)) {
|
||||
return {
|
||||
execute: () => {
|
||||
const target = resolveMotion(input, ctx.cursor, count)
|
||||
ctx.setOffset(target.offset)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (FIND_KEYS.has(input)) {
|
||||
return { next: { type: 'find', find: input as FindType, count } }
|
||||
}
|
||||
|
||||
if (input === 'g') return { next: { type: 'g', count } }
|
||||
if (input === 'r') return { next: { type: 'replace', count } }
|
||||
if (input === '>' || input === '<') {
|
||||
return { next: { type: 'indent', dir: input, count } }
|
||||
}
|
||||
if (input === '~') {
|
||||
return { execute: () => executeToggleCase(count, ctx) }
|
||||
}
|
||||
if (input === 'x') {
|
||||
return { execute: () => executeX(count, ctx) }
|
||||
}
|
||||
if (input === 'J') {
|
||||
return { execute: () => executeJoin(count, ctx) }
|
||||
}
|
||||
if (input === 'p' || input === 'P') {
|
||||
return { execute: () => executePaste(input === 'p', count, ctx) }
|
||||
}
|
||||
if (input === 'D') {
|
||||
return { execute: () => executeOperatorMotion('delete', '$', 1, ctx) }
|
||||
}
|
||||
if (input === 'C') {
|
||||
return { execute: () => executeOperatorMotion('change', '$', 1, ctx) }
|
||||
}
|
||||
if (input === 'Y') {
|
||||
return { execute: () => executeLineOp('yank', count, ctx) }
|
||||
}
|
||||
if (input === 'G') {
|
||||
return {
|
||||
execute: () => {
|
||||
// count=1 means no count given, go to last line
|
||||
// otherwise go to line N
|
||||
if (count === 1) {
|
||||
ctx.setOffset(ctx.cursor.startOfLastLine().offset)
|
||||
} else {
|
||||
ctx.setOffset(ctx.cursor.goToLine(count).offset)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
if (input === '.') {
|
||||
return { execute: () => ctx.onDotRepeat?.() }
|
||||
}
|
||||
if (input === ';' || input === ',') {
|
||||
return { execute: () => executeRepeatFind(input === ',', count, ctx) }
|
||||
}
|
||||
if (input === 'u') {
|
||||
return { execute: () => ctx.onUndo?.() }
|
||||
}
|
||||
if (input === 'i') {
|
||||
return { execute: () => ctx.enterInsert(ctx.cursor.offset) }
|
||||
}
|
||||
if (input === 'I') {
|
||||
return {
|
||||
execute: () =>
|
||||
ctx.enterInsert(ctx.cursor.firstNonBlankInLogicalLine().offset),
|
||||
}
|
||||
}
|
||||
if (input === 'a') {
|
||||
return {
|
||||
execute: () => {
|
||||
const newOffset = ctx.cursor.isAtEnd()
|
||||
? ctx.cursor.offset
|
||||
: ctx.cursor.right().offset
|
||||
ctx.enterInsert(newOffset)
|
||||
},
|
||||
}
|
||||
}
|
||||
if (input === 'A') {
|
||||
return {
|
||||
execute: () => ctx.enterInsert(ctx.cursor.endOfLogicalLine().offset),
|
||||
}
|
||||
}
|
||||
if (input === 'o') {
|
||||
return { execute: () => executeOpenLine('below', ctx) }
|
||||
}
|
||||
if (input === 'O') {
|
||||
return { execute: () => executeOpenLine('above', ctx) }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle operator input (motion, find, text object scope).
|
||||
* Returns null if input is not recognized.
|
||||
*/
|
||||
function handleOperatorInput(
|
||||
op: Operator,
|
||||
count: number,
|
||||
input: string,
|
||||
ctx: TransitionContext,
|
||||
): TransitionResult | null {
|
||||
if (isTextObjScopeKey(input)) {
|
||||
return {
|
||||
next: {
|
||||
type: 'operatorTextObj',
|
||||
op,
|
||||
count,
|
||||
scope: TEXT_OBJ_SCOPES[input],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (FIND_KEYS.has(input)) {
|
||||
return {
|
||||
next: { type: 'operatorFind', op, count, find: input as FindType },
|
||||
}
|
||||
}
|
||||
|
||||
if (SIMPLE_MOTIONS.has(input)) {
|
||||
return { execute: () => executeOperatorMotion(op, input, count, ctx) }
|
||||
}
|
||||
|
||||
if (input === 'G') {
|
||||
return { execute: () => executeOperatorG(op, count, ctx) }
|
||||
}
|
||||
|
||||
if (input === 'g') {
|
||||
return { next: { type: 'operatorG', op, count } }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Transition Functions - One per state type
|
||||
// ============================================================================
|
||||
|
||||
function fromIdle(input: string, ctx: TransitionContext): TransitionResult {
|
||||
// 0 is line-start motion, not a count prefix
|
||||
if (/[1-9]/.test(input)) {
|
||||
return { next: { type: 'count', digits: input } }
|
||||
}
|
||||
if (input === '0') {
|
||||
return {
|
||||
execute: () => ctx.setOffset(ctx.cursor.startOfLogicalLine().offset),
|
||||
}
|
||||
}
|
||||
|
||||
const result = handleNormalInput(input, 1, ctx)
|
||||
if (result) return result
|
||||
|
||||
return {}
|
||||
}
|
||||
|
||||
function fromCount(
|
||||
state: { type: 'count'; digits: string },
|
||||
input: string,
|
||||
ctx: TransitionContext,
|
||||
): TransitionResult {
|
||||
if (/[0-9]/.test(input)) {
|
||||
const newDigits = state.digits + input
|
||||
const count = Math.min(parseInt(newDigits, 10), MAX_VIM_COUNT)
|
||||
return { next: { type: 'count', digits: String(count) } }
|
||||
}
|
||||
|
||||
const count = parseInt(state.digits, 10)
|
||||
const result = handleNormalInput(input, count, ctx)
|
||||
if (result) return result
|
||||
|
||||
return { next: { type: 'idle' } }
|
||||
}
|
||||
|
||||
function fromOperator(
|
||||
state: { type: 'operator'; op: Operator; count: number },
|
||||
input: string,
|
||||
ctx: TransitionContext,
|
||||
): TransitionResult {
|
||||
// dd, cc, yy = line operation
|
||||
if (input === state.op[0]) {
|
||||
return { execute: () => executeLineOp(state.op, state.count, ctx) }
|
||||
}
|
||||
|
||||
if (/[0-9]/.test(input)) {
|
||||
return {
|
||||
next: {
|
||||
type: 'operatorCount',
|
||||
op: state.op,
|
||||
count: state.count,
|
||||
digits: input,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const result = handleOperatorInput(state.op, state.count, input, ctx)
|
||||
if (result) return result
|
||||
|
||||
return { next: { type: 'idle' } }
|
||||
}
|
||||
|
||||
function fromOperatorCount(
|
||||
state: {
|
||||
type: 'operatorCount'
|
||||
op: Operator
|
||||
count: number
|
||||
digits: string
|
||||
},
|
||||
input: string,
|
||||
ctx: TransitionContext,
|
||||
): TransitionResult {
|
||||
if (/[0-9]/.test(input)) {
|
||||
const newDigits = state.digits + input
|
||||
const parsedDigits = Math.min(parseInt(newDigits, 10), MAX_VIM_COUNT)
|
||||
return { next: { ...state, digits: String(parsedDigits) } }
|
||||
}
|
||||
|
||||
const motionCount = parseInt(state.digits, 10)
|
||||
const effectiveCount = state.count * motionCount
|
||||
const result = handleOperatorInput(state.op, effectiveCount, input, ctx)
|
||||
if (result) return result
|
||||
|
||||
return { next: { type: 'idle' } }
|
||||
}
|
||||
|
||||
function fromOperatorFind(
|
||||
state: {
|
||||
type: 'operatorFind'
|
||||
op: Operator
|
||||
count: number
|
||||
find: FindType
|
||||
},
|
||||
input: string,
|
||||
ctx: TransitionContext,
|
||||
): TransitionResult {
|
||||
return {
|
||||
execute: () =>
|
||||
executeOperatorFind(state.op, state.find, input, state.count, ctx),
|
||||
}
|
||||
}
|
||||
|
||||
function fromOperatorTextObj(
|
||||
state: {
|
||||
type: 'operatorTextObj'
|
||||
op: Operator
|
||||
count: number
|
||||
scope: TextObjScope
|
||||
},
|
||||
input: string,
|
||||
ctx: TransitionContext,
|
||||
): TransitionResult {
|
||||
if (TEXT_OBJ_TYPES.has(input)) {
|
||||
return {
|
||||
execute: () =>
|
||||
executeOperatorTextObj(state.op, state.scope, input, state.count, ctx),
|
||||
}
|
||||
}
|
||||
return { next: { type: 'idle' } }
|
||||
}
|
||||
|
||||
function fromFind(
|
||||
state: { type: 'find'; find: FindType; count: number },
|
||||
input: string,
|
||||
ctx: TransitionContext,
|
||||
): TransitionResult {
|
||||
return {
|
||||
execute: () => {
|
||||
const result = ctx.cursor.findCharacter(input, state.find, state.count)
|
||||
if (result !== null) {
|
||||
ctx.setOffset(result)
|
||||
ctx.setLastFind(state.find, input)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function fromG(
|
||||
state: { type: 'g'; count: number },
|
||||
input: string,
|
||||
ctx: TransitionContext,
|
||||
): TransitionResult {
|
||||
if (input === 'j' || input === 'k') {
|
||||
return {
|
||||
execute: () => {
|
||||
const target = resolveMotion(`g${input}`, ctx.cursor, state.count)
|
||||
ctx.setOffset(target.offset)
|
||||
},
|
||||
}
|
||||
}
|
||||
if (input === 'g') {
|
||||
// If count provided (e.g., 5gg), go to that line. Otherwise go to first line.
|
||||
if (state.count > 1) {
|
||||
return {
|
||||
execute: () => {
|
||||
const lines = ctx.text.split('\n')
|
||||
const targetLine = Math.min(state.count - 1, lines.length - 1)
|
||||
let offset = 0
|
||||
for (let i = 0; i < targetLine; i++) {
|
||||
offset += (lines[i]?.length ?? 0) + 1 // +1 for newline
|
||||
}
|
||||
ctx.setOffset(offset)
|
||||
},
|
||||
}
|
||||
}
|
||||
return {
|
||||
execute: () => ctx.setOffset(ctx.cursor.startOfFirstLine().offset),
|
||||
}
|
||||
}
|
||||
return { next: { type: 'idle' } }
|
||||
}
|
||||
|
||||
function fromOperatorG(
|
||||
state: { type: 'operatorG'; op: Operator; count: number },
|
||||
input: string,
|
||||
ctx: TransitionContext,
|
||||
): TransitionResult {
|
||||
if (input === 'j' || input === 'k') {
|
||||
return {
|
||||
execute: () =>
|
||||
executeOperatorMotion(state.op, `g${input}`, state.count, ctx),
|
||||
}
|
||||
}
|
||||
if (input === 'g') {
|
||||
return { execute: () => executeOperatorGg(state.op, state.count, ctx) }
|
||||
}
|
||||
// Any other input cancels the operator
|
||||
return { next: { type: 'idle' } }
|
||||
}
|
||||
|
||||
function fromReplace(
|
||||
state: { type: 'replace'; count: number },
|
||||
input: string,
|
||||
ctx: TransitionContext,
|
||||
): TransitionResult {
|
||||
// Backspace/Delete arrive as empty input in literal-char states. In vim,
|
||||
// r<BS> cancels the replace; without this guard, executeReplace("") would
|
||||
// delete the character under the cursor instead.
|
||||
if (input === '') return { next: { type: 'idle' } }
|
||||
return { execute: () => executeReplace(input, state.count, ctx) }
|
||||
}
|
||||
|
||||
function fromIndent(
|
||||
state: { type: 'indent'; dir: '>' | '<'; count: number },
|
||||
input: string,
|
||||
ctx: TransitionContext,
|
||||
): TransitionResult {
|
||||
if (input === state.dir) {
|
||||
return { execute: () => executeIndent(state.dir, state.count, ctx) }
|
||||
}
|
||||
return { next: { type: 'idle' } }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helper functions for special commands
|
||||
// ============================================================================
|
||||
|
||||
function executeRepeatFind(
|
||||
reverse: boolean,
|
||||
count: number,
|
||||
ctx: TransitionContext,
|
||||
): void {
|
||||
const lastFind = ctx.getLastFind()
|
||||
if (!lastFind) return
|
||||
|
||||
// Determine the effective find type based on reverse
|
||||
let findType = lastFind.type
|
||||
if (reverse) {
|
||||
// Flip the direction
|
||||
const flipMap: Record<FindType, FindType> = {
|
||||
f: 'F',
|
||||
F: 'f',
|
||||
t: 'T',
|
||||
T: 't',
|
||||
}
|
||||
findType = flipMap[findType]
|
||||
}
|
||||
|
||||
const result = ctx.cursor.findCharacter(lastFind.char, findType, count)
|
||||
if (result !== null) {
|
||||
ctx.setOffset(result)
|
||||
}
|
||||
}
|
||||
|
||||
200
src/vim/types.ts
Normal file
200
src/vim/types.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Vim Mode State Machine Types
|
||||
*
|
||||
* This file defines the complete state machine for vim input handling.
|
||||
* The types ARE the documentation - reading them tells you how the system works.
|
||||
*
|
||||
* State Diagram:
|
||||
* ```
|
||||
* VimState
|
||||
* ┌──────────────────────────────┬──────────────────────────────────────┐
|
||||
* │ INSERT │ NORMAL │
|
||||
* │ (tracks insertedText) │ (CommandState machine) │
|
||||
* │ │ │
|
||||
* │ │ idle ──┬─[d/c/y]──► operator │
|
||||
* │ │ ├─[1-9]────► count │
|
||||
* │ │ ├─[fFtT]───► find │
|
||||
* │ │ ├─[g]──────► g │
|
||||
* │ │ ├─[r]──────► replace │
|
||||
* │ │ └─[><]─────► indent │
|
||||
* │ │ │
|
||||
* │ │ operator ─┬─[motion]──► execute │
|
||||
* │ │ ├─[0-9]────► operatorCount│
|
||||
* │ │ ├─[ia]─────► operatorTextObj
|
||||
* │ │ └─[fFtT]───► operatorFind │
|
||||
* └──────────────────────────────┴──────────────────────────────────────┘
|
||||
* ```
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// Core Types
|
||||
// ============================================================================
|
||||
|
||||
export type Operator = 'delete' | 'change' | 'yank'
|
||||
|
||||
export type FindType = 'f' | 'F' | 't' | 'T'
|
||||
|
||||
export type TextObjScope = 'inner' | 'around'
|
||||
|
||||
// ============================================================================
|
||||
// State Machine Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Complete vim state. Mode determines what data is tracked.
|
||||
*
|
||||
* INSERT mode: Track text being typed (for dot-repeat)
|
||||
* NORMAL mode: Track command being parsed (state machine)
|
||||
*/
|
||||
export type VimState =
|
||||
| { mode: 'INSERT'; insertedText: string }
|
||||
| { mode: 'NORMAL'; command: CommandState }
|
||||
|
||||
/**
|
||||
* Command state machine for NORMAL mode.
|
||||
*
|
||||
* Each state knows exactly what input it's waiting for.
|
||||
* TypeScript ensures exhaustive handling in switches.
|
||||
*/
|
||||
export type CommandState =
|
||||
| { type: 'idle' }
|
||||
| { type: 'count'; digits: string }
|
||||
| { type: 'operator'; op: Operator; count: number }
|
||||
| { type: 'operatorCount'; op: Operator; count: number; digits: string }
|
||||
| { type: 'operatorFind'; op: Operator; count: number; find: FindType }
|
||||
| {
|
||||
type: 'operatorTextObj'
|
||||
op: Operator
|
||||
count: number
|
||||
scope: TextObjScope
|
||||
}
|
||||
| { type: 'find'; find: FindType; count: number }
|
||||
| { type: 'g'; count: number }
|
||||
| { type: 'operatorG'; op: Operator; count: number }
|
||||
| { type: 'replace'; count: number }
|
||||
| { type: 'indent'; dir: '>' | '<'; count: number }
|
||||
|
||||
/**
|
||||
* Persistent state that survives across commands.
|
||||
* This is the "memory" of vim - what gets recalled for repeats and pastes.
|
||||
*/
|
||||
export type PersistentState = {
|
||||
lastChange: RecordedChange | null
|
||||
lastFind: { type: FindType; char: string } | null
|
||||
register: string
|
||||
registerIsLinewise: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Recorded change for dot-repeat.
|
||||
* Captures everything needed to replay a command.
|
||||
*/
|
||||
export type RecordedChange =
|
||||
| { type: 'insert'; text: string }
|
||||
| {
|
||||
type: 'operator'
|
||||
op: Operator
|
||||
motion: string
|
||||
count: number
|
||||
}
|
||||
| {
|
||||
type: 'operatorTextObj'
|
||||
op: Operator
|
||||
objType: string
|
||||
scope: TextObjScope
|
||||
count: number
|
||||
}
|
||||
| {
|
||||
type: 'operatorFind'
|
||||
op: Operator
|
||||
find: FindType
|
||||
char: string
|
||||
count: number
|
||||
}
|
||||
| { type: 'replace'; char: string; count: number }
|
||||
| { type: 'x'; count: number }
|
||||
| { type: 'toggleCase'; count: number }
|
||||
| { type: 'indent'; dir: '>' | '<'; count: number }
|
||||
| { type: 'openLine'; direction: 'above' | 'below' }
|
||||
| { type: 'join'; count: number }
|
||||
|
||||
// ============================================================================
|
||||
// Key Groups - Named constants, no magic strings
|
||||
// ============================================================================
|
||||
|
||||
export const OPERATORS = {
|
||||
d: 'delete',
|
||||
c: 'change',
|
||||
y: 'yank',
|
||||
} as const satisfies Record<string, Operator>
|
||||
|
||||
export function isOperatorKey(key: string): key is keyof typeof OPERATORS {
|
||||
return key in OPERATORS
|
||||
}
|
||||
|
||||
export const SIMPLE_MOTIONS = new Set([
|
||||
'h',
|
||||
'l',
|
||||
'j',
|
||||
'k', // Basic movement
|
||||
'w',
|
||||
'b',
|
||||
'e',
|
||||
'W',
|
||||
'B',
|
||||
'E', // Word motions
|
||||
'0',
|
||||
'^',
|
||||
'$', // Line positions
|
||||
])
|
||||
|
||||
export const FIND_KEYS = new Set(['f', 'F', 't', 'T'])
|
||||
|
||||
export const TEXT_OBJ_SCOPES = {
|
||||
i: 'inner',
|
||||
a: 'around',
|
||||
} as const satisfies Record<string, TextObjScope>
|
||||
|
||||
export function isTextObjScopeKey(
|
||||
key: string,
|
||||
): key is keyof typeof TEXT_OBJ_SCOPES {
|
||||
return key in TEXT_OBJ_SCOPES
|
||||
}
|
||||
|
||||
export const TEXT_OBJ_TYPES = new Set([
|
||||
'w',
|
||||
'W', // Word/WORD
|
||||
'"',
|
||||
"'",
|
||||
'`', // Quotes
|
||||
'(',
|
||||
')',
|
||||
'b', // Parens
|
||||
'[',
|
||||
']', // Brackets
|
||||
'{',
|
||||
'}',
|
||||
'B', // Braces
|
||||
'<',
|
||||
'>', // Angle brackets
|
||||
])
|
||||
|
||||
export const MAX_VIM_COUNT = 10000
|
||||
|
||||
// ============================================================================
|
||||
// State Factories
|
||||
// ============================================================================
|
||||
|
||||
export function createInitialVimState(): VimState {
|
||||
return { mode: 'INSERT', insertedText: '' }
|
||||
}
|
||||
|
||||
export function createInitialPersistentState(): PersistentState {
|
||||
return {
|
||||
lastChange: null,
|
||||
lastFind: null,
|
||||
register: '',
|
||||
registerIsLinewise: false,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user