Skip to content
Published on

Tree-sitter & 증분 파싱 Deep Dive — GLR, 증분 재파싱, CST, Queries, Syntax Highlighting 완전 정복 (2025)

Authors

TL;DR

  • Tree-sitter는 Max Brunsfeld가 2018년 GitHub에서 시작한 파서 생성기 + 런타임. Atom 에디터의 대체 구문 하이라이팅을 위해.
  • GLR 파서: Generalized LR. 모호한 grammar도 처리. 전통 LR/LALR보다 자연스러운 grammar 작성 가능.
  • 증분 재파싱: 편집 시 변경된 부분만 재파싱. 10,000 줄 파일에 한 글자 입력해도 1ms. 이것이 핵심 혁신.
  • Concrete Syntax Tree (CST): AST가 아닌 모든 토큰을 포함한 트리 — 공백, 주석, 심지어 에러 노드. 에디터가 필요한 것이 바로 이것.
  • 에러 복구: 문법 오류가 있어도 parser가 계속 작동. 코드 편집 중에 항상 의미 있는 트리 제공.
  • Queries: S-expression 기반 패턴 매칭 언어. "모든 함수 이름 찾아", "모든 메서드 호출 찾아" 같은 쿼리를 선언적으로.
  • 적용: Neovim (nvim-treesitter), Helix, Zed, GitHub 코드 검색, Emacs tree-sitter mode, Obsidian.
  • 수백 개의 language grammar: Python, JS, Rust, Go, C++, 심지어 Regex, JSON, Bash까지.

1. 파서의 오래된 문제

1.1 왜 파싱이 어려운가

텍스트를 트리로 변환. 간단해 보이지만:

  1. 문법이 복잡: 현대 언어의 문법은 수백 규칙.
  2. 모호성: x - y - z(x-y)-z인가 x-(y-z)인가?
  3. 에러 복구: 사용자가 타이핑 중 항상 완성된 코드가 아님.
  4. 성능: 편집마다 즉시 반영 필요.
  5. 다국어: 한 에디터가 수십 언어.

1.2 전통적 접근들

정규식: 빠르지만 context-free 언어를 파싱 못함. TextMate grammar가 대표적.

Hand-written recursive descent: 유연하고 빠름. 하지만 언어마다 따로 작성. 유지보수 비용 높음.

Parser generators (yacc/bison): LALR 기반. 빠르지만 모호성 불허, grammar 작성 불편.

PEG parsers: 모호성 처리 (choice 우선순위). 하지만 전체 재파싱 필요, 증분 불가.

1.3 Tree-sitter 이전의 에디터

Atom, VS Code (원래): TextMate grammar + 정규식. 빠르지만:

  • Context 이해 불가.
  • Nested 구조 잘못 하이라이트.
  • 에러 있으면 깨짐.
  • Semantic 정보 없음.

JetBrains IDE: 자체 hand-written parser per language. 품질 좋지만:

  • 각 언어에 전담 팀 필요.
  • 느린 언어 추가.

중간이 필요했다: 정규식처럼 빠르고, IDE처럼 정확한 파서.

1.4 Max Brunsfeld와 Tree-sitter

2018년 GitHub에서 Atom 팀의 Max Brunsfeld가 시작. 목표:

  1. 증분 파싱: 편집 시 즉시 반영.
  2. 언어 독립: Grammar 언어로 새 언어 추가 쉽게.
  3. 에러 내성: 깨진 코드도 부분적으로 파싱.
  4. 에디터 특화: 구문 하이라이팅, folding, 인덴테이션을 위한 API.

결과: Tree-sitter. 이후 Atom은 죽었지만 Tree-sitter는 Neovim, Helix, Zed, GitHub 전반에서 사용.


2. GLR 파서

2.1 LR의 한계

LR(k) 파서: left-to-right, rightmost derivation, k tokens lookahead.

yacc, bison, ANTLR이 생성. 빠르고 결정론적. 단점: 모호한 grammar 불허.

예: C++의 >>

vector<vector<int>> v;  // <<VS>> 중첩? 또는 shift 연산자?

LR은 이를 처리 못함 → C++ parser가 복잡해짐.

2.2 GLR의 아이디어

Generalized LR (Tomita, 1986). 아이디어: "모호한 지점에서 여러 파서 상태를 동시에 유지".

Parser state 1: <vector<int>> (템플릿)
Parser state 2: >> (shift)

두 가설이 동시에 진행. 한 쪽이 불가능해지면 소거. 결국 한 쪽만 생존.

2.3 성능

LR의 결정론적 파싱은 O(n). GLR은 **worst case O(n^3)**이지만 실제 언어에서는 대부분 O(n) — 모호성이 드물기 때문.

Tree-sitter의 실측: JavaScript, Python 같은 언어도 수 MB/s 파싱 속도.

2.4 DFA + Stack

