Skip to content

Commit bd095fd

Browse files
committed
docs(nook): scope tree-sitter highlighting feasibility (roadmap item 3)
1 parent bd1bd45 commit bd095fd

2 files changed

Lines changed: 118 additions & 2 deletions

File tree

cmd/nook/ROADMAP.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,15 @@ Roughly in priority order.
7171
product decision before build — it is a large surface and should not
7272
land half-done.
7373
3. **Tree-sitter highlighting.** Upgrade the syntax backend from chroma
74-
to tree-sitter (via WASM) for incremental, error-tolerant parsing.
75-
chroma stays the fallback for grammars not yet wired.
74+
to tree-sitter for incremental, error-tolerant parsing. chroma stays
75+
the fallback for grammars not yet wired. Feasibility is scoped in
76+
`docs/nook/design/02-tree-sitter-highlighting.md`: a CGO-free pure-Go
77+
runtime (odvcencio/gotreesitter) highlights correctly and reparses
78+
incrementally at ~69µs/snippet, but importing its grammar umbrella
79+
bloats the binary to 31M (init-time registration defeats DCE). The
80+
design pivot is to feed the runtime only the grammar blobs nook wires;
81+
the first slice proves that keeps the binary lean before any host
82+
wiring lands.
7683
4. **Minimap.** Optional document overview gutter. Lowest priority;
7784
useful but not load-bearing for the editing experience. The geometry
7885
foundation is in place: `internal/minimap` is a pure, constant-time
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
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

Comments
 (0)