- Neovim 0.12+ (recommended — full feature support including on-type formatting)
- Neovim 0.11 (supported — native LSP works, no on-type formatting)
hledger-lspbinary in PATH (see main README)
Neovim 0.11 introduced native LSP configuration via vim.lsp.config() and vim.lsp.enable(). No plugins required.
Create ~/.config/nvim/lsp/hledger_lsp.lua:
return {
cmd = { "hledger-lsp" },
filetypes = { "hledger", "journal" },
root_markers = { ".git", "*.journal" },
single_file_support = true,
}Then enable it in your init.lua:
vim.lsp.enable("hledger_lsp")vim.lsp.config("hledger_lsp", {
cmd = { "hledger-lsp" },
filetypes = { "hledger", "journal" },
root_markers = { ".git", "*.journal" },
single_file_support = true,
})
vim.lsp.enable("hledger_lsp")For older Neovim versions, use nvim-lspconfig:
Using lazy.nvim
{
"neovim/nvim-lspconfig",
config = function()
local lspconfig = require("lspconfig")
local configs = require("lspconfig.configs")
if not configs.hledger_lsp then
configs.hledger_lsp = {
default_config = {
cmd = { "hledger-lsp" },
filetypes = { "hledger", "journal" },
root_dir = lspconfig.util.root_pattern(".git", "*.journal"),
single_file_support = true,
},
}
end
lspconfig.hledger_lsp.setup({})
end,
}Using init.lua directly
local lspconfig = require("lspconfig")
local configs = require("lspconfig.configs")
if not configs.hledger_lsp then
configs.hledger_lsp = {
default_config = {
cmd = { "hledger-lsp" },
filetypes = { "hledger", "journal" },
root_dir = lspconfig.util.root_pattern(".git", "*.journal"),
single_file_support = true,
},
}
end
lspconfig.hledger_lsp.setup({})Add to your config:
vim.filetype.add({
extension = {
journal = "hledger",
hledger = "hledger",
},
})hledger-lsp uses custom semantic token types. Add highlight links to your config:
vim.api.nvim_set_hl(0, "@lsp.type.account.hledger", { link = "Identifier" })
vim.api.nvim_set_hl(0, "@lsp.type.commodity.hledger", { link = "Type" })
vim.api.nvim_set_hl(0, "@lsp.type.payee.hledger", { link = "Function" })
vim.api.nvim_set_hl(0, "@lsp.type.date.hledger", { link = "Number" })
vim.api.nvim_set_hl(0, "@lsp.type.amount.hledger", { link = "Number" })
vim.api.nvim_set_hl(0, "@lsp.type.directive.hledger", { link = "PreProc" })
vim.api.nvim_set_hl(0, "@lsp.type.code.hledger", { link = "Special" })
vim.api.nvim_set_hl(0, "@lsp.type.status.hledger", { link = "Operator" })Or with custom colors:
vim.api.nvim_set_hl(0, "@lsp.type.account.hledger", { fg = "#4EC9B0" })
vim.api.nvim_set_hl(0, "@lsp.type.commodity.hledger", { fg = "#569CD6" })
vim.api.nvim_set_hl(0, "@lsp.type.payee.hledger", { fg = "#DCDCAA" })
vim.api.nvim_set_hl(0, "@lsp.type.date.hledger", { fg = "#B5CEA8" })
vim.api.nvim_set_hl(0, "@lsp.type.amount.hledger", { fg = "#B5CEA8" })
vim.api.nvim_set_hl(0, "@lsp.type.directive.hledger", { fg = "#C586C0" })
vim.api.nvim_set_hl(0, "@lsp.type.code.hledger", { fg = "#9CDCFE" })
vim.api.nvim_set_hl(0, "@lsp.type.status.hledger", { fg = "#D4D4D4" })Neovim 0.11+ includes default LSP keymaps (grn rename, gra code action, grr references). For additional bindings:
vim.api.nvim_create_autocmd("LspAttach", {
callback = function(args)
local opts = { buffer = args.buf }
vim.keymap.set("n", "K", vim.lsp.buf.hover, opts)
vim.keymap.set("n", "gd", vim.lsp.buf.definition, opts)
vim.keymap.set("n", "<leader>f", vim.lsp.buf.format, opts)
vim.keymap.set("n", "<leader>ca", vim.lsp.buf.code_action, opts)
end,
})hledger-lsp registers Enter and Tab as trigger characters for textDocument/onTypeFormatting:
- Enter: auto-indents new posting lines after transaction headers or existing postings
- Tab: aligns cursor to the amount column after an account name
A complete setup needs three pieces — and the Tab piece requires a custom keymap because of an LSP protocol limitation explained at the end of this section.
vim.lsp.on_type_formatting handles <CR> (Enter) typed inside insert mode, but it does not fire on the o normal-mode command (which inserts a newline at the C level, never reaching vim.on_key). Without a Vim-side indent rule, o after a transaction header drops you on an unindented line. Set indentexpr so new posting lines start indented:
_G.hledger_indentexpr = function()
local lnum = vim.v.lnum
if lnum <= 1 then return 0 end
local prev = vim.fn.getline(lnum - 1)
-- Transaction header (date), periodic (~), auto-rule (=)
if prev:match("^[%d~=]") then return vim.bo.shiftwidth end
-- Existing posting (whitespace + content)
if prev:match("^%s+%S") then return vim.bo.shiftwidth end
return 0
end
vim.api.nvim_create_autocmd("FileType", {
pattern = "hledger",
callback = function(args)
vim.bo[args.buf].autoindent = true
vim.bo[args.buf].indentexpr = "v:lua.hledger_indentexpr()"
end,
})Neovim 0.12 added native textDocument/onTypeFormatting support (PR #34637). Enable it explicitly per-client — it is not active by default:
vim.api.nvim_create_autocmd("LspAttach", {
callback = function(args)
local client = vim.lsp.get_client_by_id(args.data.client_id)
if client and client.name == "hledger_lsp" then
vim.lsp.on_type_formatting.enable(true, { client_id = args.data.client_id })
end
end,
})See :h lsp-on_type_formatting for the API. With this, pressing <CR> at the end of a posting line inserts a new properly-indented line.
Tab alignment cannot use the native vim.lsp.on_type_formatting pipeline. The LSP textDocument/onTypeFormatting response is a TextEdit[] with no field for cursor position (LSP spec, microsoft/language-server-protocol#724). The server can insert the alignment spaces, but the client has no standardized way to learn that it should jump the cursor to the end of the inserted text. The native module applies the edit and leaves the cursor where it was — visually it looks like Tab "did nothing".
Use an insert-mode keymap that calls the LSP request synchronously and explicitly moves the cursor afterwards:
local function hledger_tab_fallback()
local key = vim.api.nvim_replace_termcodes("<Tab>", true, false, true)
vim.api.nvim_feedkeys(key, "n", false)
end
local function hledger_tab()
local bufnr = vim.api.nvim_get_current_buf()
local clients = vim.lsp.get_clients({ bufnr = bufnr, name = "hledger_lsp" })
if #clients == 0 then return hledger_tab_fallback() end
local client = clients[1]
local pos = vim.api.nvim_win_get_cursor(0)
local params = {
textDocument = vim.lsp.util.make_text_document_params(),
position = { line = pos[1] - 1, character = pos[2] },
ch = "\t",
options = { tabSize = vim.bo.tabstop, insertSpaces = vim.bo.expandtab },
}
local resp = client:request_sync("textDocument/onTypeFormatting", params, 500, bufnr)
if not resp or not resp.result or #resp.result == 0 then
return hledger_tab_fallback()
end
vim.lsp.util.apply_text_edits(resp.result, bufnr, client.offset_encoding)
local edit = resp.result[#resp.result]
local new_col = edit.range.start.character + #edit.newText
vim.api.nvim_win_set_cursor(0, { pos[1], new_col })
end
vim.api.nvim_create_autocmd("FileType", {
pattern = "hledger",
callback = function(args)
vim.keymap.set("i", "<Tab>", hledger_tab, { buffer = args.buf })
end,
})The keymap captures the cursor position before Tab is processed (matching the position the server expects), sends a synchronous onTypeFormatting request, applies the returned edit, and moves the cursor to start + len(newText). If the LSP is unavailable or returns no edit, it falls back to a literal <Tab> (which expandtab will convert to spaces).
Note (completion plugins): This keymap intercepts every
<Tab>in insert mode forhledgerfiletype. If you use a completion plugin (nvim-cmp, blink.cmp, etc.) that maps<Tab>to navigate the completion menu, integrate accordingly — for example, only callhledger_tab()when the completion popup is not visible, otherwise delegate to the completion plugin.
textDocument/onTypeFormatting requires Neovim 0.12+. On 0.11 the LSP server still works (completions, diagnostics, hover, formatting on demand), but Enter auto-indent and Tab alignment are not available.
- Open a
.journalfile - Run
:checkhealth vim.lsp— should show hledger_lsp attached - Type an account name and trigger completion (
<C-x><C-o>or your completion plugin)
LSP not attaching:
- Run
:checkhealth vim.lspand check for errors - Verify filetype with
:set ft? - Ensure
hledger-lspis in PATH
No completions:
- Check if completion plugin is configured (nvim-cmp, etc.)
- Try manual completion with
<C-x><C-o>