Project 03 · VHDL
A VHDL indent query for Neovim
The VHDL grammar had highlights, folds, injections and textobjects, but no indent query — so Neovim fell back to a Vimscript file that declares itself VHDL-93 and has not substantively changed since 2017. This is the query that replaced it, and the harness that proves it.
Problem
The one query nobody had written
A tree-sitter grammar ships queries telling the editor what to
do with the parse tree. VHDL's had four of the five. The
missing one was indentation, so every VHDL buffer in Neovim
quietly fell back to the bundled
runtime/indent/vhdl.vim — a script written
against the 1993 revision of the language and untouched in any
substantive way since 2017.
I write VHDL for a living. This is the kind of defect that survives for years precisely because everyone works around it rather than reporting it.
Approach
Anchor on the wrapper, not the clause
The obvious way to indent an if/elsif/else chain
is to capture each clause. It is also wrong. Every clause is an
ancestor of the lines below it and each starts on a different
row, so nvim-treesitter's
is_processed_by_row does not deduplicate them and
the indent compounds one level per clause:
if reset = '1' then
count <= 0;
elsif rising_edge(clk) then
count <= count + 1; -- one level too deep
elsif enable = '1' then
count <= count; -- and again
end if;
So the query anchors on the outermost wrapper of each construct
instead. if_statement_block appears exactly once
per chain however many elsif clauses sit inside
it, so the depth cannot compound. It also makes the query
robust to the grammar changing underneath it: if the maintainer
flattens if/elsif/else later, the results are
identical under either tree shape and nothing here needs
touching.
The head and body nodes are deliberately left uncaptured. One
wrapper covers both the declarative and statement regions, and
"begin" branches back to the construct's own
level. Two of them — process_head and
generate_body — have an optional-keyword
variant whose node starts on a different line in each form, so
neither can be anchored directly at all.
Tests
Correct output is an empty diff
Thirteen reference files, one per construct family, each already indented the way the query should indent it. Re-indenting a correct file must change nothing, so the check is a diff against the input itself. There are no expected-output files, which means there is nothing to drift out of step with the query.
$ NVIM_TREESITTER=/path/to/nvim-treesitter ./test/indent/run.sh
# builds the parser from the working tree, then stages
# queries/Neovim as queries/vhdl on the runtimepath
13 passed
The runner also refuses to report a result it cannot trust. It
asserts the tree-sitter indentexpr is actually
active before measuring anything, and exits 3 if
it is not. Without that guard a misconfigured run falls back to
the old Vimscript indent and emits a full set of
plausible-looking diffs — the worst kind of failure,
because it looks exactly like real output.
None of this can run in the grammar's own CI. The tree-sitter indent implementation lives in the nvim-treesitter plugin rather than the tree-sitter CLI, so the plugin has to be on disk for any of it to execute.
Scope
Listed, not quietly omitted
The pull request names what the query does not handle:
multi-line aggregates and the continuation lines of concurrent
assignments; the hanging-paren style, since this imposes the
block style on interface lists; and
configuration and context
declarations, protected types, and
with ... select.
Writing that list down is the point. A reviewer can weigh what arrived against what did not, rather than finding the gaps later and wondering whether they were decisions or oversights.
Also merged
Two root-resolution fixes
Both in nvim-lspconfig, where most Neovim users get their language-server configuration.
- vhdl_ls
- Root marker removed
-
#4506
—
.vhdl_ls.tomlwas a listed marker, but the server only ever reads it from$HOME, soroot_dirresolved to the home directory for every VHDL file beneath it. Proven by reading the server's own config loader. - verible
- Root marker added
-
#4498
— upstream
documents
verible.filelistas the project root, but the config carried only.git, so a project whose filelist sat apart from its repository root resolved to an ancestor.
The second was scoped deliberately. Correcting the root did not restore cross-file go-to-definition, which is what I had been chasing when I found it — that turned out to be a separate issue in the language server itself. Bundling an unproven fix with a proven one would only have made the proven one harder to review.