내부 구현: 각 상태가 DFA. Stack이 여러 개 (GSS — Graph Structured Stack).

Input:  a + b - c
DFA state 5 (expression)
  ├─ Stack 1: add_expr
  └─ Stack 2: unary_expr  (- 가 unary일 경우)

GSS는 공통 prefix를 공유 → 메모리 효율.

2.5 Conflict Resolution

모호성이 남으면 grammar에 precedenceassociativity를 명시:

// tree-sitter grammar.js
rules: {
    expression: $ => choice(
        $.binary_expression,
        $.unary_expression,
    ),
    binary_expression: $ => prec.left(1, seq($.expression, '+', $.expression)),
}

prec.left(1, ...)는 "이 규칙은 left-associative, precedence 1".


3. 증분 재파싱

Tree-sitter의 가장 혁신적 기능.

3.1 문제

사용자가 편집하면 다시 파싱해야 한다. 10,000줄 파일 + 한 글자 편집 = 10,000줄 재파싱?

Vim/Emacs 구 syntax는 그냥 전체 재파싱 또는 정규식 기반 빠른 재검사. 품질 vs 속도의 트레이드오프.

3.2 Tree-sitter의 해결

편집된 범위와 주변만 재파싱. 핵심 알고리즘:

  1. 편집 정보: {start_byte, old_end_byte, new_end_byte}.
  2. 옛 트리의 해당 범위 노드를 무효화.
  3. 변경 지점부터 재파싱 시작.
  4. 파서가 옛 트리의 노드를 재사용하려 시도:
    • 영향 안 받은 부분은 그대로.
    • 영향 받은 부분만 새로 계산.
  5. 새 트리 반환.
TSTree *new_tree = ts_parser_parse(parser, old_tree, input);

old_tree가 있으면 증분, 없으면 전체 파싱.

3.3 효과

10,000 줄 Python 파일, 중간에 한 글자:

  • Full reparse: ~30 ms.
  • Incremental: ~1 ms (30배 빠름).

1 ms는 사용자가 못 느끼는 속도. 에디터가 "실시간"처럼.

3.4 왜 이게 가능한가

편집이 지역적이기 때문. 대부분 편집은:

  • 한 함수 안에서.
  • 한 expression 안에서.
  • 한 토큰 안에서.

파싱 트리의 대부분은 변경되지 않음. Tree-sitter가 이 불변성을 활용.

3.5 구현 상세

각 노드에 편집 후에도 유효한지 표시. 유효하면 재사용, 그렇지 않으면 재파싱.

위치 업데이트: 편집 후 각 노드의 byte offset이 바뀔 수 있다. Tree-sitter는 이를 lazy하게 계산.

경계 케이스: 편집이 토큰 경계를 넘나들면 주변까지 재파싱.

3.6 API 사용

const parser = new Parser();
parser.setLanguage(JavaScript);

let sourceCode = 'let x = 5;';
let tree = parser.parse(sourceCode);

// 편집: let x = 5; → let y = 5;
sourceCode = 'let y = 5;';

// 편집 정보 제공
tree.edit({
    startIndex: 4,        // 'x' 위치
    oldEndIndex: 5,
    newEndIndex: 5,
    startPosition: { row: 0, column: 4 },
    oldEndPosition: { row: 0, column: 5 },
    newEndPosition: { row: 0, column: 5 }
});

// 증분 재파싱
const newTree = parser.parse(sourceCode, tree);

4. Concrete Syntax Tree (CST)

4.1 AST vs CST

AST (Abstract Syntax Tree): 의미 있는 노드만.

let x = 5;
Variable(name: "x", value: Number(5))
  • 공백, 세미콜론 제거.
  • 컴파일러에 필요한 최소 정보.
  • 원본 코드 복원 불가.

CST (Concrete Syntax Tree): 모든 토큰 포함.

let x = 5;
Program
  └─ LexicalDeclaration
      ├─ 'let'
      ├─ VariableDeclarator
      │   ├─ 'x'
      │   ├─ '='
      │   └─ NumericLiteral: '5'
      └─ ';'
  • 모든 공백/주석 위치 기록.
  • 트리에서 원본 복원 가능.
  • 에디터에 필요.

4.2 Tree-sitter는 CST

이유: 에디터는 모든 문자를 표시해야 한다. 공백, 주석, 연산자 기호 — 전부 syntax highlighting 대상.

Compiler의 AST와 에디터의 CST는 다른 목적. Tree-sitter는 에디터 중심.

4.3 Anonymous Nodes

CST에는 익명 노드도 많음:

'let' (anonymous: keyword token)
'=' (anonymous: operator token)
';' (anonymous: punctuation)

Grammar에서 이름 붙이지 않으면 자동 익명. 하이라이팅에 사용.

4.4 Named Nodes

주요 구조는 명명된 노드:

function_declaration
  identifier (함수 이름)
  formal_parameters
  statement_block

