BETAER_
← Logbook
5 min read
RustTree-sitterParsing

Tree-sitter로 16개 언어에서 심볼 뽑아내기

정규식으로 코드를 파싱하면 반드시 무너진다. amdb의 심볼 추출을 Tree-sitter AST 위에 올리면서 배운 것들 — 쿼리 S-expression, 언어별 grammar 관리, 버전 충돌 지옥.

amdb의 첫 번째 일은 코드베이스에서 심볼을 뽑는 것이다. 함수, 구조체, 클래스, 메서드 — 이름과 위치와 시그니처. 처음엔 언어별 정규식으로 시작하려다 하루 만에 접었다. 중첩 제네릭 하나, 여러 줄에 걸친 시그니처 하나면 정규식은 무너진다. 파싱은 파서가 해야 한다.

왜 Tree-sitter인가

Tree-sitter는 GitHub이 에디터 하이라이팅용으로 만든 증분 파서 생성기다. 컴파일러 프론트엔드를 언어마다 붙이는 것과 비교하면 장점이 명확하다.

  • 언어마다 grammar가 독립 crate로 배포된다. tree_sitter_rust, tree_sitter_python처럼 추가만 하면 된다.
  • 에러 복구가 내장이다. 문법 오류가 있는 파일도 파싱 가능한 부분까지 AST가 나온다. 실제 코드베이스에는 항상 깨진 파일이 있다.
  • 증분 파싱을 지원한다. 파일이 조금 바뀌면 바뀐 부분만 다시 파싱한다. 데몬 모드의 증분 업데이트가 이 위에 서 있다.

파서 셋업

Rust 바인딩은 단순하다. 언어를 지정하고 소스를 넘기면 AST가 나온다.

parser.rs
use tree_sitter::Parser;
 
fn parse_rust(source: &str) -> Option<tree_sitter::Tree> {
    let mut parser = Parser::new();
    parser
        .set_language(&tree_sitter_rust::LANGUAGE.into())
        .expect("grammar version mismatch");
    parser.parse(source, None)
}

parse의 두 번째 인자에 이전 트리를 넘기면 증분 파싱이 된다. None이면 전체 파싱.

쿼리 S-expression으로 심볼 추출

AST를 직접 순회하는 대신 Tree-sitter 쿼리를 쓴다. 패턴을 S-expression으로 선언하면 매칭 노드를 돌려준다. Rust에서 함수와 구조체 이름을 뽑는 쿼리는 이렇다.

symbols.rs
use tree_sitter::{Query, QueryCursor};
 
const RUST_SYMBOLS: &str = r#"
(function_item name: (identifier) @name)
(struct_item name: (type_identifier) @name)
(impl_item type: (type_identifier) @name)
"#;
 
fn extract(source: &str, tree: &tree_sitter::Tree) -> Vec<String> {
    let query = Query::new(&tree_sitter_rust::LANGUAGE.into(), RUST_SYMBOLS).unwrap();
    let mut cursor = QueryCursor::new();
    let mut names = Vec::new();
    for m in cursor.matches(&query, tree.root_node(), source.as_bytes()) {
        for capture in m.captures {
            names.push(source[capture.node.byte_range()].to_string());
        }
    }
    names
}

핵심은 추출 로직이 데이터가 된다는 점이다. 언어를 추가할 때 Rust 코드를 고치는 게 아니라 그 언어의 쿼리 문자열을 추가한다. amdb가 Rust, Python, JavaScript, TypeScript, C, C++, C#, Go, Java, Ruby, PHP, HTML, CSS, JSON, Bash까지 16개 언어를 지원하는 방식이 이것이다. 확장자 → grammar → 쿼리 테이블 하나로 전부 라우팅된다.

언어마다 노드 이름이 다르다

같은 "함수"라도 Rust는 function_item, Python은 function_definition, Go는 function_declaration이다. 쿼리를 쓸 때는 각 grammar의 node-types.json을 열어 실제 노드 이름을 확인해야 한다. 추측으로 쓰면 조용히 아무것도 매칭되지 않는다. 쿼리가 빈 결과를 내면 에러가 아니라 침묵이라는 점이 가장 위험하다.

버전 충돌 지옥

16개 grammar crate를 한 바이너리에 넣으면 반드시 만나는 문제: grammar마다 요구하는 tree-sitter 코어 버전이 다르다. ABI 버전이 어긋나면 set_language가 런타임에 실패한다. 해결책은 우아하지 않다.

  • 코어 crate 버전을 하나로 핀하고, 그 버전과 호환되는 grammar 버전 조합을 직접 찾는다.
  • cargo tree -i tree-sitter로 어떤 grammar가 다른 코어를 끌고 오는지 추적한다.
  • grammar 업데이트는 한 번에 하나씩. 전부 올리고 빌드가 깨지면 원인 찾기가 지수적으로 어려워진다.

정리

  • 코드 파싱에 정규식을 쓰지 마라. Tree-sitter는 에러 복구와 증분 파싱까지 공짜로 준다.
  • 심볼 추출은 쿼리 S-expression으로 선언하라. 언어 추가가 코드 수정이 아니라 데이터 추가가 된다.
  • 노드 이름은 grammar마다 다르다. node-types.json이 유일한 진실이다.
  • 다중 grammar 프로젝트의 진짜 비용은 파싱이 아니라 의존성 버전 관리다.