
Pierre Computer Company가 WebAssembly Text(WAT)로 작성한 구문 강조 라이브러리 Highlights를 공개했다. 80개 언어를 지원하며 HTML과 테마가 적용된 토큰을 만들고, 입력 스트리밍과 편집에 따른 증분 갱신도 처리한다. 회사가 공개한 벤치마크에서는 대용량 파일의 HTML 생성 처리량이 Shiki보다 최대 298배 높았지만, 수치는 특정 기기와 실행 조건에서 측정한 결과다.
Highlights는 공개 저장소에 구현이 공개돼 있으며, 문서는 Highlights 안내에서 확인할 수 있다.
대용량 파일의 HTML 생성 벤치마크는 Bun 1.4.0과 Apple M4 Pro에서 2026년 10월 5일 측정했다. Highlights의 UTF-8 인코딩과 HTML 디코딩을 포함했고, Shiki는 문자열 API 기준선으로 사용했다. Tree-sitter는 tree-sitter-highlight 1.1.2를 사용했다. 각 사례는 200ms 워밍업 뒤 1.5초 동안 표본을 수집했고, 최소 20개 표본을 얻도록 경쟁자 실행 순서를 바꿨다. 처리량은 약 5ms 단위 배치에서 호출별 평균을 구한 뒤 그 중앙값으로 계산했다. 기록된 결과와 벤치마크에서 조건과 재현 자료를 볼 수 있다.
- CSS 379KiB: Highlights 297MiB/s, Tree-sitter 11.3MiB/s, Shiki 1.22MiB/s. Shiki 대비 244배다.
- HTML 474KiB: Highlights 615MiB/s, Tree-sitter는 측정되지 않았고 Shiki는 2.8MiB/s다. Shiki 대비 220배다.
- JSONC 292KiB: Highlights 928MiB/s, Tree-sitter 25.6MiB/s, Shiki 5.89MiB/s다. Shiki 대비 157배다.
- TypeScript 517KiB: Highlights 320MiB/s, Tree-sitter 9MiB/s, Shiki 1.07MiB/s다. Shiki 대비 298배다.
구문 강조 방식과 설계
구문 하이라이터는 소스 코드를 토큰으로 나누고, 테마를 적용해 색을 정한다. 예를 들어 const greeting = "hello";에서 const를 키워드로, "hello"를 문자열로 식별한다. 출력은 렌더러가 사용할 토큰일 수도 있고, 스타일이 적용된 요소를 담은 HTML일 수도 있다. 토큰을 식별하는 과정이 대부분의 작업을 차지한다.
Shiki, Prism, highlight.js, Sugar High, Rangi는 정규식을 사용해 토큰을 찾는다. 정규식은 키워드나 숫자, 따옴표로 둘러싸인 문자열의 모양을 간결하게 나타내며, Prism처럼 언어마다 패턴을 정의해 지원 범위를 확장할 수 있다. 다만 여러 규칙이 같은 코드 구간을 각각 살펴볼 수 있고, 복잡한 정규식은 일치에 실패하면 다른 경로를 다시 탐색할 수 있다. 따라서 큰 파일을 처리하거나 편집할 때마다 강조를 다시 수행하면 성공한 일치, 실패한 시도, 반복 스캔의 비용이 누적된다. V8의 정규식 논백트래킹 설명도 이 문제를 다룬다.
Tree-sitter는 다른 접근을 취한다. 소스 코드를 구두점 등 세부 정보를 보존하는 구체 구문 트리로 파싱한 뒤, 쿼리로 트리의 노드에 강조 이름을 붙이고 테마가 이를 스타일에 대응시킨다. 이 구조는 함수 이름과 매개변수처럼 코드 안의 위치에 따라 항목을 구별할 수 있다. 트리를 만들고 보관하고 질의하는 데 시간과 메모리가 들지만, 코드 탐색 같은 기능에도 활용할 수 있다. 편집 후에는 트리를 증분 갱신해 바뀌지 않은 부분을 재사용한다. 따라서 전체 파일을 대상으로 하는 벤치마크와 사용자가 입력하는 동안 트리를 유지하는 작업은 서로 다른 부하를 측정한다. Tree-sitter의 구문 강조 규칙과 프로젝트 문서에서 자세한 내용을 볼 수 있다.
Vercel Labs의 슈 딩(Shu Ding)은 최근 WebGPU에서 작은 학습 모델을 실행하는 실험적 하이라이터 gpu-lexer를 소개했다. 이 방식은 언어 인자나 언어별 문법을 별도로 요구하지 않고 여러 언어를 처리하는 모델 하나를 사용한다. CPU가 먼저 소스를 단어, 공백, 기호 같은 단순한 부분으로 나누고, GPU가 그 조각과 문맥의 특징을 처리해 아홉 가지 시각적 분류 중 하나를 예측한다. 공개 API인 await parse(code)는 자바스크립트 문자열 오프셋을 포함한 분류 구간을 반환한다. 프로젝트 README는 처리 과정과 보안 브라우저 문맥에서 WebGPU가 필요하다는 조건을 설명한다.
학습 방식은 명시적으로 지원하도록 설정하지 않은 언어도 강조를 시도할 수 있다는 점에서 흥미롭다. 다만 결과 품질은 모델이 학습한 내용과 입력에 얼마나 잘 일반화되는지에 달려 있다. 모델 카드에 따르면 평가는 의미상 정확성을 재는 대신 Shiki가 붙인 라벨과의 일치도를 측정한다.
개발사가 제공한 three.min.js 처리 시간 비교는 Chromium 153과 Apple M4 Pro에서 2026년 10월 21일 측정한 결과다. Highlights 0.2.0은 npm에서 설치했고, 초기화를 제외한 세 차례 실행의 중앙값을 사용했다. 각 표본은 새 워커에서 한 번 워밍업했다. DOM 렌더링은 측정에 포함하지 않았다. Highlights와 Shiki는 테마가 적용된 자바스크립트 토큰을 반환하는 codeToTokens()를 사용했고, gpu-lexer는 분류 구간, Starry Night는 HAST 트리, Sugar High·Prism.js·Highlight.js는 HTML을 반환했다. 이는 대형 자바스크립트 입력 하나에서 각 API 경로의 시간을 비교한 것이며 강조 품질을 측정하지 않는다. 기록된 표본에는 버전과 실행 조건이 담겼다.
- Highlights 0.2.0의 테마 토큰 반환은 81.6ms였다.
- gpu-lexer의 구간 반환은 400.8ms, Sugar High의 HTML 반환은 761.6ms, Prism.js는 994.4ms, Highlight.js는 1.35초였다.
- Starry Night의 HAST 반환은 10.50초, Shiki의 토큰 반환은 28.28초였다.
Highlights의 구현과 사용
Highlights의 처리 흐름은 소스 텍스트를 UTF-8 바이트로 WebAssembly 메모리에 넣고, 언어별 렉서가 이를 스캔한 뒤 HTML 바이트나 간결한 토큰 기록을 내보내는 방식이다. 자바스크립트는 공개 API, 입력 인코딩, 테마 준비를 담당한다. WebAssembly 모듈은 소스를 스캔해 HTML 또는 토큰 기록을 만든다. 처리 도중 구문 트리를 만들지는 않는다. 구성 경계는 아키텍처 노트에 설명돼 있다.
각 언어의 렉서는 WAT로 직접 작성하며 공통 스캐너를 함께 사용한다. 렉서는 소스를 훑으며 키워드, 숫자, 주석, 문자열 같은 의미 있는 범위를 찾는다. 복잡한 언어는 더 많은 상태를 필요로 하고, 내장 언어는 다른 렉서에 처리를 넘길 방법도 필요하다. 예를 들어 자바스크립트 렉서는 HTML 안의 <script> 영역을 범위에 맞춰 처리한 다음 HTML 렉서로 제어를 돌려준다. 지원되는 Markdown 코드 펜스도 같은 방식으로 처리한다.
이 설계에서는 언어 지원이 명시적이다. 새 언어를 추가하려면 렉서를 구현하고 유지해야 한다. 기존 TextMate 문법을 그대로 불러올 수 없으며, 결과가 Shiki나 Tree-sitter와 항상 똑같이 코드를 분류하지도 않는다. 예를 들어 TypeScript의 const greeting = 1;에서 Highlights는 greeting을 variable로 분류하지만 Shiki는 TextMate 범위인 variable.other.constant.ts를 부여한다. 기본 제공 GitHub Dark 테마를 적용하면 해당 식별자는 Highlights에서 전경색으로, Shiki에서 파란색으로 표시된다. 기존 하이라이트와 일치하는 것이 중요하다면 전환 전에 실제 파일을 비교해야 한다.
Highlights는 긴 주석이나 문자열처럼 대부분의 문자가 특별한 처리를 요구하지 않는 구간을 빠르게 건너뛴다. WebAssembly SIMD를 사용하면 한 명령으로 여러 값을 처리할 수 있다. 이 경우 스캐너가 16바이트를 읽어 찾는 문자와 한꺼번에 비교하고, 비트 마스크로 첫 일치 위치를 찾는다. 긴 주석에서는 한 번에 16바이트씩 진행한다. 반면 토큰 사이의 짧은 공백은 SIMD 준비 비용을 피하도록 일반 스칼라 경로로 처리한다. 키워드 조회에는 빌드 때 준비한 테이블을 사용한다. 선택한 바이트와 단어 길이로 해시를 구한 뒤 후보의 실제 바이트를 확인해 언어별 키워드를 찾는다.
HTML 출력기는 WebAssembly 메모리에 이스케이프된 텍스트와 스타일이 적용된 요소를 직접 쓴다. 자바스크립트가 미리 테마를 준비하며, 일반 색상 테마는 토큰 유형별 색과 글꼴 설정을 담은 간결한 테이블로 바뀐다. 같은 스타일을 쓰는 인접 범위는 그 사이 공백을 포함해 하나의 요소를 공유할 수 있고, 여는 태그도 재사용한다. 이런 처리는 HTML 생성 작업과 브라우저가 받는 마크업 양을 줄인다.
공개 API는 UTF-8 바이트를 반환한다.
import { codeToHtml } from '@pierre/highlights';
import { pierreDark } from '@pierre/highlights/themes';
const bytes = codeToHtml('const greeting = "hello";', {
lang: 'js',
theme: pierreDark,
});
const html = new TextDecoder().decode(bytes);
패키지는 Node.js, 브라우저, Cloudflare Workers에서 실행된다. WebAssembly를 자동으로 초기화하므로 가져온 뒤의 강조 호출은 동기식이다. Zed 호환 테마 객체를 받으며, @pierre/highlights/themes에서 pierreDark나 githubLight 같은 기본 테마를 가져오거나 직접 테마를 제공할 수 있다.
StreamTokenizer는 텍스트 조각이나 UTF-8 바이트 조각을 받아 완성된 줄의 테마 토큰을 내보낸다. 조각 사이에 렉서 상태를 보존하므로 주석이나 문자열이 다음 조각까지 이어져도 앞선 줄을 다시 스캔하지 않는다. 끝나지 않은 마지막 줄은 줄바꿈이 오거나 호출자가 end()로 스트림을 끝낼 때까지 버퍼에 남는다. 스트리밍 문서는 API를 안내한다.
import { StreamTokenizer } from '@pierre/highlights';
import { pierreDark } from '@pierre/highlights/themes';
const response = await fetch('https://example.com/source.ts');
const lines = response.body.pipeThrough(
new StreamTokenizer({ lang: 'ts', theme: pierreDark })
);
for await (const tokens of lines) {
console.log(tokens);
}
LiveTokenizer는 기존 텍스트의 편집을 처리한다. 문서, 줄별 토큰 기록, 저장된 렉서 상태를 자체 WebAssembly 인스턴스에 보관한다. 편집 뒤에는 영향을 받은 줄을 다시 토큰화하고, 변경되지 않은 경계에서 출력 상태가 이전에 저장한 상태와 같아질 때까지 이어 간다. 그 경계 너머의 기존 결과는 재사용할 수 있다. 증분 편집 문서에 관련 설명이 있다.
import { LiveTokenizer } from '@pierre/highlights';
import { pierreDark } from '@pierre/highlights/themes';
const live = new LiveTokenizer({
code: 'let count = 1;\nconsole.log(count);',
lang: 'js',
theme: pierreDark,
});
live.applyEdits([{
range: { start: { line: 0, character: 12 }, end: { line: 0, character: 13 } },
newText: '2',
}]);
console.log(live.getLineText(0)); // let count = 2;
console.log(live.getLineTokens(0).tokens);
live.dispose();
일부 편집은 여전히 파일 전체에 영향을 준다. 파일 앞부분에서 여러 줄에 걸친 주석을 열면 뒤의 모든 줄을 토큰화하는 방식이 달라질 수 있다. renderRange를 사용하면 라이브 토크나이저는 화면에 보이는 범위의 끝까지 처리하면서 렉서 상태를 확립하는 데 필요한 앞쪽의 변경된 줄도 포함한다. 나머지 작업은 백그라운드 조각으로 마치고 onDeferTokenize를 통해 토큰을 전달한다. 따라서 편집기는 나머지 처리가 끝나기를 기다리지 않고 보이는 줄부터 갱신할 수 있다.
WAT 선택과 메모리 관리
WAT는 WebAssembly 명령의 텍스트 표현이다. 직접 작성하면 로드, 저장, 분기, 벡터 연산을 명시적으로 다뤄야 한다. Rust와 Zig도 WebAssembly로 컴파일할 수 있고 메모리와 SIMD를 제어할 수 있지만, Pierre Computer Company가 WAT를 택한 이유는 작업 방식에 있다. 소스에 살펴보고 조정하려는 로드, 비교, 분기를 직접 표현할 수 있기 때문이다. 예를 들어 16바이트 주석 스캔은 벡터 로드, 비교, 비트 마스크 확인으로 작성한다. 반복적인 스캔이 중심인 프로그램에서는 명령 표현을 직접 다루는 것이 유용하다고 설명한다. 대신 세부 사항을 직접 유지해야 한다.
테이블과 상수를 모두 손으로 쓰면 렉서 유지보수가 어려워진다. 전처리기 설명에 따르면 프로젝트는 몇 가지 문법을 추가해 빌드 중 표준 WAT로 확장한다. 로컬 가져오기는 공통 코드를 한 모듈로 가져오며 각 파일을 한 번만 포함한다. 열거형은 토큰 유형에 숫자 ID를 부여하고, 이름 있는 상수는 원시 오프셋을 반복하지 않고 메모리 주소를 가리킨다.
(import "./langs/tsx.wat")
WABT가 확장된 WAT를 WebAssembly로 조립하고 Binaryen이 이를 최적화한다. 이런 편의 기능은 빌드 과정에서 처리되며, 실행 중에는 일반 WebAssembly 명령으로 바이트를 스캔한다.
모듈은 바이트 오프셋으로 접근하는 확장 가능한 버퍼인 WebAssembly 선형 메모리를 사용한다. 제어 데이터, 테이블, 렉서 상태, 입력, 출력에 공간을 배정한다. 문자열은 입력 영역에 직접 인코딩할 수 있고, 출력 영역은 필요하면 커진다. HTML 경로는 토큰마다 자바스크립트 객체를 만들지 않고 범위를 처리한다. 라이브 토크나이저에는 문서 텍스트, 줄 기록, 저장된 상태를 편집 사이에 보관하는 별도 힙이 있다.
메모리 소유권은 API 사용에도 영향을 준다. codeToHtml()이 반환하는 바이트는 재사용 가능한 메모리를 가리킨다. 따라서 위 예시처럼 곧바로 디코딩하거나 다음 강조·토큰화 호출 전에 .slice()로 복사해야 한다. 테마가 적용된 자바스크립트 토큰을 요청하면 객체가 만들어지지만, 원시 기록을 사용하는 소비자는 그 작업을 미룰 수 있다.
메모리 비교는 Bun 1.4.0과 Apple M4 Pro에서 2026년 10월 5일, 각 사례마다 새 프로세스 다섯 개를 실행해 최대 RSS를 측정했다. RSS에는 런타임, 컴파일된 코드, Wasm, 할당자 용량이 포함된다. Highlights의 HTML 결과에는 디코딩도 포함했다. 양쪽 라이브러리는 GitHub Dark를 사용했다. Highlights는 내장 렉서를 모두 포함했고, Shiki는 줄 길이와 시간 제한을 끈 상태로 TypeScript를 불러왔다. 기록 결과와 벤치마크 데이터에서 방법을 확인할 수 있다. 단위는 MiB이며, 각 항목은 Highlights·Shiki JS·Shiki Wasm 순이다.
- 2KiB TypeScript, HTML: 37·80·148. 토큰: 37·78·147.
- 31KiB TypeScript, HTML: 38·91·181. 토큰: 41·91·171.
- 517KiB TypeScript, HTML: 50·155·349. 토큰: 55·112·303.
- 5MiB TypeScript, HTML: 104·739·963. 토큰: 133·326·488.
크기와 제약
스캐너, 키워드 테이블, 언어 분기, 출력기는 하나의 모듈에 들어간다. 렉서끼리 공통 연산을 재사용할 수 있고 빌드에서 테이블과 상수의 표현을 조정할 수 있다. 예컨대 같은 바이트로 채운 벡터는 전체 벡터 상수로 인코딩하는 것보다 작은 정수 하나를 불러 반복하는 편이 공간을 덜 쓴다. 최적화 뒤 빌드는 이런 반복 바이트 상수를 다시 써서 공간을 줄인다.
공개된 Highlights 0.2.0의 Wasm 모듈은 183,180바이트이며 gzip 레벨 9로 압축하면 84,987바이트다. 이 크기에는 내장 언어 80개의 렉서가 포함되지만 자바스크립트 래퍼와 테마 자산은 포함되지 않는다.
브라우저 번들 크기 비교는 최소화한 뒤 Brotli로 압축한 런타임과 선택 언어의 크기이며 테마는 제외했다. KB는 1,024바이트다. Highlights 0.2.0은 npm에서 설치해 2026년 10월 21일 측정했다. 다른 제품 수치는 9월 9일 기록된 gpu-lexer 크기 비교에서 가져왔다. 크기 데이터에는 지원 범위, 빌드 설정, 산출물 해시가 담겼다.
- Sugar High는 주요 웹 언어 6개에서 5.4KB, 전체 29개 언어에서 8.8KB다. Prism.js는 주요 웹 언어 6개에서 8.6KB, 전체 297개에서 162.1KB다.
- Highlight.js는 주요 웹 언어 6개에서 14.9KB, 전체 193개에서 240.4KB다. gpu-lexer 모델 하나는 27.4KB다.
- Highlights 0.2.0은 전체 80개 언어에서 99.0KB다. Starry Night는 주요 웹 언어 6개에서 185.3KB, 전체 710개 문법에서 1.46MB다.
- Shiki는 주요 웹 언어 6개에서 213.9KB, 전체 242개 문법에서 991.5KB다.
WAT를 쓰면 상위 수준 언어가 보통 관리해 주는 버퍼 경계, 임시 공간, 상태 배치, 불완전한 입력에 대한 각 렉서의 동작을 프로젝트가 직접 책임져야 한다. 런타임 차이도 고려해야 한다. 빌드에는 SIMD 호출 규약과 일부 정수식에 관한 JavaScriptCore 우회 처리가 들어 있다. 테스트로 다루고 있지만, WebAssembly에 가까운 층에서 작업해도 주변 엔진의 영향을 받는다는 점을 보여준다. 구현이 커질수록 경계를 이해하기 쉽게 유지하는 일도 관리 비용이 된다.
Diffs 통합과 성능 비교
프로젝트를 시작한 이유는 Diffs에서 강조 처리 비용을 줄이는 것이었다. 렉서가 빨라도 파일 렌더러, 편집기, 워커 풀에 들어갔을 때의 효과를 확인해야 한다. 통합 PR은 File, FileDiff, Editor, FileStream, 서버 렌더링, 워커에서 Highlights를 선택형 백엔드로 추가했다. 기본값은 여전히 Shiki의 자바스크립트 엔진이다. Highlights를 선택하려면 preferredHighlighter를 설정한다.
import { FileDiff } from '@pierre/diffs';
const diff = new FileDiff({
theme: { dark: 'pierre-dark', light: 'pierre-light' },
diffStyle: 'split',
preferredHighlighter: 'highlights',
});
각 백엔드는 필요할 때 불러온다. 컴포넌트가 워커 풀을 쓰면 풀에서 백엔드를 선택하므로 highlighterOptions에도 preferredHighlighter를 지정해야 한다. 기본 테마 이름은 백엔드 사이에서 사용할 수 있지만, 사용자 정의 Highlights 테마는 Zed 형식을 쓴다. 사용자 정의 TextMate 문법은 계속 Shiki에서 사용해야 하며 Highlights가 지원하지 않는 언어는 일반 텍스트로 렌더링한다.
공통 하이라이터 인터페이스는 토큰, 테마, 스트리밍, 실시간 편집을 처리한다. 각 백엔드는 동일한 렌더러에 토큰을 전달해 줄 마크업과 차이 표시를 만든다. 따라서 Diffs의 서버 렌더링 HTML은 자바스크립트 토큰과 공통 렌더러를 거치며, 앞서 설명한 독립형 Wasm-to-HTML 경로를 사용하지 않는다.
PR 성능 비교는 Shiki WASM을 쓰는 main 브랜치와 Highlights를 통합한 브랜치를 비교했다. Diffs의 기본값인 Shiki JS는 비교 대상이 아니므로, 보고된 개선은 Shiki WASM을 기준으로 한다. 작업 대상은 TypeScript 529KB 파일 10,825줄이며, 어두운 테마 하나와 전체 펼침 분할 diff를 사용했다. Apple M4 Pro에서 SSR과 토크나이저 스트리밍은 Bun 1.4.0, 브라우저 렌더링은 Chrome 151로 측정했다. 가상화와 워커 수는 항목별로 다르다. 결과에는 통합 변경도 포함된다. PR #1141의 기록 중앙값과 벤치마크 데이터를 확인할 수 있다. 각 막대 쌍은 자체 선형 눈금을 쓴다.
- 가상화로 첫 100줄을 렌더링하는 시간은 Shiki WASM 925.5ms, Highlights 23.9ms로, 보고된 속도 향상은 38.7배다. 워커 풀을 더한 100줄 렌더링은 989.9ms에서 70.0ms로 14.1배다.
- 전체 10,825줄 렌더링은 1,270.3ms에서 320.7ms로 4배, 분할 diff는 2,679.2ms에서 724.2ms로 3.7배, 통합 diff는 2,291.5ms에서 382.9ms로 6배다.
- 전체 파일 SSR은 978.0ms에서 15.4ms로 63.4배, 전체 diff SSR은 2,012.6ms에서 45.6ms로 44.1배다.
- 4KiB 조각을 쓰는 스트리밍은 452.3ms에서 17.9ms로 25.3배다. 파일 8개와 워커 1개를 쓴 워커 풀은 7,811.8ms에서 181.5ms로 43배, 워커 4개에서는 2,114.4ms에서 97.2ms로 21.8배다.
전체 파일 렌더링에서 첫 100줄보다 개선 폭이 작다. 하이라이트 비용을 줄여도 브라우저가 처리해야 할 수천 줄의 DOM은 남기 때문이다. 다른 경로에서도 전체 파일 SSR은 978.0ms에서 15.4ms로, 4KiB 조각 토크나이저 스트리밍은 452.3ms에서 17.9ms로 줄었다. 워커는 여전히 작업을 분산하는 데 도움이 되며, Highlights는 각 워커가 처리하는 작업 자체를 줄인다.
Highlights는 아직 실험 단계이고 렉서의 출력은 Shiki와 다를 수 있다. 플레이그라운드에서 통합을 시험할 수 있다. 제품을 선택할 때는 손으로 작성한 렉서를 계속 관리해야 한다는 점, TextMate 문법 생태계가 더 넓다는 점, 구문 트리가 필요한 애플리케이션은 그 구조를 제공하는 도구가 유리하다는 점을 고려해야 한다. 하이라이트 처리가 빨라져도 브라우저의 레이아웃, 페인팅, DOM 처리 비용은 남는다.
Highlights를 설치하려면 pnpm add @pierre/highlights를 실행한다. 구현과 문서는 저장소와 문서에서 확인할 수 있다.
댓글 (0)
로그인하면 이 기사에 내 생각을 남길 수 있어요