🎯 Use case
This file lives under “vim/”, which covers Vim-style modal editing integrations. On the API surface it exposes TransitionContext, TransitionResult, and transition — mainly types, interfaces, or factory objects. It composes internal code from motions, operators, and types (relative imports). What the file header says: 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.
Generated from folder role, exports, dependency roots, and inline comments — not hand-reviewed for every path.
🧠 Inline summary
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.
📤 Exports (heuristic)
TransitionContextTransitionResulttransition
🖥️ Source preview
/**
* 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)
}
}