miser bridges mise and Neovim. Declare your tools in mise.toml and miser handles the rest: LSP servers start, formatters run on save, and mise tasks are a keypress away.
No more mason, no more global installs drifting out of sync, no more hardcoding formatters in your Neovim config. The project decides which tools it needs. Miser makes Neovim respect that.
mise ls --currentvim.lsp.enable — so your own lsp/ and after/lsp/ files still apply on topBufWritePost — the project's mise.toml determines which formatter, not your Neovim configmise install runs in the background to ensure tools are up to dateWith vim.pack.add (Neovim 0.11+):
vim.pack.add({
{ src = "https://github.com/carldaws/miser.nvim" },
})
With lazy.nvim:
{
"carldaws/miser.nvim",
config = function()
require("miser").setup()
end,
}
Miser bundles nvim-lspconfig as a git submodule. If your plugin manager doesn't fetch submodules automatically, miser will init it on first run.
require("miser").setup()
That's it. If your project has a mise.toml with tools declared, miser will configure LSPs and formatters automatically.
require("miser").setup({
auto_install = true, -- run `mise install` on startup
auto_format = true, -- format on save (registry first, LSP fallback)
auto_lsp = true, -- enable LSPs from mise tools
registry = {}, -- override or extend the built-in registry
task_runner = nil, -- function(cmd_string) -> ... (default: terminal split)
task_keymaps = { enabled = true, prefix = "<leader>m" }, -- bind <prefix><alias> for tasks with an `alias`
})
Both auto_format paths are gated by auto_format = true:
biome, prettier, stylua) runs on BufWritePost. If two registry formatters claim the same filetype, miser refuses to pick one and emits a warning — fix mise.toml.textDocument/formatting will format on BufWritePre.The registry wins where both exist — your mise.toml is the source of truth.
Set alias = "..." on any task in mise.toml and miser will bind <prefix><alias> in normal mode. Tasks without an alias get no keymap.
[tasks.dev]
run = "npm run dev"
alias = "d" # <leader>md runs this
[tasks.test]
run = "npm test"
alias = "t" # <leader>mt runs this
Given a project mise.toml:
[tools]
"npm:typescript-language-server" = "latest"
biome = "1.9.4"
[tasks.dev]
description = "Start the dev server"
run = "npm run dev"
[tasks.test]
description = "Run the test suite"
run = "npm test"
Miser will:
ts_ls LSP for JavaScript and TypeScript filesbiome format --write on savemise install in the background to ensure both tools are availabledev and test available via miser.tasks.list() and :Miser runNo LSP config blocks. No formatter autocmds. Just declare your tools.
| Command | Description |
|---|---|
:Miser status |
Open a status panel showing tools, LSPs, and formatters |
:Miser install |
Run mise install and re-activate LSPs and formatters |
:Miser run <task> |
Run a mise task via your configured task_runner |
:Miser format |
Run formatter on-demand in current buffer |
The status panel supports:
q / <Esc> to closei to run mise install and refreshRun :checkhealth miser to verify your setup — checks for mise, the lspconfig submodule, project tools, and running LSP clients.
For tasks that have an alias in mise.toml, miser binds <prefix><alias> automatically (see Task keymaps above). For everything else, the full task list lives at require("miser.tasks").list() (a synchronous read of cached state) — wire it up to any picker:
-- With mini.pick
vim.keymap.set("n", "<leader>mp", function()
local tasks = require("miser.tasks").list()
require("mini.pick").start({
source = {
name = "Mise Tasks",
items = vim.tbl_map(function(t) return t.name end, tasks),
choose = function(chosen)
if chosen then require("miser.tasks").run(chosen) end
end,
},
})
end)
-- With vim.ui.select
vim.keymap.set("n", "<leader>mp", function()
vim.ui.select(require("miser.tasks").list(), {
prompt = "Mise Tasks:",
format_item = function(t) return t.name end,
}, function(choice)
if choice then require("miser.tasks").run(choice.name) end
end)
end)
list() reads from miser's cached state; run :Miser install (or any miser command that triggers re-activation) after editing mise.toml to refresh.
Miser tasks pair well with surface.nvim for persistent, toggleable terminal windows. Instead of throwaway splits, tasks open in surface windows that you can dismiss and resurface later — great for long-running tasks like dev servers:
require("miser").setup({
task_runner = function(cmd)
require("surface").open(cmd, "bottom")
end,
})
Now both :Miser run dev and your picker keymap route through surface:
vim.keymap.set("n", "<leader>mp", function()
vim.ui.select(require("miser.tasks").list(), {
prompt = "Mise Tasks:",
format_item = function(t) return t.name end,
}, function(choice)
if choice then require("miser.tasks").run(choice.name) end
end)
end)
Pick "dev", the server starts in a surface window. Dismiss it, keep coding. Resurface it later with the same keymap — your server output is still there.
The registry maps mise tool names to LSP server names and formatter commands. Each tool has its own file under lua/miser/registry/, keyed by the exact tool name you'd write in mise.toml.
LSP entries are just name mappings — the config comes from the bundled nvim-lspconfig on the runtimepath:
["npm:typescript-language-server"] = { lsp = "ts_ls" },
["go:golang.org/x/tools/gopls"] = { lsp = "gopls" },
Because miser only adds nvim-lspconfig to the runtimepath and calls vim.lsp.enable, your own lsp/<server>.lua and after/lsp/<server>.lua files are merged on top of the bundled default the usual way — miser won't clobber them.
To override or extend a server's config from the registry, add a config table — it's pushed via vim.lsp.config, so it takes precedence over the bundled default (and over after/lsp/):
["gem:ruby-lsp"] = {
lsp = "ruby_lsp",
config = {
init_options = { formatter = "standard" },
},
},
The built-in lua-language-server entry already ships an on_init that teaches the server about the vim global and Neovim runtime — so editing your Neovim config "just works" out of the box. To opt out for a non-Neovim Lua project, drop a .luarc.json (or .luarc.jsonc) at the project root and miser will leave that workspace alone.
When a tool maps to multiple servers, key config by server name:
["npm:vscode-langservers-extracted"] = {
lsp = { "html", "cssls", "jsonls" },
config = {
cssls = { settings = { css = { validate = false } } },
},
},
Formatter entries define the command and which filetypes it handles:
["biome"] = {
formatter = {
filetypes = { "javascript", "typescript", "json", "css" },
cmd = { "biome", "format", "--write" },
},
},
Tools can have both:
["gem:rubocop"] = {
lsp = "rubocop",
formatter = {
filetypes = { "ruby" },
cmd = { "rubocop", "-A", "--stderr" },
},
},
| mise.toml tool | LSP | Formatter |
|---|---|---|
| lua-language-server | lua_ls | |
| gem:ruby-lsp | ruby_lsp | |
| gem:rubocop | rubocop | rubocop -A |
| npm:typescript-language-server | ts_ls | |
| npm:@astrojs/language-server | astro | |
| go:golang.org/x/tools/gopls | gopls | |
| zls | zls | |
| ruff | ruff | ruff format |
| biome | biome format --write | |
| prettier | prettier --write | |
| stylua | stylua | |
| gofumpt | gofumpt -w | |
| black | black | |
| shfmt | shfmt -w |
require("miser").setup({
registry = {
["npm:my-lsp"] = { lsp = "my_ls" },
["my-formatter"] = {
formatter = {
filetypes = { "custom" },
cmd = { "my-fmt", "--write" },
},
},
},
})
If you already have LSPs and formatters configured via mason.nvim and mason-lspconfig, you don't need to throw any of that away. Miser can replace Mason purely as a tool manager — just disable the automation:
require("miser").setup({
auto_lsp = false,
auto_format = false,
})
This puts mise's bin-paths on Neovim's PATH and runs mise install, but doesn't touch your LSP or formatter config. Your existing lspconfig setup, conform.nvim, or whatever else you use keeps working exactly as before — the only difference is that mise manages the binaries instead of Mason.
From there you can remove mason.nvim and mason-lspconfig.nvim from your plugin list and declare the same tools in mise.toml instead. The servers and formatters are the same binaries, just installed and versioned by mise.
You can also mix and match: leave auto_lsp = true for tools where the defaults work and handle the rest yourself. Or override specific registry entries to change formatter commands without replacing the whole setup.
Mise configs are layered — global, project, and local — and mise ls --current resolves them all for the current directory. Miser reads that resolved list, so you can structure your mise configs to fit different situations:
Global ~/.config/mise/config.toml — baseline tools you want everywhere. Language servers, formatters, and runtimes that make up your default editing environment:
[tools]
lua-language-server = "latest"
stylua = "latest"
node = "22"
Project mise.toml — committed to the repo. The team's agreed-upon tools and tasks. Everyone gets the same versions:
[tools]
"npm:typescript-language-server" = "latest"
biome = "1.9.4"
[tasks.dev]
run = "npm run dev"
Project mise.local.toml — gitignored, personal overrides. I use this in two situations:
Adding tools to projects that don't use mise. I add mise.local.toml to my global gitignore (~/.config/git/ignore) so I can drop one into any project without it showing up in version control. The project doesn't need to know about mise — my tools are just available.
Environment variables. When the project's mise.toml is committed, I keep secrets and local config in mise.local.toml:
[env]
DATABASE_URL = "postgres://localhost/myapp_dev"
SECRET_KEY_BASE = "..."
Miser doesn't care how you structure this — it just calls mise ls --current and works with whatever tools are resolved for the current directory.
The registry is the main contribution surface. Each tool has its own file under lua/miser/registry/. To add support for a new tool:
lua/miser/registry/ (e.g. my_tool.lua)mise.toml tool name with lsp and/or formatter configlua/miser/registry/init.luamise.toml that declares the toolMIT