jgonmor16/hdlsnip.nvim

github github
programming-languages-support
stars 2
issues 0
subscribers 0
forks 0
CREATED

UPDATED


hdlsnip.nvim

ci release neovim ghdl license

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.

Why not a snippet pack

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:

  • omit the reset port entirely when the design has no reset
  • align port columns against whatever your clock is called
  • emit async_reg on AMD, preserve on Intel, nothing on neither
  • keep the last port free of its trailing semicolon, whatever the port count

Static templates still render as ordinary LSP snippets, so nothing is lost.

Requirements

  • Neovim >= 0.10 (vim.snippet)
  • Neovim >= 0.11 for the completion menu (vim.lsp.completion); everything else works on 0.10
  • Neovim >= 0.12 if installing with vim.pack; the plugin itself still works on 0.10 with any other manager
  • No plugins. Pickers and prompts go through vim.ui, so telescope, fzf-lua or snacks are used if you have them and the built-in prompts if you do not.

Installation

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.

Usage

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.

Templates

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.

Changing your mind

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.

Instantiating what you already have

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.

Configuration

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.

Per project

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.

Adding your own templates

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.

Correctness

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

Roadmap

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.

Contributing

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.

Licence

MIT — see LICENSE. Code generated by this plugin is yours to use without restriction or attribution; see OUTPUT-LICENSE.md.