|
| 1 | +# Tree-sitter highlighting: feasibility and shape |
| 2 | + |
| 3 | +Status: design, backed by a throwaway spike. Roadmap item 3. Today nook |
| 4 | +highlights with chroma (regex/lexer based). The goal is incremental, |
| 5 | +error-tolerant parsing so highlights stay correct mid-edit and so the |
| 6 | +same trees can later feed folding, structural selection, and tags. |
| 7 | +chroma stays the fallback for grammars we do not wire. |
| 8 | + |
| 9 | +## The hard constraint the choice has to respect |
| 10 | + |
| 11 | +nook ships as a single binary installed with |
| 12 | +`go install github.com/truffle-dev/glyph/cmd/nook@latest`, and its north |
| 13 | +star is the lightest, fastest terminal IDE. Two consequences gate the |
| 14 | +backend before any API taste question: |
| 15 | + |
| 16 | +- No C toolchain at install time. That rules out the CGO bindings |
| 17 | + (`smacker/go-tree-sitter`, `tree-sitter/go-tree-sitter`): they need |
| 18 | + `CGO_ENABLED=1`, a C compiler, and the grammar C sources on the user's |
| 19 | + machine. A CGO dependency turns `go install` into "install a compiler |
| 20 | + first," which is a regression we will not ship. |
| 21 | +- Binary size is a feature. nook is ~5.6M today. A backend that adds tens |
| 22 | + of megabytes fails the north star even if it works. |
| 23 | + |
| 24 | +That leaves two CGO-free families, both pure Go: |
| 25 | + |
| 26 | +1. `github.com/odvcencio/gotreesitter` — a pure-Go reimplementation of the |
| 27 | + tree-sitter runtime (parser, lexer, query engine, incremental reparse), |
| 28 | + 200+ grammars, built-in highlight queries. Cross-compiles anywhere Go |
| 29 | + does, including wasip1. |
| 30 | +2. `github.com/malivvan/tree-sitter` — the upstream C tree-sitter compiled |
| 31 | + to WASM, run under `wazero` (pure-Go WASM runtime). You embed only the |
| 32 | + grammar `.wasm` blobs you ship. |
| 33 | + |
| 34 | +## What the spike measured (gotreesitter v0.20.9) |
| 35 | + |
| 36 | +A standalone module, built with `CGO_ENABLED=0`, exercised the full path |
| 37 | +for a Go source snippet: |
| 38 | + |
| 39 | +- `grammars.DetectLanguage("main.go")` resolves the language from the |
| 40 | + filename; `entry.Language()` is the lazy grammar loader and |
| 41 | + `entry.HighlightQuery` is the bundled `.scm`. |
| 42 | +- `ts.NewHighlighter(lang, entry.HighlightQuery).Highlight(src)` returned |
| 43 | + 14 `HighlightRange{StartByte, EndByte, Capture}` spans with correct |
| 44 | + captures — `keyword`, `string`, `comment`, `type`, `property`, |
| 45 | + `variable`. Capture names map cleanly onto nook's existing highlight |
| 46 | + style classes, the same seam chroma feeds today. |
| 47 | +- `HighlightIncremental(src, oldTree)` reparsed after an edit and returned |
| 48 | + an updated span set. Incremental reparse is the whole reason to leave |
| 49 | + chroma, and it works. |
| 50 | +- Latency: highlighting a small function was ~69µs. Well inside a |
| 51 | + first-paint budget for a screenful; nothing here blocks a frame. |
| 52 | + |
| 53 | +So the runtime is correct, CGO-free, and fast. The problem is size. |
| 54 | + |
| 55 | +## The size finding, and the pivot it forces |
| 56 | + |
| 57 | +Importing `github.com/odvcencio/gotreesitter/grammars` produced a **31M** |
| 58 | +binary — a 6x bloat over nook's ~5.6M — and it stayed 31M even when the |
| 59 | +program referenced only `grammars.GoLanguage()`. Go's dead-code |
| 60 | +elimination cannot help: the grammars package registers every language |
| 61 | +through `Register(LangEntry{...})` calls at package `init()` |
| 62 | +(`registry_builtin_gen.go`, 542 init-bearing files), and init side |
| 63 | +effects are never eliminated. Importing the umbrella for one language |
| 64 | +costs all 200. |
| 65 | + |
| 66 | +That kills the naive "import the grammars package" approach for nook. It |
| 67 | +does not kill gotreesitter. The runtime exposes the escape hatch directly: |
| 68 | +`ts.LoadLanguage(data []byte)` loads a grammar from a serialized blob, and |
| 69 | +the grammars tree carries `blob_source_external*.go` and |
| 70 | +`blob_source_subset_embedded.go` — i.e. the runtime already supports |
| 71 | +loading grammar blobs from disk and embedding a chosen subset rather than |
| 72 | +the whole registry. The design pivot is to bypass the umbrella package and |
| 73 | +feed the runtime only the grammar blobs nook wires. |
| 74 | + |
| 75 | +## Proposed first slice (green, size-gated) |
| 76 | + |
| 77 | +Prove the lean path before building any host wiring: |
| 78 | + |
| 79 | +1. A tiny spike that embeds exactly one grammar blob (Go) via the |
| 80 | + subset/external blob source and calls `ts.LoadLanguage` + |
| 81 | + `NewHighlighter`, with **no import of the umbrella `grammars` |
| 82 | + package**. Success criterion is a binary within a small delta of |
| 83 | + baseline (target: single-digit MB add, not 29M) that still highlights |
| 84 | + Go correctly. If subset-embed cannot escape the registry init, the blob |
| 85 | + loads from disk at runtime instead (tree-sitter becomes opt-in, grammar |
| 86 | + blobs shipped beside the binary or fetched on first use). |
| 87 | +2. If neither keeps the binary lean, fall back to the WASM/wazero family |
| 88 | + (`malivvan/tree-sitter`): embed only wired `.wasm` grammar blobs, pay |
| 89 | + the wazero runtime once. Re-measure size and latency the same way. |
| 90 | + |
| 91 | +Only after one of those keeps nook light do we touch the host: a |
| 92 | +`tshl` package that wraps the chosen backend behind the same |
| 93 | +capture-name → style-class contract chroma uses today, wired as the |
| 94 | +primary highlighter with chroma as the fallback for unwired languages. |
| 95 | +Nothing here changes the render path until the size question is settled, |
| 96 | +so the first-paint rule and the binary-size north star both hold. |
| 97 | + |
| 98 | +## Open questions for the build hour |
| 99 | + |
| 100 | +- Does subset-embedding one grammar actually avoid the 542 init |
| 101 | + registrations, or is the registry all-or-nothing? (Determines slice 1 |
| 102 | + vs. runtime-blob-loading vs. WASM.) |
| 103 | +- Is upstream willing to expose per-grammar subpackages / DCE-friendly |
| 104 | + registration? A lean single-language import would make this trivial and |
| 105 | + is worth an issue against odvcencio/gotreesitter once the spike pins the |
| 106 | + exact constraint. |
| 107 | +- Highlight query dialect: gotreesitter ships `.scm` queries per language; |
| 108 | + confirm they match nook's expected capture vocabulary or add a small |
| 109 | + capture-name remap in `tshl`. |
0 commit comments