Zed#
Polytmpl's Zed extension. A thin Rust shell compiled to WebAssembly that:
- registers per-flavour languages for
*.tmpl,*.yaml.tmpl,*.json.tmpl,*.html.tmpl, … - bundles the
tree-sitter-gotmplgrammar - (once Layer 2 lands) launches the polytmpl LSP binary
This README documents Zed-specific details that do not belong in the top-level README. For project-wide architecture, see docs/architecture.md and docs/adrs/01-architecture.md.
The inverted injection pattern#
The per-flavour languages here do not host a base language (YAML, JSON, HTML, …) and inject gotmpl inside it. They do the opposite: gotmpl is the host grammar, and the base language is injected by name into gotmpl's text regions (everything outside {{ … }}).
The reason is an editor-extension constraint. A Zed extension's language config.toml grammar = "…" field resolves only against grammars the extension declares in its own extension.toml. It cannot reference Zed's built-in grammars (yaml, json, …). If we tried the intuitive direction (grammar = "yaml" in a hypothetical YAML Template), Zed fails at load time with no such grammar yaml. The injection lookup ((#set! injection.language "yaml")) does resolve against Zed's full language registry, including built-ins — so the inverted direction works:
; yaml-template/injections.scm
((text) @injection.content
(#set! injection.language "yaml")
(#set! injection.combined))
The combined flag merges every text region into one logical base-language parse, so YAML/JSON/HTML structure is preserved across template actions instead of being parsed fragment-by-fragment.
This is the same pattern used by cabrinha/helm.zed and the Helm dialect of ngalaiko/tree-sitter-go-template. The ADR implementation notes capture the postmortem of why the obvious direction does not work.
The duplication trade-off#
Zed loads tree-sitter queries per language, not per grammar. Every per-flavour language needs its own highlights.scm even though they all share the gotmpl grammar. We accept the duplication and use the workspace generator script (see Adding a new flavour below) to keep the copies in sync. Each generated file carries a header comment flagging the sync requirement.
Current languages#
All flavours share grammar = "gotmpl". Edit the matching config.toml to change file patterns.
| Directory | Display name | File patterns | Injected base | Availability |
|---|---|---|---|---|
languages/gotmpl/ |
Go Template | *.tmpl, *.gotmpl |
(none — pure gotmpl) | always |
languages/yaml-template/ |
YAML Template | *.yaml.tmpl, *.yml.tmpl |
yaml |
Zed built-in |
languages/json-template/ |
JSON Template | *.json.tmpl |
json |
Zed built-in |
languages/toml-template/ |
TOML Template | *.toml.tmpl |
toml |
Zed built-in |
languages/sql-template/ |
SQL Template | *.sql.tmpl |
sql |
community extension (install separately) |
languages/html-template/ |
HTML Template | *.html.tmpl, *.gohtml |
html |
Automatically installed (see docs) |
If you open an *.html.tmpl file without the Zed HTML extension installed, the non-template parts will appear unhighlighted but the gotmpl actions still colour correctly.
Adding a new flavour#
Use the workspace generator script:
pnpm --filter polytmpl-scripts run generate-zed-language <lang-id> [display-name]
Examples:
pnpm --filter polytmpl-scripts run generate-zed-language toml
pnpm --filter polytmpl-scripts run generate-zed-language dockerfile "Dockerfile Template"
The generator writes config.toml, injections.scm, and highlights.scm into a new languages/<lang-id>-template/ directory using the inverted-injection pattern, and prints a post-generation checklist. See scripts/README.md for full options.
Build & test#
Prerequisites: run mise install at the repo root to provision Rust and the tree-sitter CLI.
One-time setup for local development#
This setup is required only for testing the extension as a dev extension on your own machine. End users installing the published extension from the Zed Extension Registry need none of this — Zed downloads a prebuilt WASM artifact and runs it directly. No Rust, no Cargo target, no compilation on the user's side.
Zed runs as a macOS GUI app and queries mise shims at the global default, not the per-directory default declared in mise.toml. Set the global Rust version and add the WebAssembly target once:
mise use -g rust@1.95.0
rustup target add wasm32-wasip2
Build the WASM#
# From the repo root:
cargo build --manifest-path zed/Cargo.toml --target wasm32-wasip2 --release
# Or from this directory:
cargo build --target wasm32-wasip2 --release
Zed rebuilds the extension automatically every time you reinstall it as a dev extension, so this local build is only useful for catching compile errors early.
Configure local offline loading#
extension.toml's [grammars.gotmpl] block points at your local repo path and at a commit whose tree contains the tree-sitter-gotmpl/ subtree you want to test:
[grammars.gotmpl]
repository = "/absolute/path/to/polytmpl"
commit = "<a SHA in this repo whose tree-sitter-gotmpl/ matches what you want>"
path = "tree-sitter-gotmpl"
Bump the commit SHA whenever tree-sitter-gotmpl/ changes. Editor-side files (languages/, src/) are read straight from this directory and do not need a SHA bump.
Install the dev extension#
In Zed:
Cmd+Shift+P→zed: install dev extension- Select this
zed/directory.
Zed compiles the Rust crate to WASM, checks out the grammar at the configured commit, and registers everything under languages/.
Logs#
Almost every dev-extension issue surfaces in Zed's main log: extension build output, grammar query errors, LSP launches, and the LSP binary's own stderr all stream there.
| Platform | Log path |
|---|---|
| macOS | ~/Library/Logs/Zed/Zed.log |
| Linux | ~/.local/share/zed/logs/Zed.log |
| Windows | %LOCALAPPDATA%\Zed\logs\Zed.log |
Open from the command palette: zed: open log. Or follow polytmpl-related lines as you test:
tail -f ~/Library/Logs/Zed/Zed.log | grep -iE 'polytmpl|gotmpl|extension|language server|grammar'
Useful lines to scan for:
| Pattern | Meaning |
|---|---|
INFO [lsp] starting language server process. binary path: "…polytmpl-lsp" |
Zed found and launched the LSP binary. Phase 1 success signal. |
polytmpl-lsp starting / initialize from Zed … |
The LSP's own stderr — confirms the handshake started. |
ERROR [extension::extension_builder] … |
Rust crate or grammar compilation failed during dev install. |
ERROR [language::language_registry] failed to load language … |
A languages/<name>/ config or query file is malformed; the message usually names the offending grammar or capture. |
ERROR [language_core::grammar] missing required capture … |
A .scm file is missing a @injection.content or similar required capture. |
Verify#
Open one of the fixtures in ../tests/fixtures/:
plain.tmpl— pure Go template, no base layer.config.yaml.tmpl— YAML base + gotmpl actions.data.json.tmpl— JSON base + gotmpl actions.simple.html.tmpl— HTML base + gotmpl actions (requires Zed's community HTML extension).
Both layers should be highlighted. Common failure modes:
| Symptom | Likely cause |
|---|---|
| No highlighting anywhere | Extension didn't load. Check ~/Library/Logs/Zed/Zed.log. |
| Base language highlighted, gotmpl actions plain | The per-flavour language's highlights.scm is missing or out of sync with languages/gotmpl/highlights.scm. Regenerate with the script. |
| gotmpl actions highlighted, base language plain | The injected language id is not registered in Zed (built-in or community extension). Install the relevant extension or adjust injections.scm. |
Files in this directory#
| Path | Role |
|---|---|
extension.toml |
Extension manifest: metadata, grammar declaration, (future) language-server registration. |
Cargo.toml, Cargo.lock, src/ |
Rust crate compiled to wasm32-wasip2. Currently a minimal shell that will be wired to launch the polytmpl LSP. |
languages/<name>/ |
Per-language configuration: config.toml plus highlights.scm and injections.scm. |