Parameterised VHDL templates for Neovim — entities, packages, clocked processes and CDC synchronisers, rendered from Lua rather than pasted from a static snippet file.
Four characters and two answers:
-- :HdlSnip process, with reset.style = "async", polarity = "low"
p_main : process (clk, rst_n) is
begin
if rst_n = '0' then
-- reset state
elsif rising_edge(clk) then
-- clocked logic
end if;
end process p_main;
Change reset.style to "sync" and the same template puts the reset branch
inside the clock test instead, and drops the reset from the sensitivity list.
That is the point: the shape of the code follows your house style, and getting
it wrong is a synthesis mismatch rather than a syntax error.
VS Code snippet files cannot express a stage count, a register file depth, a reset flavour, a vendor attribute or an aligned port column. Templates here are Lua that renders VHDL, so they can:
async_reg on AMD, preserve on Intel, nothing on neitherStatic templates still render as ordinary LSP snippets, so nothing is lost.
vim.snippet)vim.lsp.completion); everything
else works on 0.10vim.pack; the plugin itself still works on
0.10 with any other managervim.ui, so telescope, fzf-lua or
snacks are used if you have them and the built-in prompts if you do not.With vim.pack, built into Neovim 0.12 and needing nothing else:
vim.pack.add({ { src = "https://github.com/jgonmor16/hdlsnip.nvim" } })
-- Optional; the commands work on the defaults without it.
require("hdlsnip").setup({
vhdl_std = "2008",
reset = { style = "async", polarity = "low" },
})
With lazy.nvim:
{
"jgonmor16/hdlsnip.nvim",
ft = { "vhdl" },
---@type hdlsnip.Config
opts = {
vhdl_std = "2008",
reset = { style = "async", polarity = "low" },
},
}
setup() is optional. The commands work on the defaults without it.
| Command | Does |
|---|---|
:HdlSnip [name] |
Prompt for parameters and insert. Picker when no name is given |
:HdlSnipExpand [name] |
Expand as a snippet, with tabstops |
:HdlSnipInstantiate[!] [name] |
Instantiate an entity from the project; ! adds its signals |
:HdlSnipTestbench [name] |
Write a testbench around an entity from the project |
:HdlSnipReload |
Rescan the runtimepath for templates |
:checkhealth hdlsnip |
Templates found, configuration in effect, anything skipped |
:HdlSnipEdit |
Change the parameters of the template under the cursor |
:HdlSnipLspAttach |
Attach the completion server to this buffer |
Nothing is mapped by default. To expand from insert mode and move between tabstops:
require("hdlsnip").setup({
keys = {
expand = "<C-k>", -- expand a trigger, or jump forward in a snippet
jump_prev = "<C-j>",
},
})
Each falls through when it has nothing to do, so <C-k> still inserts a
digraph when the word before the cursor is not a trigger and no snippet is
active. jump_next is only needed if you want a separate key for jumping
forward.
field_next and field_prev move between fields in the parameter dialog. They
default to <Tab> and <C-k>, and <S-Tab> and <C-j> — set rather than
unset, unlike the others, because they are buffer-local to a window the plugin
opened and take nothing from you. Either takes one mapping or a list.
With a plugin manager that takes an opts table, keys goes in there alongside the rest of the configuration.
If a completion plugin is bound to the same key, whichever mapping is defined last wins. With blink.cmp, give hdlsnip first refusal instead:
keymap = {
["<C-k>"] = {
function()
return require("hdlsnip").expand_at_cursor()
end,
"fallback",
},
}
Templates also appear in the completion menu. hdlsnip runs an in-process LSP server — a Lua table, not a process — so any completion frontend picks them up. Every template is offered. A static one expands as a snippet; a dynamic one opens a dialog for its parameters, and says so in the menu.
With the built-in menu:
vim.lsp.completion.enable(true, client_id, bufnr, { autotrigger = true })
nvim-cmp and blink.cmp need nothing: they consume LSP sources already. Turn it
off with lsp = false.
| Trigger | Name | Kind | Emits |
|---|---|---|---|
ent |
entity |
skeleton | Entity with a matching architecture |
pkg |
package |
skeleton | Package declaration |
prc |
process |
rtl | Clocked process with the configured reset |
cnt |
counter |
rtl | Counter that wraps or saturates |
edge |
edge_detect |
rtl | One-cycle pulse on a rising, falling or either edge |
fsm |
fsm |
rtl | Two-process finite state machine |
mux |
mux |
rtl | Multiplexer, combinational or registered |
pipe |
pipeline |
rtl | N-stage delay line |
cdc |
bit_sync |
cdc | Single-bit CDC synchroniser |
hs |
cdc_handshake |
cdc | Multi-bit CDC by request and acknowledge |
fifo |
fifo_sync |
mem | Synchronous FIFO with count-based flags |
afifo |
fifo_async |
mem | Asynchronous FIFO with gray-coded pointers |
ram |
ram_dp |
mem | Simple dual-port RAM, read-first |
axil |
axi4lite_slave |
bus | AXI4-Lite slave with a register file |
axis |
axis_skid |
bus | AXI-Stream register slice with backpressure |
apb |
apb_slave |
bus | APB slave with a register file |
wb |
wishbone_slave |
bus | Wishbone B4 classic slave with a register file |
avmm |
avalon_mm_slave |
bus | Avalon-MM slave with a register file |
tb |
testbench |
tb | Self-checking testbench skeleton |
vtb |
tb_vunit |
tb | VUnit testbench with a test suite |
otb |
tb_osvvm |
tb | OSVVM testbench with alerts and logs |
Templates are either static, rendering as a snippet with tabstops, or
dynamic, where the output depends on configuration or on a parameter. A
tabstop cannot decide whether a reset port exists, so dynamic templates prompt
and insert fully formed instead — one dialog with every field, not a question
at a time. :HdlSnipExpand falls back to that automatically.
A dynamic template prompts once and inserts finished code, so changing a
parameter used to mean deleting the block and starting again. It doesn't now:
put the cursor in a block you inserted and run :HdlSnipEdit. A small form
lists the parameters, and the block re-renders as you type.
The parameters are edited outside the generated code rather than inside it, which is what keeps this small: there is no cursor to preserve in a block being replaced, and no per-parameter region to track. No snippet engine is involved — the text is generated, so re-rendering from the parameters is enough.
The anchor is dropped when you write the file, and as soon as the block stops matching what was rendered. Editing a line by hand ends the tracking rather than having it overwritten later.
The interface of a design already exists in a file. :HdlSnipInstantiate finds
the entities in your project, offers them, and writes the instantiation for the
one you pick — named association throughout, since positional compiles happily
with two same-typed ports swapped and you find out in simulation.
Typing in the picker narrows the list; <C-d> and <C-u> page through it,
<C-k> and <C-j> move one at a time. With telescope, fzf-lua or snacks
installed you get yours instead — picker = "hdlsnip" forces this one.
:HdlSnipInstantiate! adds the port signals too. They go above the
architecture's begin while the instance goes below it, and they are named
exactly as the instantiation maps them, so the two halves cannot disagree.
Generic values are substituted into the subtypes, since the instantiating scope
has no G_WIDTH of its own.
A generic with no default is mapped to its own name. That will not analyse, deliberately: it is a value you have to supply, and a wrong guess would be worse than an obvious gap.
:HdlSnipTestbench goes further and writes the whole testbench: a signal for
every port, the clock generated, the reset released, the DUT wired up and a
stimulus process that stops the run. Clocks and resets are found by shape
rather than by name, so a crossing with src_clk and dst_clk gets both, and
a reset ending in n is released to '1'.
Every input is driven from time zero. Without that the design starts with 'U'
on its inputs and nothing downstream means anything.
Defaults, in full:
{
vhdl_std = "2008", -- "93" | "2002" | "2008" | "2019"
keyword_case = "lower", -- "lower" | "upper"
indent = " ",
clock = { name = "clk", edge = "rising" },
reset = {
style = "async", -- "async" | "sync" | "none"
polarity = "low", -- "low" | "high"
name = nil, -- derived: rst_n when low, rst when high
},
naming = {
in_suffix = "_i",
out_suffix = "_o",
reg_suffix = "_r",
sig_prefix = "",
proc_prefix = "p_",
const_prefix = "C_",
generic_prefix = "G_",
},
vendor = "generic", -- "generic" | "amd" | "intel" | "lattice" | "microchip"
align_ports = true,
lsp = true, -- offer templates in the completion menu
picker = "auto", -- "auto" | "hdlsnip" | "ui"
picker_height = 10,
keys = {
expand = false, -- trigger word before the cursor
jump_next = false, -- next tabstop
jump_prev = false, -- previous tabstop
field_next = { "<Tab>", "<C-k>" }, -- in the parameter dialog only
field_prev = { "<S-Tab>", "<C-j>" },
},
}
Invalid options are reported with the path that is wrong, and the previous configuration is kept rather than half-applied.
setup() is partial: options it does not mention keep their current value, so
calling it twice accumulates rather than resetting.
A .hdlsnip.lua at the project root overrides the global configuration for
buffers under it:
-- .hdlsnip.lua
return {
keyword_case = "upper",
clock = { name = "axi_aclk" },
reset = { style = "sync" },
}
It is arbitrary Lua from a checked-out repository, so it goes through
vim.secure.read: Neovim asks once per file, the same as exrc.
Templates are discovered from the runtimepath at
lua/hdlsnip/templates/<lang>/<kind>/<name>.lua. Neovim searches your config
before plugins, so a file at that path in your own config shadows the one
shipped here. Overriding a template needs no configuration.
-- ~/.config/nvim/lua/hdlsnip/templates/vhdl/rtl/counter.lua
return {
name = "counter", -- must match the file name
trig = "cnt", -- unique, and never a reserved word
kind = "rtl", -- must match the directory
scope = "declarative",
desc = "Counter register",
params = {
{ name = "sig", type = "identifier", default = "count", desc = "Signal" },
{ name = "width", type = "integer", default = 8, min = 1, desc = "Width" },
},
body = "signal {{sig}} : unsigned({{width}} - 1 downto 0);{{cursor}}",
}
A render = function(params, cfg) returning a string replaces body for
anything needing logic; set dynamic = true alongside it.
:checkhealth hdlsnip lists anything that failed to load and why.
Every template is rendered across seven configuration variants and one case
per parameter alternative — 651 files, committed under tests/golden/ — and
every one that does not need an external library is analysed with GHDL in CI.
A change to generated VHDL shows up as a reviewable diff rather than hiding
inside a Lua change.
Where behaviour rather than syntax is the point, the output has also been simulated: both FIFOs, the AXI4-Lite, APB, Wishbone and Avalon-MM slaves, the CDC handshake, the AXI-Stream slice and the testbench skeleton each run against a testbench and pass. The asynchronous FIFO crosses 200 words in order under four clock ratios.
The generated VHDL is also style checked with VSG against a reviewed rule set:
861 rules enabled, 28 disabled as house style with the reason recorded in
vsg_config.yaml.
make test # spec suite
make golden # regenerate fixtures
make ghdl # analyse every fixture
make vsg
The library, the engine and the entity tooling are complete. What comes next depends on what people ask for — open an issue.
Deliberately out of scope: SystemVerilog (the engine allows it, nobody has asked), and synthesis or simulation from inside the editor, which is what your build system is for.
Branches follow feat/, fix/, doc/, ci/, test/, refactor/,
chore/ and hotfix/; pull requests target devel, which is merged into
main and tagged for each release.
Commit messages follow Conventional Commits with a 50-character subject and a
body wrapped at 70 columns.
MIT — see LICENSE. Code generated by this plugin is yours to use without restriction or attribution; see OUTPUT-LICENSE.md.