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:

anchoring on each clause what not to do
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;
COMPOUNDS one level per clause

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.

test/indent/run.sh
$ 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
EXIT 0 no diff against any reference

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.toml was a listed marker, but the server only ever reads it from $HOME, so root_dir resolved 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.filelist as 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.