transitions.ts
vim/transitions.ts
No strong subsystem tag
491
Lines
12381
Bytes
3
Exports
3
Imports
10
Keywords
What this is
This page documents one file from the repository and includes its full source so you can read it without leaving the docs site.
Beginner explanation
This file is one piece of the larger system. Its name, directory, imports, and exports show where it fits. Start by reading the exports and related files first.
How it is used
Start from the exports list and related files. Those are the easiest clues for where this file fits into the system.
Expert explanation
Architecturally, this file intersects with general runtime concerns. It contains 491 lines, 3 detected imports, and 3 detected exports.
Important relationships
Detected exports
TransitionContextTransitionResulttransition
Keywords
inputcountexecutenextresulttransitioncontexttransitionresultcursoroffsetoperator
Detected imports
./motions.js./operators.js./types.js
Source notes
This page embeds the full file contents. Small or leaf files are still indexed honestly instead of being over-explained.
Full source
/**
* 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)
}
}