Vim 모드 구현

터미널 UI 내 완전한 vim 키바인딩 엔진 — 상태 머신, 순수 함수, 정확성에 대한 타협 없음.

01

개요

Claude Code는 터미널 입력 영역에서 완전한 Vim 키바인딩을 지원합니다. 약 700줄, 5개 파일로 구성된 이 구현은 Vim의 모달 편집 철학을 충실하게 재현하며, 순수 함수 기반의 상태 머신으로 예측 가능한 동작을 보장합니다.

다루는 소스 파일: vim/state.tsvim/transitions.tsvim/motions.tsvim/operators.tsvim/textObjects.ts

핵심 설계 원칙:

02

VimState 판별 유니온

VimState는 TypeScript 판별 유니온(discriminated union)으로 표현됩니다. 최상위에서 INSERT 모드와 NORMAL 모드로 나뉘고, NORMAL 모드 안에서 11개의 CommandState 변형이 존재합니다.

// VimState — 판별 유니온
type VimState =
  | { mode: 'INSERT' }
  | { mode: 'NORMAL'; commandState: CommandState }

// CommandState — 11개 변형
type CommandState =
  | { type: 'IDLE' }                                  // 입력 대기
  | { type: 'OPERATOR_PENDING'; operator: Operator }   // d, c, y 후 모션 대기
  | { type: 'COUNT'; digits: string }                  // 숫자 입력 중
  | { type: 'REPLACE_CHAR' }                           // r 후 문자 대기
  | { type: 'FIND_CHAR'; direction: Direction }         // f/F/t/T 후 문자 대기
  | { type: 'REGISTER' }                               // " 후 레지스터 키 대기
  | { type: 'MARK_SET' }                               // m 후 마크 키 대기
  | { type: 'MARK_JUMP' }                              // ' 후 마크 키 대기
  | { type: 'G_PREFIX' }                               // g 후 다음 키 대기
  | { type: 'Z_PREFIX' }                               // z 후 다음 키 대기
  | { type: 'VISUAL'; start: Position; end: Position }  // 비주얼 선택
딥 다이브 — 왜 판별 유니온인가?

판별 유니온은 TypeScript 컴파일러가 모든 상태 변형을 알고 있으므로, switch문에서 처리하지 않은 상태가 있으면 컴파일 에러를 발생시킵니다. 이는 Vim 키바인딩처럼 복잡한 상태 머신에서 상태 처리 누락을 방지하는 핵심 안전장치입니다. 런타임이 아닌 컴파일 타임에 오류를 잡습니다.

03

전환 테이블

상태 전환은 순수 함수로 구현됩니다. 현재 상태와 입력을 받아 새로운 상태를 반환하며, 부수 효과가 없습니다.

// 전환 함수 — 순수 함수: (상태, 입력) => 새 상태
function transition(
  state: VimState,
  input: KeyInput,
  ctx: VimContext
): TransitionResult {
  if (state.mode === 'INSERT') {
    if (input.key === 'Escape') {
      return { state: { mode: 'NORMAL', commandState: { type: 'IDLE' } } }
    }
    return { state, textEdit: { insert: input.char } }
  }

  // NORMAL 모드 전환
  switch (state.commandState.type) {
    case 'IDLE':
      return handleIdleInput(state, input, ctx)
    case 'OPERATOR_PENDING':
      return handleOperatorPending(state, input, ctx)
    case 'COUNT':
      return handleCount(state, input, ctx)
    // ... 나머지 11개 변형 처리
  }
}

TransitionResult는 새 상태뿐 아니라 텍스트 편집 명령, 커서 이동, 스크롤 등의 효과도 포함합니다. 이 효과들은 호출자가 적용하며, 전환 함수 자체는 순수합니다.

04

모션, 연산자, 텍스트 객체

모션 (순수 커서 수학)

모션은 커서 위치를 계산하는 순수 함수입니다. 현재 커서 위치와 텍스트 내용을 받아 새 커서 위치를 반환합니다. w(단어 앞으로), b(단어 뒤로), 0(줄 시작), $(줄 끝) 등이 있습니다.

// 모션 — 순수 커서 위치 계산
function wordForward(text: string, cursor: number): number {
  const segmenter = new Intl.Segmenter('en', { granularity: 'word' })
  const segments = [...segmenter.segment(text.slice(cursor))]
  const nextWord = segments.find(s => s.isWordLike && s.index > 0)
  return nextWord ? cursor + nextWord.index : text.length
}

연산자 (텍스트 변형)

