/ vim / transitions.ts
transitions.ts
  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  
  8  import { resolveMotion } from './motions.js'
  9  import {
 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'
 25  import {
 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   */
 43  export type TransitionContext = OperatorContext & {
 44    onUndo?: () => void
 45    onDotRepeat?: () => void
 46  }
 47  
 48  /**
 49   * Result of a transition.
 50   */
 51  export type TransitionResult = {
 52    next?: CommandState
 53    execute?: () => void
 54  }
 55  
 56  /**
 57   * Main transition function. Dispatches based on current state type.
 58   */
 59  export 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   */
 98  function 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   */
206  function 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  
248  function 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  
265  function 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  
283  function 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  
310  function 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  
334  function 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  
350  function 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  
369  function 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  
385  function 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  
420  function 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  
438  function 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  
450  function 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  
465  function 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  }