source dump of claude code
at main 490 lines 12 kB view raw
1/** 2 * Vim State Transition Table 3 * 4 * This is the scannable source of truth for state transitions. 5 * To understand what happens in any state, look up that state's transition function. 6 */ 7 8import { resolveMotion } from './motions.js' 9import { 10 executeIndent, 11 executeJoin, 12 executeLineOp, 13 executeOpenLine, 14 executeOperatorFind, 15 executeOperatorG, 16 executeOperatorGg, 17 executeOperatorMotion, 18 executeOperatorTextObj, 19 executePaste, 20 executeReplace, 21 executeToggleCase, 22 executeX, 23 type OperatorContext, 24} from './operators.js' 25import { 26 type CommandState, 27 FIND_KEYS, 28 type FindType, 29 isOperatorKey, 30 isTextObjScopeKey, 31 MAX_VIM_COUNT, 32 OPERATORS, 33 type Operator, 34 SIMPLE_MOTIONS, 35 TEXT_OBJ_SCOPES, 36 TEXT_OBJ_TYPES, 37 type TextObjScope, 38} from './types.js' 39 40/** 41 * Context passed to transition functions. 42 */ 43export type TransitionContext = OperatorContext & { 44 onUndo?: () => void 45 onDotRepeat?: () => void 46} 47 48/** 49 * Result of a transition. 50 */ 51export type TransitionResult = { 52 next?: CommandState 53 execute?: () => void 54} 55 56/** 57 * Main transition function. Dispatches based on current state type. 58 */ 59export function transition( 60 state: CommandState, 61 input: string, 62 ctx: TransitionContext, 63): TransitionResult { 64 switch (state.type) { 65 case 'idle': 66 return fromIdle(input, ctx) 67 case 'count': 68 return fromCount(state, input, ctx) 69 case 'operator': 70 return fromOperator(state, input, ctx) 71 case 'operatorCount': 72 return fromOperatorCount(state, input, ctx) 73 case 'operatorFind': 74 return fromOperatorFind(state, input, ctx) 75 case 'operatorTextObj': 76 return fromOperatorTextObj(state, input, ctx) 77 case 'find': 78 return fromFind(state, input, ctx) 79 case 'g': 80 return fromG(state, input, ctx) 81 case 'operatorG': 82 return fromOperatorG(state, input, ctx) 83 case 'replace': 84 return fromReplace(state, input, ctx) 85 case 'indent': 86 return fromIndent(state, input, ctx) 87 } 88} 89 90// ============================================================================ 91// Shared Input Handling 92// ============================================================================ 93 94/** 95 * Handle input that's valid in both idle and count states. 96 * Returns null if input is not recognized. 97 */ 98function handleNormalInput( 99 input: string, 100 count: number, 101 ctx: TransitionContext, 102): TransitionResult | null { 103 if (isOperatorKey(input)) { 104 return { next: { type: 'operator', op: OPERATORS[input], count } } 105 } 106 107 if (SIMPLE_MOTIONS.has(input)) { 108 return { 109 execute: () => { 110 const target = resolveMotion(input, ctx.cursor, count) 111 ctx.setOffset(target.offset) 112 }, 113 } 114 } 115 116 if (FIND_KEYS.has(input)) { 117 return { next: { type: 'find', find: input as FindType, count } } 118 } 119 120 if (input === 'g') return { next: { type: 'g', count } } 121 if (input === 'r') return { next: { type: 'replace', count } } 122 if (input === '>' || input === '<') { 123 return { next: { type: 'indent', dir: input, count } } 124 } 125 if (input === '~') { 126 return { execute: () => executeToggleCase(count, ctx) } 127 } 128 if (input === 'x') { 129 return { execute: () => executeX(count, ctx) } 130 } 131 if (input === 'J') { 132 return { execute: () => executeJoin(count, ctx) } 133 } 134 if (input === 'p' || input === 'P') { 135 return { execute: () => executePaste(input === 'p', count, ctx) } 136 } 137 if (input === 'D') { 138 return { execute: () => executeOperatorMotion('delete', '$', 1, ctx) } 139 } 140 if (input === 'C') { 141 return { execute: () => executeOperatorMotion('change', '$', 1, ctx) } 142 } 143 if (input === 'Y') { 144 return { execute: () => executeLineOp('yank', count, ctx) } 145 } 146 if (input === 'G') { 147 return { 148 execute: () => { 149 // count=1 means no count given, go to last line 150 // otherwise go to line N 151 if (count === 1) { 152 ctx.setOffset(ctx.cursor.startOfLastLine().offset) 153 } else { 154 ctx.setOffset(ctx.cursor.goToLine(count).offset) 155 } 156 }, 157 } 158 } 159 if (input === '.') { 160 return { execute: () => ctx.onDotRepeat?.() } 161 } 162 if (input === ';' || input === ',') { 163 return { execute: () => executeRepeatFind(input === ',', count, ctx) } 164 } 165 if (input === 'u') { 166 return { execute: () => ctx.onUndo?.() } 167 } 168 if (input === 'i') { 169 return { execute: () => ctx.enterInsert(ctx.cursor.offset) } 170 } 171 if (input === 'I') { 172 return { 173 execute: () => 174 ctx.enterInsert(ctx.cursor.firstNonBlankInLogicalLine().offset), 175 } 176 } 177 if (input === 'a') { 178 return { 179 execute: () => { 180 const newOffset = ctx.cursor.isAtEnd() 181 ? ctx.cursor.offset 182 : ctx.cursor.right().offset 183 ctx.enterInsert(newOffset) 184 }, 185 } 186 } 187 if (input === 'A') { 188 return { 189 execute: () => ctx.enterInsert(ctx.cursor.endOfLogicalLine().offset), 190 } 191 } 192 if (input === 'o') { 193 return { execute: () => executeOpenLine('below', ctx) } 194 } 195 if (input === 'O') { 196 return { execute: () => executeOpenLine('above', ctx) } 197 } 198 199 return null 200} 201 202/** 203 * Handle operator input (motion, find, text object scope). 204 * Returns null if input is not recognized. 205 */ 206function handleOperatorInput( 207 op: Operator, 208 count: number, 209 input: string, 210 ctx: TransitionContext, 211): TransitionResult | null { 212 if (isTextObjScopeKey(input)) { 213 return { 214 next: { 215 type: 'operatorTextObj', 216 op, 217 count, 218 scope: TEXT_OBJ_SCOPES[input], 219 }, 220 } 221 } 222 223 if (FIND_KEYS.has(input)) { 224 return { 225 next: { type: 'operatorFind', op, count, find: input as FindType }, 226 } 227 } 228 229 if (SIMPLE_MOTIONS.has(input)) { 230 return { execute: () => executeOperatorMotion(op, input, count, ctx) } 231 } 232 233 if (input === 'G') { 234 return { execute: () => executeOperatorG(op, count, ctx) } 235 } 236 237 if (input === 'g') { 238 return { next: { type: 'operatorG', op, count } } 239 } 240 241 return null 242} 243 244// ============================================================================ 245// Transition Functions - One per state type 246// ============================================================================ 247 248function fromIdle(input: string, ctx: TransitionContext): TransitionResult { 249 // 0 is line-start motion, not a count prefix 250 if (/[1-9]/.test(input)) { 251 return { next: { type: 'count', digits: input } } 252 } 253 if (input === '0') { 254 return { 255 execute: () => ctx.setOffset(ctx.cursor.startOfLogicalLine().offset), 256 } 257 } 258 259 const result = handleNormalInput(input, 1, ctx) 260 if (result) return result 261 262 return {} 263} 264 265function fromCount( 266 state: { type: 'count'; digits: string }, 267 input: string, 268 ctx: TransitionContext, 269): TransitionResult { 270 if (/[0-9]/.test(input)) { 271 const newDigits = state.digits + input 272 const count = Math.min(parseInt(newDigits, 10), MAX_VIM_COUNT) 273 return { next: { type: 'count', digits: String(count) } } 274 } 275 276 const count = parseInt(state.digits, 10) 277 const result = handleNormalInput(input, count, ctx) 278 if (result) return result 279 280 return { next: { type: 'idle' } } 281} 282 283function fromOperator( 284 state: { type: 'operator'; op: Operator; count: number }, 285 input: string, 286 ctx: TransitionContext, 287): TransitionResult { 288 // dd, cc, yy = line operation 289 if (input === state.op[0]) { 290 return { execute: () => executeLineOp(state.op, state.count, ctx) } 291 } 292 293 if (/[0-9]/.test(input)) { 294 return { 295 next: { 296 type: 'operatorCount', 297 op: state.op, 298 count: state.count, 299 digits: input, 300 }, 301 } 302 } 303 304 const result = handleOperatorInput(state.op, state.count, input, ctx) 305 if (result) return result 306 307 return { next: { type: 'idle' } } 308} 309 310function fromOperatorCount( 311 state: { 312 type: 'operatorCount' 313 op: Operator 314 count: number 315 digits: string 316 }, 317 input: string, 318 ctx: TransitionContext, 319): TransitionResult { 320 if (/[0-9]/.test(input)) { 321 const newDigits = state.digits + input 322 const parsedDigits = Math.min(parseInt(newDigits, 10), MAX_VIM_COUNT) 323 return { next: { ...state, digits: String(parsedDigits) } } 324 } 325 326 const motionCount = parseInt(state.digits, 10) 327 const effectiveCount = state.count * motionCount 328 const result = handleOperatorInput(state.op, effectiveCount, input, ctx) 329 if (result) return result 330 331 return { next: { type: 'idle' } } 332} 333 334function fromOperatorFind( 335 state: { 336 type: 'operatorFind' 337 op: Operator 338 count: number 339 find: FindType 340 }, 341 input: string, 342 ctx: TransitionContext, 343): TransitionResult { 344 return { 345 execute: () => 346 executeOperatorFind(state.op, state.find, input, state.count, ctx), 347 } 348} 349 350function fromOperatorTextObj( 351 state: { 352 type: 'operatorTextObj' 353 op: Operator 354 count: number 355 scope: TextObjScope 356 }, 357 input: string, 358 ctx: TransitionContext, 359): TransitionResult { 360 if (TEXT_OBJ_TYPES.has(input)) { 361 return { 362 execute: () => 363 executeOperatorTextObj(state.op, state.scope, input, state.count, ctx), 364 } 365 } 366 return { next: { type: 'idle' } } 367} 368 369function fromFind( 370 state: { type: 'find'; find: FindType; count: number }, 371 input: string, 372 ctx: TransitionContext, 373): TransitionResult { 374 return { 375 execute: () => { 376 const result = ctx.cursor.findCharacter(input, state.find, state.count) 377 if (result !== null) { 378 ctx.setOffset(result) 379 ctx.setLastFind(state.find, input) 380 } 381 }, 382 } 383} 384 385function fromG( 386 state: { type: 'g'; count: number }, 387 input: string, 388 ctx: TransitionContext, 389): TransitionResult { 390 if (input === 'j' || input === 'k') { 391 return { 392 execute: () => { 393 const target = resolveMotion(`g${input}`, ctx.cursor, state.count) 394 ctx.setOffset(target.offset) 395 }, 396 } 397 } 398 if (input === 'g') { 399 // If count provided (e.g., 5gg), go to that line. Otherwise go to first line. 400 if (state.count > 1) { 401 return { 402 execute: () => { 403 const lines = ctx.text.split('\n') 404 const targetLine = Math.min(state.count - 1, lines.length - 1) 405 let offset = 0 406 for (let i = 0; i < targetLine; i++) { 407 offset += (lines[i]?.length ?? 0) + 1 // +1 for newline 408 } 409 ctx.setOffset(offset) 410 }, 411 } 412 } 413 return { 414 execute: () => ctx.setOffset(ctx.cursor.startOfFirstLine().offset), 415 } 416 } 417 return { next: { type: 'idle' } } 418} 419 420function fromOperatorG( 421 state: { type: 'operatorG'; op: Operator; count: number }, 422 input: string, 423 ctx: TransitionContext, 424): TransitionResult { 425 if (input === 'j' || input === 'k') { 426 return { 427 execute: () => 428 executeOperatorMotion(state.op, `g${input}`, state.count, ctx), 429 } 430 } 431 if (input === 'g') { 432 return { execute: () => executeOperatorGg(state.op, state.count, ctx) } 433 } 434 // Any other input cancels the operator 435 return { next: { type: 'idle' } } 436} 437 438function fromReplace( 439 state: { type: 'replace'; count: number }, 440 input: string, 441 ctx: TransitionContext, 442): TransitionResult { 443 // Backspace/Delete arrive as empty input in literal-char states. In vim, 444 // r<BS> cancels the replace; without this guard, executeReplace("") would 445 // delete the character under the cursor instead. 446 if (input === '') return { next: { type: 'idle' } } 447 return { execute: () => executeReplace(input, state.count, ctx) } 448} 449 450function fromIndent( 451 state: { type: 'indent'; dir: '>' | '<'; count: number }, 452 input: string, 453 ctx: TransitionContext, 454): TransitionResult { 455 if (input === state.dir) { 456 return { execute: () => executeIndent(state.dir, state.count, ctx) } 457 } 458 return { next: { type: 'idle' } } 459} 460 461// ============================================================================ 462// Helper functions for special commands 463// ============================================================================ 464 465function executeRepeatFind( 466 reverse: boolean, 467 count: number, 468 ctx: TransitionContext, 469): void { 470 const lastFind = ctx.getLastFind() 471 if (!lastFind) return 472 473 // Determine the effective find type based on reverse 474 let findType = lastFind.type 475 if (reverse) { 476 // Flip the direction 477 const flipMap: Record<FindType, FindType> = { 478 f: 'F', 479 F: 'f', 480 t: 'T', 481 T: 't', 482 } 483 findType = flipMap[findType] 484 } 485 486 const result = ctx.cursor.findCharacter(lastFind.char, findType, count) 487 if (result !== null) { 488 ctx.setOffset(result) 489 } 490}