vim / tree-sitter grammar
hml is my own template language, so no editor knows it. Neovim highlights with tree-sitter, so I wrote a grammar.
It lives in the hml repo next to the Go engine: grammar.js, an
external scanner in src/scanner.c, and queries under queries/.
Scanner
hml maps indentation to HTML nesting. A context-free grammar cannot
express that, so an external scanner emits zero-width INDENT and
DEDENT tokens at the end of a line:
externals: ($) => [
$._indent,
$._dedent,
$.filter_body,
$.comment_body,
$._error_sentinel,
],
_block is then ordinary:
_block: ($) => seq($._indent, repeat1($._item), $._dedent),
Zero width lets several DEDENTs land at one position, and lets a
rescan from that byte measure the next line again.
The scanner keeps a stack of indent widths, at most 64 deep, and serializes the struct whole.
Tree-sitter sets _error_sentinel during error recovery, and the
scanner then returns no token. A file mid-edit is the common case in an
editor.
The scanner reads a filter or comment body as one token, since the engine hands those lines to JavaScript or CSS.
Go parser
The precedence table copies the order in expr.go: parseOr,
parseAnd, parseNot, parseCmp. So ! binds looser than a
comparison.
Shorthands after the first are immediate tokens, so whitespace decides:
tag_name: (_) => token(/%[a-zA-Z0-9_][a-zA-Z0-9_-]*/),
_class_immediate: (_) => token.immediate(CLASS),
%p and .note on separate lines are two elements. %p.note is one.
An unrecognized line is text: a token with negative precedence, so every other line form wins.
Queries
In queries/highlights.scm, a later pattern wins, so general captures
come first.
A call gets @function.call by shape:
(call
function: (identifier) @function.call)
The app registers transforms and helpers at runtime, so the query keeps no list of names.
queries/injections.scm hands a filter body to the language that owns
it:
((filter
(filter_name) @_name
(filter_body) @injection.content)
(#eq? @_name ":javascript")
(#set! injection.language "javascript"))
Generated parser
grammar.js and scanner.c are source. The C parser and
src/grammar.json the CLI generates stay out of the repo.
nvim-treesitter generates the parser at install. hml is not in its
registry, so I register it in ~/.config/nvim/init.lua:
vim.api.nvim_create_autocmd("User", {
pattern = "TSUpdate",
callback = function()
require("nvim-treesitter.parsers").hml = {
install_info = {
url = "https://github.com/croaky/hml",
branch = "main",
queries = "queries",
generate = true,
generate_from_json = false,
},
tier = 0,
}
end,
})
if not vim.tbl_contains(ts_installed, "hml") then
require("nvim-treesitter.install").install({ "hml" })
end
- The registration goes in the
TSUpdateautocmd, because install and update reload that table. generate = truebuilds against the ABI of this Neovim.laptop.shinstallstree-sitter-clifor that step. See cmd / laptop.generate_from_json = false, sogrammar.jsis the only source.- The URL is the GitHub mirror, so a machine without a local clone
still highlights
.hml.
I pin no revision. nvim-treesitter installs the grammar once. To pick up a grammar change:
:lua require("nvim-treesitter.install").install({ "hml" }, { force = true })
Tests
tree-sitter's corpus format is a source block and the tree it should
parse to, under test/corpus/:
tree-sitter test
CI runs that after generating the parser:
grammar: tree-sitter generate --js-runtime native && tree-sitter test
A grammar.js the CLI cannot build is one nobody can install, so
generation is part of the check.
--js-runtime native reads grammar.js with the QuickJS the CLI
embeds, so neither a laptop nor a CI worker needs node. The flag lives
in the Checkfile, so the command reads the same in both places. See
cmd / laptop.
A Go test parses every corpus example with the engine, so the examples stay valid hml.