Tree-sitter API로 쉽게 접근:

const funcs = tree.rootNode.descendantsOfType('function_declaration');
funcs.forEach(f => {
    const name = f.childForFieldName('name').text;
    console.log(name);
});

4.5 Field Names

명명된 자식에 field name 부여 가능:

// grammar.js
function_declaration: $ => seq(
    'function',
    field('name', $.identifier),
    field('parameters', $.formal_parameters),
    field('body', $.statement_block),
),

코드에서:

funcNode.childForFieldName('name')
funcNode.childForFieldName('body')

인덱스 대신 이름으로 접근 → 더 명확.


5. 에러 복구

5.1 왜 중요한가

사용자가 타이핑 중엔 항상 완성된 코드가 아님:

def foo(
    x, y
# 아직 닫는 괄호 없음

전통 파서는 여기서 중단. Syntax highlighting이 완전히 깨짐.

5.2 Tree-sitter의 에러 복구

"깨진" 코드도 계속 파싱. 복구 전략:

  1. ERROR 노드: 파싱 실패 범위를 ERROR 노드로 감싸고 계속.
  2. MISSING 노드: 필요한 토큰이 없으면 가상 노드 삽입.
  3. Skipping: 이해 불가 토큰 건너뛰기.

5.3 예시

def foo(x, y  # 괄호 안 닫힘
    return x + y

Tree-sitter 트리:

function_definition
  'def'
  identifier: 'foo'
  parameters
    '('
    identifier: 'x'
    ','
    identifier: 'y'
    MISSING ')'   ← 가상 노드
  ':'
  block
    return_statement
      ...

파싱이 계속 — 함수 이름, 파라미터, body 모두 인식. MISSING ')'로 에러 표시.

5.4 에디터에 주는 이점

편집 중 매 순간 유효한 트리. Syntax highlighting, indentation, folding이 깨지지 않음.

이것이 사용자 경험의 핵심 — 에러 나자마자 전체 화면이 흑백이 되는 끔찍함을 방지.

5.5 구현

에러 복구는 heuristic. Tree-sitter의 접근:

  • 최소 거리 복구: 가능한 최소한의 삽입/삭제로 유효한 트리.
  • Beam search: 여러 복구 후보 중 가장 좋은 것.

이것이 Tree-sitter grammar 작성이 단순히 "BNF 적기"보다 어려운 이유 — 에러 복구 동작도 테스트해야.


6. Grammar 정의

6.1 grammar.js

Tree-sitter grammar는 JavaScript 파일:

// grammar.js for a simple language
module.exports = grammar({
    name: 'my_lang',

    rules: {
        source_file: $ => repeat($._statement),

        _statement: $ => choice(
            $.function_declaration,
            $.expression_statement,
        ),

        function_declaration: $ => seq(
            'fn',
            field('name', $.identifier),
            field('parameters', $.parameter_list),
            field('body', $.block),
        ),

        parameter_list: $ => seq(
            '(',
            optional(seq($.identifier, repeat(seq(',', $.identifier)))),
            ')',
        ),

        block: $ => seq(
            '{',
            repeat($._statement),
            '}',
        ),

        expression_statement: $ => seq($._expression, ';'),

        _expression: $ => choice(
            $.binary_expression,
            $.identifier,
            $.number,
        ),

        binary_expression: $ => prec.left(1, seq(
            $._expression,
            choice('+', '-', '*', '/'),
            $._expression,
        )),

        identifier: $ => /[a-zA-Z_][a-zA-Z0-9_]*/,
        number: $ => /\d+/,
    },
});

6.2 주요 연산자

  • seq(a, b, c): 순차 매칭.
  • choice(a, b, c): 대안 매칭.
  • repeat(a): 0번 이상 반복.
  • repeat1(a): 1번 이상 반복.
  • optional(a): 있어도 되고 없어도 됨.
  • prec(n, rule): 우선순위.
  • prec.left(n, rule): 좌결합 우선순위.
  • prec.right(n, rule): 우결합 우선순위.
  • field('name', rule): 필드 이름.

6.3 Regular Expressions

토큰 레벨에 정규식 사용:

identifier: $ => /[a-zA-Z_][a-zA-Z0-9_]*/,

주의: 정규식은 너무 복잡하면 안 됨. Tree-sitter가 DFA로 변환.

6.4 External Scanner

Grammar로 표현 어려운 경우 (e.g., Python indentation): C로 작성한 scanner:

// src/scanner.c
enum TokenType {
    INDENT,
    DEDENT,
};

void *tree_sitter_python_external_scanner_create() { ... }
bool tree_sitter_python_external_scanner_scan(...) { ... }

Python의 indent 규칙처럼 context-sensitive 결정이 필요할 때.

6.5 컴파일

tree-sitter generate

grammar.jsC 파서. 최적화된 DFA table을 생성.

tree-sitter test

테스트 실행. test/corpus/ 디렉토리의 예제 코드로.


7. Query — S-expression 기반

7.1 왜 Query인가

"트리를 순회하며 특정 패턴 찾기"는 매우 흔한 작업. Tree-sitter는 선언적 쿼리 언어를 제공.

7.2 기본 문법

(function_declaration
  name: (identifier) @function.name)

@function.name: capture. 매칭된 노드에 이 이름을 붙임.

실행 결과:

Match 1: @function.name = "foo"
Match 2: @function.name = "bar"

7.3 Predicates

조건 추가:

(identifier) @var
(#match? @var "^[A-Z]")

대문자로 시작하는 identifier만.

7.4 Alternation

(method_declaration
  name: (identifier) @method
  (#any-of? @method "init" "main"))

여러 값 중 하나.

7.5 Complex Patterns

(class_declaration
  name: (identifier) @class
  body: (class_body
    (method_definition
      name: (property_identifier) @method)))

"class 안의 method 찾기". 중첩 구조도 자연스럽게.

7.6 Application

Syntax highlighting:

(function_declaration name: (identifier) @function)
(identifier) @variable
(string) @string
(comment) @comment

각 capture가 하이라이트 이름. 에디터가 테마의 해당 색상 적용.

Code folding:

[
  (function_declaration)
  (class_declaration)
  (if_statement)
] @fold

이 노드들은 접을 수 있다.

Indentation:

(function_declaration body: (_) @indent)

이 body는 indent 증가.

Code navigation (Go to definition):

(function_declaration name: (_) @definition.function)
(call_expression function: (_) @reference.call)

8. Syntax Highlighting

8.1 Highlight Queries

queries/highlights.scm 파일에 tree-sitter query:

; Keywords
"fn" @keyword
"if" @keyword
"else" @keyword
"return" @keyword

; Functions
(function_declaration name: (identifier) @function)
(call_expression function: (identifier) @function.call)

; Variables
(parameter name: (identifier) @variable.parameter)
(let_declaration pattern: (identifier) @variable)

; Literals
(string_literal) @string
(number_literal) @number
(boolean_literal) @boolean

; Comments
(comment) @comment

; Operators
["+" "-" "*" "/"] @operator

8.2 Highlight Group

@keyword, @function, @variable 같은 이름이 highlight group. 에디터가 각 group에 색상 매핑.

표준 group (Neovim 기준):

  • @variable, @variable.parameter, @variable.builtin.
  • @function, @function.call, @function.builtin.
  • @type, @type.builtin.
  • @keyword, @keyword.operator, @keyword.return.
  • @string, @string.escape.
  • @number, @boolean.
  • @comment, @comment.todo.
  • @operator, @punctuation.bracket.

8.3 에디터 통합

Neovim (nvim-treesitter):

require'nvim-treesitter.configs'.setup {
    ensure_installed = {"python", "rust", "javascript"},
    highlight = { enable = true },
    indent = { enable = true },
}

Helix: 기본 내장. tree-sitter-<lang> 자동 로드.

Zed: 기본 내장. Tree-sitter가 전체 syntax highlighting 담당.

Emacs (Emacs 29+): treesit 내장 모드.

8.4 정규식 vs Tree-sitter

TextMate grammar:

match: "\\bfunction\\s+([a-zA-Z_]+)"
capture 1: entity.name.function

"function 뒤의 이름이 function name". 정규식 기반. 잘 동작하지만:

let fn = function() {};  // function은 키워드지만 선언 아님

→ TextMate는 혼동. Tree-sitter는 문법 구조를 알아서 정확히 분류.

8.5 성능

놀라운 점: Tree-sitter 정규식보다 느리지 않다. 증분 재파싱 덕분에 실질적 오버헤드가 거의 없음.

그리고 더 정확한 하이라이팅. Trade-off가 거의 없음.


9. 에디터별 구현

9.1 Neovim

nvim-treesitter: 가장 성숙한 구현. 수백 언어 지원.

기능:

  • Syntax highlighting.
  • Indentation (indent queries).
  • Text objects (function, class를 텍스트 객체로).
  • Incremental selection.
  • Folding.

플러그인 생태계:

  • nvim-treesitter-textobjects: 함수/클래스 선택.
  • nvim-treesitter-refactor: 변수 이름 변경, 정의로 이동.
  • nvim-ts-autotag: HTML 태그 자동 닫기.

9.2 Helix

Tree-sitter가 기본 내장. 설치 시 모든 지원 언어가 자동.

# languages.toml
[[language]]
name = "rust"
scope = "source.rust"
injection-regex = "rust"

매우 단순한 설정. 기본값이 합리적.

9.3 Zed

Rust + GPU 렌더링 에디터. Tree-sitter가 핵심:

  • 실시간 하이라이팅.
  • 사용자 정의 theme.
  • Multi-buffer 검색.

9.4 Emacs

Emacs 29부터 tree-sitter 내장. 오래된 font-lock 대체.

(use-package treesit
  :config
  (setq major-mode-remap-alist
    '((python-mode . python-ts-mode)
      (javascript-mode . javascript-ts-mode))))

*-ts-mode가 tree-sitter 버전.

9.5 VS Code?

VS Code는 TextMate grammar를 여전히 사용. Tree-sitter를 공식 도입하지 않음.

이유:

  • 기존 인프라 크기.
  • Monaco editor 재작성 필요.
  • LSP가 대부분 semantic 기능 담당.

하지만 일부 extension이 tree-sitter 사용.


10. GitHub 검색

10.1 Code Search 재설계

2022년 GitHub가 Code Search를 재구축 — 기존 Elasticsearch에서 새 엔진으로.

핵심: tree-sitter로 모든 public 코드를 파싱해서 semantic search.

10.2 Semantic Index

단순 텍스트 검색이 아니라:

  • 함수 정의.
  • 클래스 이름.
  • Type 참조.
  • Import 구문.

Tree-sitter로 파싱 → symbol 추출 → 역 인덱스.

search: "def parse_args"
→ 모든 언어에서 "parse_args 함수 정의" 반환

전통 grep은 "def parse_args"라는 텍스트만 찾지만, GitHub는 함수 정의로 인식.

10.3 스케일

수억 개의 repository × 수십억 줄의 코드를 tree-sitter로 파싱. 파싱 결과를 분산 검색 인덱스에.

Tree-sitter의 성능 덕분에 이것이 가능. 다른 파서 대부분은 이 스케일에서 너무 느림.

10.4 LLM과의 결합

2023+ GitHub Copilot, Chat이 tree-sitter 정보를 컨텍스트로 사용. "이 함수를 수정해줘" 같은 요청에 tree-sitter가 함수 경계를 정확히 전달.


11. LSP와의 관계

11.1 역할 분담

Tree-sitter:

  • 빠른 파싱.
  • Syntax 트리 제공.
  • 하이라이팅, folding, indentation.
  • Context-free (파일 단위).

LSP:

  • Semantic 분석 (타입, 의미).
  • Cross-file reference.
  • Refactoring (rename).
  • Diagnostics.

11.2 함께 사용

현대 에디터:

  • Tree-sitter가 즉시 하이라이팅 (파싱 즉시).
  • LSP가 천천히 semantic 정보 제공 (타입 추론 이후).

둘이 독립적으로 작동. Tree-sitter가 "syntactic layer", LSP가 "semantic layer".

11.3 보완 관계

LSP 없는 언어: Tree-sitter가 훌륭한 fallback. 기본 navigation, 하이라이팅 제공.

Tree-sitter 없는 경우: 정규식 하이라이팅 + LSP semantic tokens. 품질 낮음.

둘 다 있으면 최상.


12. 성능

12.1 파싱 속도

Tree-sitter 공식 벤치마크:

  • JavaScript: ~30 MB/s.
  • Python: ~20 MB/s.
  • Rust: ~15 MB/s (복잡한 문법).

10,000 줄 파일(~500 KB) 파싱: ~20 ms (초기), ~1 ms (증분).

12.2 메모리

트리 노드당 ~40 바이트. 10,000 줄 파일 = ~2 MB 트리. 현대 컴퓨터에 무시 가능.

12.3 시작 시간

Parser (.so 파일) 로드: ~1 ms per language. 수십 언어 모두 로드해도 100 ms 미만.

12.4 비교

TextMate (정규식): 매우 빠름 (스캔만), 하지만 대용량 파일에서 선형 비용.

PEG (Parsimonious 등): Tree-sitter와 유사 속도, 하지만 증분 없음.

ANTLR: 빠르지만 자바/플랫폼 의존, 증분 약함.

LALR yacc/bison: 매우 빠름, 하지만 에러 복구 어려움, grammar 제약.

Tree-sitter는 "속도 + 증분 + 에러 복구"라는 조합이 독특.


13. 커스텀 Grammar 작성

13.1 예: 간단한 DSL

// grammar.js
module.exports = grammar({
    name: 'configlang',

    rules: {
        source_file: $ => repeat($.statement),

        statement: $ => choice(
            $.assignment,
            $.comment,
        ),

        assignment: $ => seq(
            field('name', $.identifier),
            '=',
            field('value', $._value),
        ),

        _value: $ => choice(
            $.string,
            $.number,
            $.boolean,
        ),

        string: $ => /"[^"]*"/,
        number: $ => /\d+(\.\d+)?/,
        boolean: $ => choice('true', 'false'),
        identifier: $ => /[a-zA-Z_][a-zA-Z0-9_]*/,
        comment: $ => seq('#', /.*/),
    },
});

13.2 개발 워크플로우

tree-sitter init              # grammar 프로젝트 시작
# grammar.js 작성
tree-sitter generate          # C 파서 생성
tree-sitter test              # 테스트
tree-sitter parse example.cfg # 실제 파싱

13.3 테스트

// test/corpus/basic.txt
==================
simple assignment
==================
x = 10

---

(source_file
  (assignment
    name: (identifier)
    value: (number)))

Input → expected tree. Tree-sitter가 자동 검증.

13.4 문서화

tree-sitter highlight example.cfg

하이라이팅 결과를 터미널로 보여줌. 색상 확인.

tree-sitter playground

웹 기반 playground. Browser에서 실시간 파싱.

13.5 배포

Grammar를 GitHub에 공개 → 에디터가 tree-sitter-<language> 이름으로 자동 인식.


14. Tree-sitter를 쓰는 창의적 프로젝트

14.1 ast-grep

Grep의 semantic 버전. 정규식 대신 AST 패턴으로 검색.

ast-grep --pattern 'if (!$VAR) throw $$$' --lang js

"null check 없이 throw하는 패턴" 같은 복잡한 검색 가능.

14.2 Semgrep / Comby

유사한 도구들. Tree-sitter 또는 자체 파서로 structural search.

보안 스캐닝, 코드 리팩토링에 활용.

14.3 difftastic

구조 기반 diff. Tree-sitter로 파싱 후 tree diff. 단순 line diff보다 정확.

difft old.py new.py

함수 이름 변경, 코드 이동이 정확히 표현됨.

14.4 GitHub Copilot Chat

내부적으로 tree-sitter로 "현재 함수", "현재 클래스"를 감지해 LLM 컨텍스트로.

14.5 Zed AI

Zed 에디터의 AI 통합. 편집 중인 코드의 tree-sitter 컨텍스트를 LLM에 보냄.

14.6 JetBrains AI (실험)

Tree-sitter로 일부 언어를 파싱 (기존 JetBrains 파서 보완). 특히 minor 언어.


15. 한계와 함정

15.1 Grammar 작성의 어려움

"Grammar를 쓰면 끝"이 아니다:

  • 에러 복구 동작 튜닝.
  • Precedence 충돌 해결.
  • 성능 최적화.
  • Edge case 테스트.

각 언어의 tree-sitter grammar 저장소를 보면 수천 개 테스트가 있음.

15.2 정확성 vs 속도

Tree-sitter는 "에디터에 충분한 정확성"을 목표로. Compiler 수준의 정확한 파서보다는 부정확할 수 있다.

예: Python 3.12의 match statement — tree-sitter-python이 처음 나올 때 잘 지원 안 했음.

15.3 Language Evolution

언어 문법이 바뀌면 grammar 업데이트 필요. 보통 언어 커뮤니티가 관리하지만 지연 가능.

15.4 제약된 grammar

GLR이 많은 걸 수용하지만 극도로 context-sensitive한 언어는 어려움. C++ 같은 경우 여전히 hand-written parser만큼 정확하지 못할 수 있음.

15.5 메모리 (대용량)

수 MB 파일을 tree-sitter로 파싱하면 트리가 수백 MB. 에디터는 보통 파일 크기 제한을 둠.


16. 학습 리소스

공식:

논문:

  • Masaru Tomita, "Efficient Parsing for Natural Language" (1986) — GLR 원저.
  • Max Brunsfeld의 Strange Loop 2018 발표 — Tree-sitter 소개.

튜토리얼:

소스 코드:

  • tree-sitter-javascript, tree-sitter-python, tree-sitter-rust 등.
  • 다른 언어 grammar를 보며 배우기.

블로그:

  • Max Brunsfeld의 GitHub 블로그.
  • Helix, Neovim 개발자들의 글.

17. 요약 — 한 장 정리

┌─────────────────────────────────────────────────────┐
Tree-sitter Cheat Sheet├─────────────────────────────────────────────────────┤
│ 핵심:│   증분 파싱 (incremental)GLR 파서 (모호성 처리)│   에러 복구 (깨진 코드도 파싱)CST (모든 토큰 포함)│                                                       │
GLR:│   모호한 grammar 허용                                  │
│   여러 파서 상태 동시 유지                             │
│   대부분 언어 O(n), worst O(n^3)Graph Structured Stack│                                                       │
│ 증분 재파싱:│   편집 정보: start, old_end, new_end                 │
│   영향 없는 노드 재사용                                │
│   10K 줄 파일, 1 char 편집: ~1 ms                    │
30배 이상 빠름                                      │
│                                                       │
CST vs AST:CST: 모든 토큰, 에디터용                             │
AST: 의미 노드만, 컴파일러용                         │
Tree-sitter는 CST│                                                       │
│ 에러 복구:ERROR 노드                                          │
MISSING 노드                                        │
Heuristic 복구                                     │
│   편집 중 항상 유효한 트리                             │
│                                                       │
Grammar 정의:│   grammar.js (JavaScript)│   seq, choice, repeat, optional                      │
│   prec, prec.left, prec.rightfield('name', rule)│   external scanner (C)│                                                       │
Query (S-expression):   (function_declaration                              │
│     name: (identifier) @func)│   #match?, #any-of? predicates                       │
Highlighting, folding, indentation                 │
│                                                       │
│ 적용:Syntax highlighting                                │
FoldingIndentationText objects                                       │
NavigationRefactoring│                                                       │
│ 에디터:Neovim (nvim-treesitter)Helix (내장)Zed (내장)Emacs 29+ (treesit)│                                                       │
│ 기타 사용자:GitHub Code Search│   ast-grep, difftastic                               │
Semgrep, CombyCopilot Chat│                                                       │
LSP와 역할:Tree-sitter: 즉각 syntax (파일 단위)LSP: semantic (cross-file, 타입)│   둘이 보완                                            │
└─────────────────────────────────────────────────────┘

18. 퀴즈

Q1. Tree-sitter의 "증분 재파싱"이 어떻게 작동하는가?

A. 편집 정보를 받아 영향 범위만 재파싱하고 나머지 트리 노드를 재사용. 사용자가 편집하면 Tree-sitter API에 {startIndex, oldEndIndex, newEndIndex} 형태로 편집 정보를 전달. Parser가 옛 트리를 입력으로 받아 (1) 편집 범위 내의 노드를 무효화, (2) 변경 지점 주변부터 다시 파싱, (3) 옛 트리의 변경되지 않은 서브트리를 재사용. 대부분 편집은 지역적이므로 실제로 재파싱되는 것은 한 expression 또는 한 function 정도. 10,000 줄 파일에 한 글자 입력: full parse 30ms → incremental 1ms (30배). 이 속도가 "에디터가 실시간처럼 느껴지는" 기반 — 사용자가 타이핑할 때 syntax highlighting이 즉시 업데이트. 전통 파서(yacc, PEG)는 이 최적화 없이 매번 full reparse라 큰 파일에서 에디터가 버벅였다.

Q2. GLR 파서가 LR보다 나은 점은?

A. 모호한 grammar를 자연스럽게 처리. LR(k) (yacc, bison이 생성)는 각 상태에서 한 가지 결정만 가능 → 모호성 만나면 에러. C++의 >> 같은 케이스(템플릿 중첩 vs shift 연산자)를 처리하려면 grammar를 우회적으로 변형해야. GLR(Generalized LR, Tomita 1986)은 여러 파서 상태를 동시에 유지 — 모호한 지점에서 stack을 fork, 각각 계속 진행, 결국 유효한 것만 생존. 대부분의 경우 모호성은 국소적이라 금방 해결됨. 성능: LR은 O(n), GLR worst case O(n^3)이지만 실제 언어에서는 O(n)에 가까움 — 모호성이 드물기 때문. 결과: Grammar가 읽기 쉽다. Tree-sitter-javascript의 grammar.js는 JavaScript 사양과 매우 유사 — LALR로 똑같이 작성 불가능. 이것이 tree-sitter가 100+개 언어를 빠르게 지원할 수 있었던 이유.

Q3. CST와 AST의 차이, 왜 tree-sitter는 CST인가?

A. CST는 모든 토큰을 포함하고, AST는 의미 있는 노드만 포함. let x = 5;의 AST: Variable(name: "x", value: Number(5)) — 간결, 컴파일러가 원하는 것. CST: let 키워드 토큰, 공백, x, =, 5, ; 전부 트리에 — 원본 복원 가능. Tree-sitter가 CST를 쓰는 이유는 에디터가 모든 문자를 표시/처리해야 하기 때문. Syntax highlighting은 공백을 포함한 전체 라인에 색을 칠해야 하고, folding은 { } 위치를 알아야 하고, indentation은 공백 계산이 필요하다. AST만 있으면 원본 위치로 되돌아갈 수 없음. Compiler는 CST → AST 변환을 내부에서 하지만 에디터는 CST 자체가 필요. 그래서 tree-sitter는 "compiler 친화 파서"가 아니라 "에디터 친화 파서". LSP server들은 tree-sitter CST 위에 자체 AST를 빌드해서 semantic 분석을 한다.

Q4. Tree-sitter의 에러 복구가 왜 중요한가?

A. 편집 중 매 순간 유효한 트리가 있어야 에디터 기능이 깨지지 않음. 사용자 타이핑은 항상 불완전한 코드 — 괄호 안 닫힘, 중간 단계 expression 등. 전통 파서는 여기서 에러 반환 후 중단 → syntax highlighting이 전체 화면 흑백, LSP 기능 중단, 전체 UX 악화. Tree-sitter는 heuristic으로 복구: (1) ERROR 노드로 이해 불가 범위 감싸고 계속, (2) MISSING 노드로 누락 토큰 가상 삽입 () ; 등), (3) 이상한 토큰 건너뛰기. 결과: def foo(x, y\n return x + y처럼 닫는 괄호 없는 코드도 function 이름, 파라미터, body 전부 인식. Highlighting이 깨지지 않고 LSP가 함수 이름을 계속 추적. 이 "graceful degradation"이 tree-sitter의 숨은 가치 — 기술적 혁신보다 UX 영향이 크다. 복구 품질은 heuristic 튜닝이 필요해서 grammar 작성이 "BNF 적기"보다 어렵다.

Q5. Tree-sitter의 Query 언어로 무엇을 할 수 있는가?

A. 트리 패턴을 선언적으로 매칭해서 정보 추출. S-expression 문법: (function_declaration name: (identifier) @func) → "function 선언의 이름을 @func로 캡처". 이 단순한 아이디어가 여러 응용을 만든다: (1) Syntax highlighting@function, @string, @comment 같은 캡처가 에디터 테마의 색상에 매핑. (2) Folding@fold 캡처가 붙은 노드는 접을 수 있다. (3) Indentation@indent 캡처가 들여쓰기 규칙. (4) Text objects — "함수 선택", "클래스 삭제" 같은 Vim 편집. (5) Navigation@definition, @reference 캡처로 "go to definition". (6) 코드 분석ast-grep, semgrep이 쿼리로 패턴 검색. (7) LSP-like 기능 — LSP 없는 언어에서 tree-sitter만으로 기본 navigation 제공. Predicates(#match?, #any-of?)로 조건 추가 가능. 이 query 언어가 tree-sitter의 확장성의 핵심: 새 기능이 코드 변경 없이 쿼리 파일만 작성하면 된다.

Q6. GitHub Code Search가 tree-sitter를 선택한 이유는?

A. 언어 독립적 semantic 파싱. 2022년 GitHub Code Search 재구축 시 목표: "def parse_args" 같은 텍스트 검색 대신 "parse_args 함수 정의"를 찾는 semantic search. 필요조건: (1) 수억 개 repository × 수십억 줄 코드를 파싱할 속도, (2) 100+ 언어 지원하는 확장성, (3) 잘못된 코드도 처리하는 robustness. 대안 평가: (a) 언어별 hand-written parser — 개발 비용 너무 큼. (b) Generic LALR (yacc) — 각 언어 grammar 수동 작성, 에러 복구 약함. (c) Regex — semantic 이해 불가. Tree-sitter만 이 셋을 모두 충족. 결과: 모든 public GitHub 코드를 tree-sitter로 파싱해 symbol 인덱스 구축. 함수 정의, 클래스, import, type reference를 분산 검색 엔진에. 부가 이익: Copilot도 tree-sitter로 "현재 함수 범위"를 정확히 감지해 LLM 컨텍스트로 사용. 단일 기술이 GitHub의 두 주요 제품(Code Search, Copilot)의 기반이 됐다 — tree-sitter에 대한 투자가 복리로.

Q7. Tree-sitter와 LSP는 어떻게 보완 관계인가?

A. 레이어가 다르다 — syntactic vs semantic. Tree-sitter는 파일 단위 syntactic 분석: 이 파일의 구조(함수, 클래스, expression)를 빠르게 파싱. Context-free 문법으로 해결 가능한 것만 — 타입 체크, cross-file reference, 의미 분석은 X. LSP는 프로젝트 단위 semantic 분석: 전체 프로젝트를 이해하고 타입 추론, 정의 찾기, rename, diagnostics 제공. 무거움(초기화 수 초), 느림(semantic 작업은 복잡). 협력 방식: 에디터가 파일 열면 (1) tree-sitter가 즉시 파싱 → 1ms 안에 syntax highlighting + folding + indentation 표시. (2) LSP가 백그라운드에서 프로젝트 인덱싱 → 수 초 후 semantic 기능(타입 힌트, 정의 이동) 제공. 사용자는 syntax는 즉시, semantic은 조금 기다림. LSP 없는 언어(덜 유명한 DSL, 새 언어)는 tree-sitter만으로도 기본 기능이 작동 — fallback. Tree-sitter 없는 언어는 정규식 하이라이팅 + LSP 의존 — 품질 낮음. 둘 다 있을 때 최상의 경험. 역할 분담이 명확해서 둘이 겹치지 않고 각자 자기 영역에 집중.


이 글이 도움이 됐다면 다음 포스트도 확인해 보세요:

  • "Language Server Protocol Deep Dive" — LSP가 tree-sitter와 어떻게 함께 쓰이는지.
  • "LLVM Compiler Infrastructure" — 완전한 컴파일러 파이프라인.
  • "Git Internals Deep Dive" — GitHub의 다른 핵심 기술.
  • "Regex Engines Internals" — 정규식의 한계와 tree-sitter의 대안.