연산자는 모션이 정의한 범위에 대해 텍스트 변형을 수행합니다. d(삭제), c(변경), y(복사) 등이 있습니다. 연산자 + 모션 조합으로 작동합니다: dw = 삭제 + 단어 앞으로.

// 연산자 — 텍스트 범위에 대한 변형
function applyOperator(
  op: Operator,
  range: { start: number; end: number },
  text: string
): OperatorResult {
  switch (op) {
    case 'delete':
      return {
        text: text.slice(0, range.start) + text.slice(range.end),
        cursor: range.start,
        register: text.slice(range.start, range.end)
      }
    case 'change':
      return {
        text: text.slice(0, range.start) + text.slice(range.end),
        cursor: range.start,
        register: text.slice(range.start, range.end),
        enterInsert: true
      }
    case 'yank':
      return { text, cursor: range.start, register: text.slice(range.start, range.end) }
  }
}

텍스트 객체 (구조적 선택)

텍스트 객체는 구조적 단위(단어, 문장, 괄호 쌍 등)를 선택합니다. iw(inner word), aw(a word), i((괄호 내부), a"(따옴표 포함) 등이 있습니다.

05

점 반복과 복합 카운트

점 반복 (.)

Vim의 핵심 기능인 점 반복은 마지막 편집 명령을 재실행합니다. 구현에서는 마지막 텍스트 변형 명령(연산자 + 모션/텍스트 객체)을 기록하고, . 입력 시 같은 명령을 현재 커서 위치에서 재실행합니다.

// 점 반복 — 마지막 편집 명령 기록 및 재실행
interface DotRepeatRecord {
  operator: Operator
  motion: Motion | TextObject
  count: number
  insertedText?: string  // 'change' 연산자의 경우
}

function handleDotRepeat(
  state: VimState,
  lastEdit: DotRepeatRecord,
  ctx: VimContext
): TransitionResult {
  const range = resolveMotion(lastEdit.motion, ctx.cursor, ctx.text, lastEdit.count)
  return applyOperator(lastEdit.operator, range, ctx.text)
}

복합 카운트 (2d3w)

Vim에서 카운트는 연산자 앞과 모션 앞 모두에 올 수 있습니다. 2d3w는 "3단어 삭제를 2번 반복" = 6단어 삭제를 의미합니다. 구현에서는 두 카운트를 곱하여 최종 카운트로 사용합니다.

// 복합 카운트: 2d3w = 6단어 삭제
function resolveCount(operatorCount: number, motionCount: number): number {
  return (operatorCount || 1) * (motionCount || 1)
}
06

컨텍스트 인터페이스와 유니코드 안전

컨텍스트 인터페이스 (의존성 역전)

Vim 모듈은 에디터 API에 직접 의존하지 않습니다. 대신 VimContext 인터페이스를 정의하고, 호출 코드가 이를 구현합니다. 이를 통해 Vim 로직을 독립적으로 테스트할 수 있고, 다른 에디터에서도 재사용할 수 있습니다.

// VimContext — 의존성 역전 인터페이스
interface VimContext {
  text: string           // 현재 텍스트 내용
  cursor: number         // 커서 위치 (오프셋)
  lineCount: number      // 전체 줄 수
  getLine(n: number): string  // n번째 줄 내용
  getCursorLine(): number   // 커서가 있는 줄 번호
  getCursorCol(): number    // 커서의 열 위치
}

Intl.Segmenter 유니코드 안전

Vim의 단어 이동(w, b, e)은 "단어"의 경계를 정확히 인식해야 합니다. JavaScript의 정규식은 유니코드를 완전히 지원하지 않지만, Intl.Segmenter는 유니코드 표준에 따라 단어 경계를 정확히 식별합니다.

// Intl.Segmenter로 유니코드 안전한 단어 경계 감지
const segmenter = new Intl.Segmenter(undefined, { granularity: 'word' })
const text = 'hello 세계 🌍'
for (const { segment, isWordLike } of segmenter.segment(text)) {
  // 'hello' → isWordLike: true
  // '세계'  → isWordLike: true
  // '🌍'   → isWordLike: false (이모지)
}
주의사항

이모지와 합성 문자(예: 가족 이모지 👨‍👩‍👧‍👦)는 여러 코드포인트로 구성됩니다. String.prototype.length는 UTF-16 코드 유닛 수를 반환하므로 정확하지 않습니다. Intl.Segmentergrapheme 세그먼테이션을 사용하면 사용자가 인식하는 "하나의 문자" 단위로 정확하게 처리할 수 있습니다.

07

핵심 요약

핵심 포인트

  • Vim 모드는 약 700줄, 5개 파일로 구현되며 순수 함수 기반 상태 머신입니다
  • VimState는 INSERT/NORMAL 모드를 판별 유니온으로 표현하고, NORMAL 모드 안에 11개의 CommandState 변형이 있습니다
  • 상태 전환은 (VimState, Input) => VimState 순수 함수로, 부수 효과 없이 테스트 가능합니다
  • 모션은 순수 커서 수학, 연산자는 텍스트 변형, 텍스트 객체는 구조적 선택을 담당합니다
  • 점 반복(.)은 마지막 편집 명령을 기록하고 재실행합니다
  • 복합 카운트(2d3w)는 연산자 카운트와 모션 카운트를 곱합니다
  • 컨텍스트 인터페이스로 의존성 역전을 구현하여 에디터 독립적으로 테스트 가능합니다
  • Intl.Segmenter로 유니코드 안전한 단어/문자 경계를 감지합니다
08

지식 확인

퀴즈 — 5문제

Q1. VimState를 판별 유니온으로 구현한 가장 큰 장점은 무엇인가요?

  • A) 런타임 성능이 향상된다
  • B) TypeScript 컴파일러가 처리되지 않은 상태 변형을 컴파일 타임에 감지하여 상태 처리 누락을 방지한다
  • C) 메모리 사용량이 감소한다
  • D) 코드가 더 짧아진다
