mirror of
https://github.com/codeaashu/claude-code.git
synced 2026-04-08 22:28:48 +03:00
492 lines
12 KiB
TypeScript
492 lines
12 KiB
TypeScript
/**
|
|
* 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)
|
|
}
|
|
}
|
|
|