(macros)
Customization

Languages & modes

Support for a language in Macros has two independent layers:

  1. 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.
  2. 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-language wires 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

  1. Install the tree-sitter CLI — the tool that turns grammar.js into parser.c:

    npm install -g tree-sitter-cli      # or: cargo install tree-sitter-cli
    
  2. Write grammar.js. The name becomes the parser symbol; the rules describe the syntax. A minimal grammar for a key = value config 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.

  3. 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 it
    
  4. Write queries/highlights.scm (next section).

  5. Commit src/parser.c, src/tree_sitter/, any src/scanner.*, and queries/highlights.scm. Tag a release or note the commit SHA — that's the rev you pin in install-language.

  6. Point install-language at 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) @cap tags a named node; "literal" @cap tags an anonymous token — a keyword or punctuation written literally in the grammar.
  • Only the dotted prefix matters: @string.specialstring, @keyword.controlkeyword. 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.