Languages & modes
Support for a language in Macros has two independent layers:
- Syntax highlighting — driven by tree-sitter. A grammar parses the buffer and a highlights query tags nodes with capture names, which map to theme faces.
- A major mode — a keymap plus commands and behavior (folding, navigation, editing helpers), activated when a matching file opens.
You can do either without the other: a file type can get highlighting with no custom mode, and a mode can add behavior on top of the built-in highlighting.
One thing to know up front: a file type whose grammar is already bundled needs only init.scm config. For a brand-new grammar, install-language downloads, compiles, and loads it at runtime — no rebuild, all from your config. Both are covered below.
How highlighting works
A grammar's highlights query tags nodes with standard capture names. The highlighter maps each capture to a theme face — by the capture's dotted prefix, so constant.builtin and string.special resolve to constant and string. The recognized faces are:
comment · string · keyword · function · number · constant · type · operator · punctuation · variable
Because the mapping is by capture name, any standard tree-sitter highlights.scm query colors correctly with no per-language wiring. To restyle, set those faces in your theme — see Themes & faces.
Highlighting a file type (bundled grammar)
These grammars ship in the binary:
scheme · rust · python · javascript · typescript · tsx · json · c · cpp · go · java · scala · clojure · elixir · ruby · lua · zig · r · ocaml · haskell · sql · bash · html · css · yaml · toml · markdown · latex · typst · mermaid
Map a file extension to one of them with set-extension, and highlighting (and the LSP language id) follows automatically:
;; Highlight .conf files using the bundled TOML grammar.
(set-extension "conf" "toml")
When the file type also has a language server, configure both at once with the lsp-language form (see Language servers) — its 'extensions already calls set-extension for you:
(lsp-language "kotlin"
'extensions '("kt" "kts")
'command "kotlin-language-server"
'format-on-save 'lsp)
lsp-languagewires the server and the language id, but highlighting still needs a bundled grammar for that id. If none is bundled (Kotlin isn't), add it as below.
Installing a grammar at runtime
For a language Macros doesn't bundle, install-language downloads a tree-sitter grammar from git, compiles it, and loads it — no rebuild. Put a call in your init.scm, pinning a revision so the build is reproducible:
(install-language "nim"
"https://github.com/alaviss/tree-sitter-nim"
"a4b3e3a" ; commit, tag, or branch
'("nim" "nims")) ; file extensions (no dot)
Once installed, opening any .nim file highlights automatically — the extension lookup falls back to runtime grammars, so you don't need a separate set-extension. A runtime grammar also gets an <id>-mode command (M-x nim-mode), exactly like a bundled language.
You need a C/C++ toolchain. Published grammars ship generated C; Macros compiles it with the host compiler (cc/clang on macOS and Linux, MSVC or MinGW on Windows) — the same toolchain a source build needs. The grammar repo must commit its generated parser.c (almost all do) and a queries/highlights.scm; the query is what produces color, so a parser with no query gives a tree but no highlighting.
The first install from a repo asks for confirmation in the minibuffer — loading a grammar runs native code in-process, so a new source is trusted once (recorded in trusted-grammars in your config dir) and isn't re-prompted on later launches, even though the install-language call re-runs every startup. The compiled library is cached under the OS cache dir, keyed by language id and revision, so relaunching reuses it instead of recompiling.
Grammars in a subdirectory or with a different parser name take two optional trailing arguments — symbol then subdir. Some repos nest the grammar (tree-sitter-typescript keeps it in typescript/) or export a parser symbol (tree_sitter_<symbol>) that differs from the id you chose:
(install-language "typescript"
"https://github.com/tree-sitter/tree-sitter-typescript"
"v0.23.2" '("ts")
"typescript" ; symbol → tree_sitter_typescript
"typescript") ; subdir within the repo
The capture names in the grammar's own highlights.scm flow straight into the faces above, so no query of your own is needed. To build a grammar yourself — for a new or in-house language — see Authoring a grammar repo below.
Authoring a grammar repo
Most languages already have a community grammar — search GitHub for tree-sitter-<language> and point install-language straight at it. Write your own only for a brand-new or in-house language. Either way it helps to know the repo layout Macros consumes.
Repo layout
install-language expects a standard tree-sitter grammar repo:
tree-sitter-foo/
├── grammar.js # the grammar, in tree-sitter's JS DSL (source of truth)
├── src/
│ ├── parser.c # GENERATED from grammar.js — MUST be committed
│ ├── tree_sitter/ # generated headers, committed alongside parser.c
│ │ └── parser.h
│ └── scanner.c # optional hand-written scanner (or scanner.cc for C++)
└── queries/
└── highlights.scm # node patterns → capture names → faces
Macros only ever touches three things in it: it compiles src/parser.c (plus src/scanner.c / scanner.cc if present), reads queries/highlights.scm, and resolves the parser entry point tree_sitter_<name>, where <name> is the name: field in grammar.js. It does not run grammar.js — that's why src/parser.c must be committed (it's the generated output; nearly every published grammar commits it). If <name> differs from the id you pass to install-language, pass it as the symbol argument; if the grammar lives in a subdirectory, pass subdir.
Creating one from scratch
Install the tree-sitter CLI — the tool that turns
grammar.jsintoparser.c:npm install -g tree-sitter-cli # or: cargo install tree-sitter-cliWrite
grammar.js. Thenamebecomes the parser symbol; the rules describe the syntax. A minimal grammar for akey = valueconfig language:module.exports = grammar({ name: 'kv', // → tree_sitter_kv; this is your install-language id extras: $ => [/\s/, $.comment], // whitespace + comments may appear between tokens rules: { document: $ => repeat($.pair), pair: $ => seq(field('key', $.key), '=', field('value', $.value)), key: $ => /[A-Za-z_][A-Za-z0-9_]*/, value: $ => choice($.number, $.string), number: $ => /\d+/, string: $ => /"[^"]*"/, comment: $ => /#.*/, }, });The grammar DSL —
seq,choice,repeat,field, precedence, conflicts, and hand-written external scanners for context-sensitive tokens (indentation, heredocs) — is a subject of its own; see tree-sitter's Creating parsers guide.Generate and check:
tree-sitter generate # writes src/parser.c and src/tree_sitter/*.h tree-sitter parse example.kv # print the parse tree to sanity-check itWrite
queries/highlights.scm(next section).Commit
src/parser.c,src/tree_sitter/, anysrc/scanner.*, andqueries/highlights.scm. Tag a release or note the commit SHA — that's therevyou pin ininstall-language.Point
install-languageat it:(install-language "kv" "https://github.com/you/tree-sitter-kv" "v0.1.0" '("kv" "conf"))
Writing the highlights query
queries/highlights.scm is a list of tree-sitter query patterns, each tagging a node with an @capture. The capture name — by its dotted prefix — selects the face. For the kv grammar above:
(comment) @comment
(key) @property ; @property aliases to the `variable` face
(number) @number
(string) @string
"=" @operator ; anonymous tokens are matched as string literals
(rule_name) @captags a named node;"literal" @captags an anonymous token — a keyword or punctuation written literally in the grammar.- Only the dotted prefix matters:
@string.special→string,@keyword.control→keyword. Stick to the conventional nvim-treesitter capture names and existing themes color your language with no extra work. A capture Macros doesn't recognize simply renders uncolored — never an error — so an over-specific query degrades gracefully.
Macros resolves captures to these faces (direct prefix matches plus the common aliases):
| Face | Captures that resolve to it |
|---|---|
comment |
comment |
string |
string, character, escape |
keyword |
keyword, conditional, repeat, include, exception, label, preproc, storageclass, tag |
function |
function, method |
number |
number, float |
constant |
constant, boolean |
type |
type, constructor, namespace, module |
operator |
operator |
punctuation |
punctuation, delimiter |
variable |
variable, property, field, parameter, attribute |
Markup languages can also use the @markup.* / @text.* families (headings, bold, links) — those map onto the faces above too; the bundled latex and markdown queries are worked examples. To restyle any of these, set the face in your theme (see Themes & faces).
Building a major mode
A major mode is a keymap named after the mode, plus commands, activated on find-file-hook. The minimal recipe:
;; env-mode: a tiny major mode for `.env` files (highlighted as shell).
;; 1. Highlight the extension with a bundled grammar.
(set-extension "env" "bash")
;; 2. Commands.
(define (env-uncomment-line)
;; … edit the current line …
(message "uncommented"))
;; 3. A keymap named after the mode; bind the commands.
(define-key "env-mode" "ctrl-c ctrl-c" 'env-uncomment-line)
;; 4. Make commands discoverable from M-x.
(register-command 'env-uncomment-line)
;; 5. Activate the mode when a matching file opens.
(define (env--maybe-activate)
(when (ends-with? (current-buffer) ".env")
(set-major-mode "env-mode")))
(add-hook "find-file-hook" 'env--maybe-activate)
set-major-mode installs the keymap whose name you pass, so the mode name and the keymap name must match. It is also highlighting-aware: if the mode name matches a bundled grammar, switching to it attaches that grammar (and, via the fundamental enable-lsp hook, its language server if one is configured — see Language servers) and runs the mode's hooks. Bindings can use bind-key, which takes the command as a 'name symbol (or a bare function value) rather than a command-name string.
The bundled modes are the best templates: scheme/markdown.scm is a compact tree-sitter-backed mode (the grammar highlights; the mode adds heading navigation and folding), and scheme/org.scm is a full mode with its own parser and fontification.
Language modes and entry hooks
Every bundled grammar also gets a major mode named after it, entered automatically when a matching file opens — a .rs file lands in rust mode, a .scala file in scala mode. Each is an M-x command too (rust-mode, scala-mode, clojure-mode, …), so you can switch any buffer's mode by hand with no per-language wiring.
To configure what happens on entering a mode — the analog of Emacs's rust-mode-hook — register a function under the mode name. The hook runs on both entry paths (opening a file of that type, or M-x rust-mode):
(define (my-rust-setup)
(set-option "word-wrap:rust" #f) ; don't wrap long lines in Rust
(define-key "rust" "ctrl-c ctrl-t" 'lsp-hover))
(add-hook "rust" 'my-rust-setup)
add-hook accepts a command-name string, a 'symbol, or an anonymous (lambda () …). Two differences from Emacs worth knowing: the hook key is the mode name ("rust", not rust-mode-hook), with fundamental as the catch-all that runs for every buffer; and the registry is global (shared by all buffers in that mode) rather than buffer-local, so hooks should be idempotent — set buffer-local state, don't append to it.
Custom fontification from Scheme
When the standard tree-sitter highlighting isn't enough — you want depth-based coloring, semantic rules, or a language with no grammar — paint faces yourself.
Query the parse tree. (tree-sitter-query "<query>" "callback") runs a tree-sitter query over the active buffer's tree and calls your Scheme function with the captures as newline-separated, tab-delimited records (name⇥start⇥end⇥kind). Paint each with overlay-set-face:
(define (color-types records)
(for-each
(lambda (rec)
(let ((p (split-many rec "\t"))) ; (name start end kind)
(overlay-set-face (string->number (nth p 1))
(string->number (nth p 2))
"type")))
(non-empty-lines records)))
(tree-sitter-query "(type_identifier) @t" 'color-types)
scheme/rainbow.scm is the full worked example (depth-colored brackets via a query plus overlay-set-face), and re-runs on find-file-hook and after-change-functions so it stays live as you type.
Ask what you're inside. (tree-sitter-node-at byte "callback") reports the chain of nodes covering a byte (innermost first) — useful to, say, skip an action when point sits inside a string or comment node.
No grammar? Fontify with regexes and put-text-property / overlay-set-face, the way scheme/org.scm colors headings and TODO keywords without any tree-sitter grammar.