docs.rs에서 Rust 외 언어 코드 블록도 제대로 구문 강조 표시를 하게 만들기 위해 tree-sitter 문법들을 모아 배포하는 프로젝트 arborium을 소개하고, 이를 적용하는 세 가지 접근(스크립트 삽입, rustdoc 통합, 백엔드 후처리)을 비교한다.
URL: https://fasterthanli.me/articles/introducing-arborium
약 2주 전, 나는 docs.rs 팀과 “대체 왜 우리는 이런 걸 봐야 하죠?”라는 이야기를 하게 됐다. 대략 이런 것 말이다:
반면에 우리는 이런 걸 볼 수도 있는데 말이다:
그리고 늘 그렇듯, 사정이 있으니 지금 모습인 것이다. 그 사정을 이해해 보려고 GitHub 이슈를 하나 열었고, 그 결과 짧지만 생산적인 토론이 오갔다.
나는 의기소침해진 채로 자리를 떴다가, 이내 “사정은 사정이고”라는 마음으로 이 문제를 세 가지 각도에서 공격해 보기로 했다.
하지만 그 전에, 이 모든 것에 대한 최소한의 배경 설명부터.
Rust는 누구나 크레이트의 문서 주석(doc comments, /// 또는 모듈용 //!)으로부터 HTML과 JSON 문서를 생성할 수 있는 도구를 제공한다.
이건 정말 대단하다. 비행기를 타기 전 오프라인 문서를 쉽게 받아둘 수 있고, 배포 전에 문서가 어떻게 보일지 미리 확인할 수도 있다.
문서 작업을 충분히 반복한 다음(문서는 중요하니까), crates.io에 크레이트를 배포할 차례다.
그러면 그 크레이트는 docs.rs의 빌드 큐에 들어간다. 아니, 정확히는 두 개의 빌드 큐 중 하나인데, 착한 사람용과 나쁜 사람용이 있다:
빌드가 성공하면, 다른 모든 것들과 함께 7.75TiB 버킷 속으로 던져지고, 인터넷 한 구석의 작은 땅을 얻게 된다. 그리고 docs.rs-세계의 이곳저곳으로 연결해 주는 멋진 내비게이션 바도 함께 제공된다:
그 버킷에는 HTML, CSS, JavaScript가 잔뜩 들어 있는데, 이들은 완전히 불변(immutable)이다. 다시 말해, rustdoc 빌드를 처음부터 다시 돌리지 않는 한 바꿀 수 없다(그리고 docs.rs 팀은 모든 크레이트의 최신 버전에 대해서는 다시 빌드하지만, 과거 버전에 대해서는 그렇지 않다).
이게 바로 “그냥 색을 넣자”가 어려운 첫 번째 이유다. “색 좋아요” 기능을 켠 상태로 역사상 존재했던 모든 크레이트의 모든 버전을 다시 빌드하는 건 절대 불가능하다. 현실적으로 할 수 없다.
그리고 이건 여러 문제 중 첫 번째에 불과하다.
우선, 코드 하이라이팅(구문 강조)을 해결하는 방법은 다양하다.
자!
tree-sitter, 인기투표로 뽑힌 96개, 예, 아니오, 예, 그리 크지 않음, 아마도, 나.
나는 웹사이트를 과도하게 엔지니어링하기 시작한 이후로(즉 6년 동안) tree-sitter를 사용해 왔다.
내 기준에서 tree-sitter는 LSP가 아닌 이상 따라올 수 없는 수준의 구문 강조 “금본위”다. 물론 문서를 생성하려고 LSP를 실행하자고 누군가를 설득하려면… 행운을 빈다.
여기서 LSP는 Language Server Protocol을 뜻한다. Rust Analyzer와 코드 에디터가 대화하는 방식이다. 이들은 의미 기반(semantic) 하이라이팅을 할 수 있지만, 소스 코드 전체와 모든 의존성, 그리고 sysroot 전체를 로드해야 해서 시간과 메모리가 많이 든다.
따라서 오프라인 구문 강조에는 부적합하다. 물론… 나 때문에 못 하게 되진 말라. 나는 곰이지 경찰이 아니다.
하지만 tree-sitter의 코어 크레이트와 tree-sitter-highlight 크레이트가 있긴 해도, 그 밖의 것들은 결국 스스로 맞춰 조립해야 한다.
먼저 언어용 문법(grammar)을 찾아야 한다. Rust나 C++처럼 흔한 언어라면 최신 상태의 고품질 문법이 tree-sitter-grammars GitHub 조직에 이미 올라와 있어서 아주 유리하다.
하지만 좀 특이한 취향이라면, 완벽한 문법을 찾아 헤매거나, 심지어 직접 써야 할지도 모른다.
혹은 “대충 괜찮아 보이는데?” 싶어 가져왔더니, 실제로는 훨씬 오래된 tree-sitter 버전을 기준으로 만들어져서 정리(clean up)와 재생성(regenerate)이 필요할 수도 있다. 또 컴파일 시간이 폭발하게 만드는 이상한 규칙들을 제거해야 할 수도 있고…
여기서 “재생성”이란, 문법 저장소의 grammar.js와(있다면) scanner.cc를 tree-sitter CLI로 다시 돌려서 실제 파서가 되는 산더미 같은 C 코드를 생성하는 걸 말한다.
그리고 하이라이팅하고 싶은 언어마다 그걸 해야 한다:
나는 18개 문법을 모은 뒤에야 “이 문제를 모두를 위해 한 번에 해결할 수 있지 않을까?” 하고 생각하기 시작했다. 특히 서로 다른 프로젝트들이 각자 하이라이팅이 필요해지기 시작했기 때문이다.
이 문법들과 함께 자동 생성되는 크레이트들이 하는 일은, 단 하나의 심볼을 내보내는 것이다. 파싱 테이블과(있다면) 스캐너용 함수 포인터 등을 담은 구조체에 대한 포인터다.
이것만으로는 어떤 의미에서도 “바로 사용 가능”이 아니다.
사실은 내가 거짓말을 했는데, 저 스크린샷에서도 보이듯 운이 좋다면 highlights query나 injections query 같은 것들도 내보낸다. 코드를 트리로 파싱한 결과를 실제로 하이라이트하려면 이런 것들이 필요하다.
하이라이트 쿼리가 없으면 노드 트리는 얻을 수 있어도, 무엇이 무엇인지 알 수 없다. 키워드가 뭔지, 함수가 뭔지, 숫자/문자열 같은 색칠할 만한 요소가 뭔지 전혀 모른다.
또 테마(색상 테마)를 노드들에 어떻게 매핑해야 하는지도 모른다. 그 일을 하는 게 highlights query다. 그리고 injections query는 내 언어 안에 다른 문법이 중첩되어 있는지 알려준다.
예를 들어 Svelte 컴포넌트는 보통 HTML이면서 스크립트와 스타일을 포함할 수 있다. 그러니 JavaScript와 CSS(가끔 TypeScript도)를 주입(inject)해야 한다.
tree-sitter-highlight에는 주입을 처리하는 콜백 시스템이 있지만, 적절한 의존성을 갖추고 그 콜백을 구현하는 건 전부 사용자 몫이다!
내가 6년 동안 그 문제를 다뤄 와서 좋은 문법들을 개인적으로 모아 둔 사람이 아니라면 말이다.
하지만 오늘부터는 달라진다. 기쁘게 발표한다: arborium.
사람들이 요청한 96개 언어에 대해, 나는 사용 가능한 최선의 문법을 찾아 벤더링(vendoring)했고, 손봤고, 하이라이트 쿼리가 제대로 동작하는지 확인했으며, 재배포 시 라이선스와 저작자 표기가 포함되도록 했고, 이를 메인 arborium 크레이트의 cargo feature 플래그로 통합했다.
그런데 여기서 조금 더 나아간다. 예를 들어 Svelte를 의존성으로 추가하면, Svelte 컴포넌트를 완전히 하이라이트하는 데 필요한 크레이트들(HTML, CSS, JavaScript 등)도 함께 끌려온다.
원래 tree-sitter 크레이트들처럼, 이것들만으로는 할 수 있는 일이 많지 않다. 대신 메인 Arborium 크레이트를 통해 사용하도록 되어 있는데, 코드 하이라이팅을 위한 인터페이스가 아주 간단하다:
rustuse arborium::Highlighter; let mut highlighter = Highlighter::new(); let html = highlighter.highlight_to_html("rust", "fn main() {}")?;
물론 여기서는 tree-sitter가 제공하는 점진적 파싱/하이라이팅의 미묘함을 다소 포기한 셈이지만, 걱정하지 마라. 필요하다면 더 복잡한 API들도 준비되어 있다.
테마(내장 테마도 꽤 있다)부터 HTML 출력 스타일까지 전부 설정할 수 있다. 기본값은 현대적이고, компакт하며, 폭넓게 지원되는 방식이다:
html<a-k>keyword</a-k>
만약 레트로를 고집하고, 어차피 Brotli 압축이 다 상쇄해 준다고 “분홍 맹세”까지 한다면, 장황한 대안도 쓸 수 있다:
html<span class="code-keyword">keyword</span>
터미널 파인 사람이라면 ANSI 이스케이프 시퀀스로도 출력할 수 있다. 옵션으로 배경색, 여백(margin/padding), 테두리(border)까지 넣어서 더 두드러지게 만들 수도 있다:
그리고 어쩌면 가장 중요한 점: Rust 크레이트들이 wasm32-unknown-unknown 타깃으로 cargo를 통해 컴파일되도록 셋업되어 있다.
이게 나를 가장 괴롭힌 부분인데, 문법들이 만족하도록 “딱 필요한 만큼”의 libc 심볼을 제공해야 하기 때문이다.
textcrates/arborium-sysroot/wasm-sysroot › main 1 18via v17.0.0-clang › 18:10 🪴 › ls --tree . ├── assert.h ├── ctype.h ├── endian.h ├── inttypes.h (cut)
하지만 Amos! 아까 tree-sitter build --wasm 다음에 tree-sitter playground를 실행해서 얻은 “WASM 플레이그라운드”를 보여줬잖아?
맞다. 그건 wasm32-wasi를 타깃으로 한다.
그건 그들이 wasm32-wasi로 빌드하기 때문인데, 이는 약간 다르다. 결국 누군가는 시스템 함수를 제공해야 하고, 우리 경우엔 내가 그 역할을 한다.
제공되는 함수 대부분은 간단하다(isupper, islower 등). 예외는 malloc, free 같은 것들인데, arborium에서는 dlmalloc이 제공한다.
이 모든 크레이트들이 Rust 툴체인(이 과정에서 C 툴체인도 호출)을 통해 wasm32-unknown-unknown으로 컴파일되므로, 약간의 글루(glue)만 더하면 브라우저에서 실행할 수 있다.
현재, 크레이트를 배포한 뒤 Rust 외 언어의 코드 블록도 문서에서 하이라이트하고 싶다면, arborium.bearcove.eu의 안내에 따라 다음을 하면 된다:
이건 arboriu_docsrs_demo 페이지에서 실제로 볼 수 있고, 소스는 arborium 저장소에 있다.
(원문에는 영상이 있으나, 여기서는 “브라우저가 video 태그를 지원하지 않는다”는 메시지로 표시되어 있다.)
나는 또 한 걸음 더 나아가서, docs.rs에서 실행 중인지 감지하고 현재 활성 테마에 반응형으로 맞추는 기능도 넣었다. 그래서 페이지 상태에 따라 docs.rs light, docs.rs dark, Ayu 테마를 따라간다.
이 테마들은 내 개인 취향에는 그다지 맞지 않지만, 여기서는 일관성이 최우선이라고 판단했다.
이 해결책이 좋은 이유는 “오늘 당장” 동작하기 때문이다.
또 Rust 문서 팀에게 추가 일을 전혀 요구하지 않는다. Rustdoc을 건드릴 필요도 없고, 빌드 파이프라인이나 인프라도 손댈 필요가 없다. 그냥 된다. 훌륭한 탈출구(escape hatch)다.
사람들은 이 메커니즘으로 KaTeX(LaTeX 수식 렌더링), 다이어그램 렌더링 등 프론트엔드에서 별별 걸 다 해 왔다.
하지만 이 해결책은 또한 최악이다! JavaScript는 물론 WebAssembly까지 필요하고, 작은 코드 블록 몇 개 하이라이트하려고 사람들이 거대한 문법 번들(때로는 수백 KB!)을 내려받게 만든다.
그리고 무엇보다도, 보안 측면에서 터질 일만 남은 재앙이다.
페이지의 메인 컨텍스트에 제3자 JavaScript를 주입하도록 허용하면 절대 안 된다. 지금 docs.rs에서 훔칠 수 있는 건 좋아하는 테마 정도지만, 그게 영원히 그럴 거라는 보장은 없다. 그냥 나쁜 관행이고, 팀도 그걸 안다—그 구멍은 닫고 싶어 하거나, 닫아야 한다.
왜 이게 그렇게 나쁜지 감이 안 온다면 이런 상황을 상상해 보자. 모두가 docs.rs 페이지에서 코드 하이라이팅을 하는 표준 방법으로 Arborium을 채택했다. 몇 년 후 내가 악당이 되기로 결심한다. 그러면 NPM에 악성 버전 arborium 패키지를 올리기만 해도 수백만 명에게 즉시 도달할 수 있다.
대중적 믿음과 내가 매달 구독료를 내고 산 이 스톡 사진(반드시 써먹을 거다)과는 달리, 해킹하려고 꼭 후드티를 입을 필요는 없다.
물론 사람들이 Arborium 패키지 특정 버전에 고정(pin)하도록 할 수도 있겠지만, 그러면 중요한 업데이트도 못 받게 된다. 이상적으로는 docs.rs 페이지에 배포되는 모든 JavaScript는 docs 팀이 제공해야 한다. 그래야 세상이 위험해지는 경우는 docs 팀 자체가 악해졌을 때뿐이다.
따라서 장기적으로, 돈과 사람과 시간이 충분한 세계에서는, 두 가지 다른 각도를 고려해야 한다.
Arborium은 그냥 여러 Rust 크레이트들로 이루어져 있고 그 안에 많은 C 코드가 들어 있을 뿐이며, 둘 다 매우 이식성이 높다. 이상한 건 아무것도 없다. 동적 링크도 없고, 플러그인 폴더도 없고, 비동기 로딩 같은 것도 없다. 그냥 여러 문법과 하이라이팅에 필요한 코드들이다.
그래서 나는 RustDoc에 PR을 올려 다른 언어들도 하이라이트하도록 만들 수 있었다:
+537-11짜리로 꽤 작은 PR이지만, 실제로는 tree-sitter가 생성한 파서 C 코드 수백만 줄을 끌어온다.
그래서 “어떤 문법을 번들로 넣을 것인가?”라는 질문은 더더욱 중요해진다—다행히 그걸 해결할 사람은 내가 아니다.
textrust › rustdoc-arborium 3via v3.14.2 › 00:54 🪴 › ls -lhA build/aarch64-apple-darwin/stage2/bin/rustdoc Permissions Size User Date Modified Name .rwxr-xr-x 171M amos 14 Dec 00:52 build/aarch64-apple-darwin/stage2/bin/rustdoc
textrust › main via v3.14.2 › 01:44 🪴 › ls -lhA build/aarch64-apple-darwin/stage2/bin/rustdoc Permissions Size User Date Modified Name .rwxr-xr-x 22M amos 14 Dec 01:44 build/aarch64-apple-darwin/stage2/bin/rustdoc
위: 96개 언어를 전부 컴파일해 넣은 커스텀 rustdoc. 아래: “main branch” rustdoc.
토론 중 누군가 이 바이너리 크기를 보고 “으… 이건 좀 아닌 것 같은데”라고 말할 가능성이 크다고 본다.
그래서, 세 번째 각도를 제시한다.
집에서(로컬에서) 수백 가지 프로그래밍/마크업/설정 언어의 하이라이팅을 모두 즐길 사치가 현실적으로 불가능하다면, docs.rs 백엔드에서 그 일을 처리하는 걸로 만족하겠다.
등장: arborium-rustdoc.
이건 rustdoc 전용 후처리기(post-processor)다. HTML 파일의 코드 블록을 탐지해서 하이라이트한다! 그리고 메인 CSS 파일도 패치해서 하단에 스타일을 추가한다.
나는 _facet 모노레포의 모든 의존성_에 대해 테스트해 봤고, 약 900MB짜리 문서 폴더 크기가 겨우 24KB 늘어나는 데 그쳤다!
(원문에는 영상이 있으나, 여기서는 “브라우저가 video 태그를 지원하지 않는다”는 메시지로 표시되어 있다.)
정말 이 정도는 감당할 수 있기를 바란다. 나는 개인적으로 돈을 보탤 의향도 있다.
이 프로젝트에서 가장 어려웠던 부분은 아마 CI 설정일 것이다. 작은 패키지를 빌드할 때는 GitHub Actions가 그럭저럭 참을 만하지만, 2x96 빌드 + 지원 패키지들까지 오케스트레이션하고, 두 플랫폼에 provenance(출처)까지 포함해서 배포하려면 정말 아니다.
프로젝트를 일찍 포기하지 않게 도와준, 강력한 CI 러너를 아낌없이 기부해 준 Depot.dev에 감사드린다.
그래도 나는 플러그인 잡을 트리 테마의 10개 그룹으로 나눠 분산했다:
CI 실패는 _가혹_하므로, 가능한 한 YAML 밖으로 로직을 빼서 cargo-xtask에 넣었다. 실제로 꽤 친절하다!
(원문에는 영상이 있으나, 여기서는 “브라우저가 video 태그를 지원하지 않는다”는 메시지로 표시되어 있다.)
하지만 이건 진행 바와 nerd font 아이콘만의 문제가 아니다. 우리가 만들어내는 모든 아티팩트가 브라우저에서 로드 가능한지를 확인하기 위해, WebAssembly 번들을 파싱해서 import를 검사한다. 이 과정은 wasm-objdump -x를 대충 grep으로 거르는 대신 walrus를 사용한다.
여기엔 엄청난 빌드 엔지니어링이 들어 있다. 입력을 다시 계산하지 않으려고 blake3 해시를 쓰는데, 대부분은 이름이 멋있어서다. 그 2주 동안 온갖 미친 일이 일어났고 지금은 반도 기억이 안 난다.
나는 arborium을 앞으로 20년은 버틸 수 있게 만들었다. Apache2+MIT 라이선스로 커먼즈에 기부하게 되어 기쁘고, 앞으로 웹에서 정확한 구문 강조 표시가 꽃피는 걸 보고 싶다. 예전에 코드 에디터들이 갑자기 구문 강조를 훨씬 잘하게 된 것처럼.
tree-sitter는 세상을 두 번째로 바꿀 수 있다고 믿는다. 이번에는, 그 모든 조각을 맞출 시간도 노하우도 없는 사람들을 위해서.
자세한 내용은 arborium 웹사이트에 있다.
docs.rs에 한정해서 말하면, 내가 현실적으로 고르자면? 후처리 단계로 arborium-rustdoc를 선택하겠다. 빠르고, 모든 언어 지원을 넣어 빌드할 수 있으며, 다른 두 해결책이 가진 보안 문제나 번들 크기 문제도 없다. 샌드박싱도 가능하다!
즐거운 연말 보내시길!
스폰서들께 감사드립니다:
가능하다면, 감당 가능한 티어에서 이 작업을 후원해 달라:
Bronze Tier*
Silver Tier*
Gold Tier*
나도 영상 만드는 거 알고 있었나? PeerTube와 YouTube에서 볼 수 있다.
여기 당신을 위한 다른 글도 있다: