claude-code

This commit is contained in:
ashutoshpythoncs@gmail.com
2026-03-31 18:58:05 +05:30
parent a2a44a5841
commit b564857c0b
2148 changed files with 564518 additions and 2 deletions

83
src/vim/motions.ts Normal file
View 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
View 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
View 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
View 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
View 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,
}
}