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

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.

← All articles