판별 유니온은 TypeScript의 완전성 검사(exhaustiveness check)를 활용합니다. switch문에서 모든 상태 변형을 처리하지 않으면 컴파일 에러가 발생하여, Vim 상태 머신의 복잡한 전환에서 누락을 방지합니다.

Q2. 상태 전환 함수가 순수 함수여야 하는 이유는?

  • A) 부수 효과 없이 입력과 출력만으로 동작을 검증할 수 있어 단위 테스트가 용이하다
  • B) JavaScript 엔진이 순수 함수를 더 빠르게 실행한다
  • C) React의 요구사항이기 때문이다
  • D) 멀티스레드 환경에서 경쟁 조건을 방지한다
순수 함수는 같은 입력에 항상 같은 출력을 반환합니다. 이를 통해 다양한 입력 시퀀스에 대한 상태 전환을 에디터 없이 단위 테스트로 검증할 수 있습니다. Vim 키바인딩의 수백 가지 조합을 정확하게 테스트하는 데 필수적입니다.

Q3. 2d3w에서 최종 카운트는 어떻게 계산되나요?

  • A) 2 + 3 = 5단어를 삭제한다
  • B) 마지막 카운트인 3단어만 삭제한다
  • C) 2 * 3 = 6단어를 삭제한다
  • D) 먼저 입력된 2단어만 삭제한다
Vim의 복합 카운트는 연산자 앞 카운트(2)와 모션 앞 카운트(3)를 곱합니다. 2d3w는 "3단어 삭제를 2번 반복" = 6단어 삭제입니다. 구현에서는 (operatorCount || 1) * (motionCount || 1)로 계산합니다.

Q4. VimContext 인터페이스의 목적은 무엇인가요?

  • A) Vim 모드의 키바인딩을 사용자가 커스터마이징할 수 있도록 한다
  • B) Vim 상태를 직렬화하여 세션 간에 유지한다
  • C) 모션 계산의 성능을 최적화한다
  • D) 의존성 역전으로 Vim 로직을 특정 에디터 API에서 분리하여 독립적으로 테스트하고 재사용할 수 있게 한다
VimContext는 Vim 모듈이 필요로 하는 에디터 기능(텍스트 읽기, 커서 위치 등)을 추상화합니다. Vim 로직이 특정 에디터에 의존하지 않으므로 모의 컨텍스트로 독립 테스트가 가능하고, 다른 에디터에서도 동일한 로직을 재사용할 수 있습니다.

Q5. Intl.Segmenter를 사용하는 이유는 무엇인가요?

  • A) JavaScript의 정규식보다 빠르기 때문
  • B) 이모지, CJK 문자 등 유니코드 표준에 따라 정확한 단어/문자 경계를 감지하기 때문
  • C) Node.js에서만 사용 가능한 API이기 때문
  • D) 다국어 번역을 위해 필요하기 때문
String.prototype.length는 UTF-16 코드 유닛 수를 반환하므로 이모지나 합성 문자의 길이가 부정확합니다. Intl.Segmenter는 유니코드 표준에 따라 grapheme, word 단위로 정확하게 분할하여 모든 문자에 대해 올바른 Vim 동작을 보장합니다.
0 / 5