터미널 UI 내 완전한 vim 키바인딩 엔진 — 상태 머신, 순수 함수, 정확성에 대한 타협 없음.
Claude Code는 터미널 입력 영역에서 완전한 Vim 키바인딩을 지원합니다. 약 700줄, 5개 파일로 구성된 이 구현은 Vim의 모달 편집 철학을 충실하게 재현하며, 순수 함수 기반의 상태 머신으로 예측 가능한 동작을 보장합니다.
vim/state.ts → vim/transitions.ts → vim/motions.ts → vim/operators.ts → vim/textObjects.ts
핵심 설계 원칙:
(VimState, Input) => VimState 형태의 순수 함수Intl.Segmenter를 사용하여 이모지, CJK 등 모든 유니코드 문자를 올바르게 처리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 키바인딩처럼 복잡한 상태 머신에서 상태 처리 누락을 방지하는 핵심 안전장치입니다. 런타임이 아닌 컴파일 타임에 오류를 잡습니다.
상태 전환은 순수 함수로 구현됩니다. 현재 상태와 입력을 받아 새로운 상태를 반환하며, 부수 효과가 없습니다.
// 전환 함수 — 순수 함수: (상태, 입력) => 새 상태
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는 새 상태뿐 아니라 텍스트 편집 명령, 커서 이동, 스크롤 등의 효과도 포함합니다. 이 효과들은 호출자가 적용하며, 전환 함수 자체는 순수합니다.
모션은 커서 위치를 계산하는 순수 함수입니다. 현재 커서 위치와 텍스트 내용을 받아 새 커서 위치를 반환합니다. 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"(따옴표 포함) 등이 있습니다.
.)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)
}
Vim 모듈은 에디터 API에 직접 의존하지 않습니다. 대신 VimContext 인터페이스를 정의하고, 호출 코드가 이를 구현합니다. 이를 통해 Vim 로직을 독립적으로 테스트할 수 있고, 다른 에디터에서도 재사용할 수 있습니다.
// VimContext — 의존성 역전 인터페이스
interface VimContext {
text: string // 현재 텍스트 내용
cursor: number // 커서 위치 (오프셋)
lineCount: number // 전체 줄 수
getLine(n: number): string // n번째 줄 내용
getCursorLine(): number // 커서가 있는 줄 번호
getCursorCol(): number // 커서의 열 위치
}
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.Segmenter의 grapheme 세그먼테이션을 사용하면 사용자가 인식하는 "하나의 문자" 단위로 정확하게 처리할 수 있습니다.
VimState는 INSERT/NORMAL 모드를 판별 유니온으로 표현하고, NORMAL 모드 안에 11개의 CommandState 변형이 있습니다(VimState, Input) => VimState 순수 함수로, 부수 효과 없이 테스트 가능합니다.)은 마지막 편집 명령을 기록하고 재실행합니다2d3w)는 연산자 카운트와 모션 카운트를 곱합니다Intl.Segmenter로 유니코드 안전한 단어/문자 경계를 감지합니다Q1. VimState를 판별 유니온으로 구현한 가장 큰 장점은 무엇인가요?
switch문에서 모든 상태 변형을 처리하지 않으면 컴파일 에러가 발생하여, Vim 상태 머신의 복잡한 전환에서 누락을 방지합니다.Q2. 상태 전환 함수가 순수 함수여야 하는 이유는?
Q3. 2d3w에서 최종 카운트는 어떻게 계산되나요?
2d3w는 "3단어 삭제를 2번 반복" = 6단어 삭제입니다. 구현에서는 (operatorCount || 1) * (motionCount || 1)로 계산합니다.Q4. VimContext 인터페이스의 목적은 무엇인가요?
VimContext는 Vim 모듈이 필요로 하는 에디터 기능(텍스트 읽기, 커서 위치 등)을 추상화합니다. Vim 로직이 특정 에디터에 의존하지 않으므로 모의 컨텍스트로 독립 테스트가 가능하고, 다른 에디터에서도 동일한 로직을 재사용할 수 있습니다.Q5. Intl.Segmenter를 사용하는 이유는 무엇인가요?
String.prototype.length는 UTF-16 코드 유닛 수를 반환하므로 이모지나 합성 문자의 길이가 부정확합니다. Intl.Segmenter는 유니코드 표준에 따라 grapheme, word 단위로 정확하게 분할하여 모든 문자에 대해 올바른 Vim 동작을 보장합니다.