An in-process LSP server for Neovim that provides spell checking diagnostics and code actions, leveraging Neovim's built-in spell checking capabilities.
spellwand.nvim
An in-process LSP server for Neovim that provides spell checking diagnostics and code actions, leveraging Neovim's built-in spell checking capabilities.
Uses Neovim's built-in spell checking, so results are always consistent with native behavior. Also shares how to implement an in-process LSP server โ see Limitations for its advantages and disadvantages.
Features
- In-process LSP server - zero external dependencies, seamless access to Neovim's internal spell APIs
- Native LSP integration - works with
vim.lsp.buf.code_action(), telescope, trouble.nvim, etc. - Standard LSP configuration - provides
lsp/spellwand.luaruntime path, just like nvim-lspconfig - Treesitter-aware - uses
@spellcaptures for context-aware checking, with fallback to full buffer scan - Spellfile support - works with Neovim's
spellfileoption for multiple dictionaries - Performance-optimized - insert-mode pending strategy and normal-mode debounce mechanism to keep the UI responsive
- Customizable processing - users can define
condandpreprocessfunctions to customize spell checking
Installation
Version Compatibility:
- Neovim 0.11+ for basic LSP functionality (
vim.lsp.config) - Neovim 0.12+ for
:lsp stopand other LSP management commands
lazy.nvim
{
"chaneyzorn/spellwand.nvim",
config = function()
vim.lsp.enable("spellwand")
end,
}
vim.pack (Neovim 0.12+)
vim.pack.add({
{ src = "https://github.com/chaneyzorn/spellwand.nvim" },
})
vim.lsp.enable("spellwand")
Configuration
spellwand uses the standard Neovim 0.11+ LSP configuration API:
-- Default configuration (no setup needed)
vim.lsp.enable("spellwand")
-- Custom configuration with max_errors vim.lsp.config("spellwand", { settings = { spellwand = { max_errors = 500, } } }) vim.lsp.enable("spellwand")
Or create a config file at lsp/spellwand.lua in your config directory:
-- ~/.config/nvim/lsp/spellwand.lua
return {
filetypes = { "markdown", "text", "gitcommit" },
settings = {
spellwand = {
max_errors = 500,
}
}
}
Then just enable:
vim.lsp.enable("spellwand")
Available Options
All configuration options and their defaults (passed via settings.spellwand):
vim.lsp.config("spellwand", {
filetypes = nil, ---@type string[]? Filetypes to attach to (nil = all filetypes)
settings = {
spellwand = {
---@type fun(bufnr: integer): boolean
---Return false to skip spell checking for this buffer entirely (no diagnostics will be produced)
cond = function(bufnr) return true end,
---@type ("treesitter"|"full")[] | fun(bufnr: integer): ("treesitter"|"full")[] ---Tries each strategy in order until one succeeds strategies = { "treesitter", "full" },
---@type integer ---Early-return limit to keep performance acceptable on large buffers. ---Once this many spelling errors are found, scanning stops immediately. max_errors = 999,
---@type fun(bufnr: integer, spell_errors: spellwand.SpellingError[]): spellwand.SpellingError[] ---Transform or filter the raw spell errors before they are converted to diagnostics and code actions. ---Use this to ignore short words, deduplicate, or inject custom logic. preprocess = function(bufnr, spellerrors) return spellerrors end,
---@type table<string, integer> Severity mapping severity = { SpellBad = vim.diagnostic.severity.WARN, SpellCap = vim.diagnostic.severity.HINT, SpellLocal = vim.diagnostic.severity.HINT, SpellRare = vim.diagnostic.severity.INFO, },
---@type spellwand.Messages Diagnostic message formatter (templates or custom function) messages = { SpellBad = 'Unknown word: "%s"', SpellCap = 'Capitalization error: "%s"', SpellLocal = 'Local word: "%s"', SpellRare = 'Rare word: "%s"', SuggestPrefix = "did you mean: %s", },
---@type integer Number of spelling suggestions shown in diagnostic messages (0 to disable) numsuggestionsin_diagnostics = 0,
---@type integer Number of spelling suggestions offered in code actions numsuggestionsincodeaction = 3,
---@type integer Debounce delay in milliseconds before re-computing diagnostics debounce_ms = 300, } } })
See lua/spellwand/types.lua for complete type definitions.
Since spellwand runs in-process, it is possible to use runtime Lua functions for cond and preprocess โ no JSON serialization involved.
The treesitter strategy only checks @spell nodes (typically comments and string literals), defined in query files like queries/lua/highlights.scm. Use the full strategy to check all buffer text.
Debounce
There are two independent debounce layers you can tune:
Client-side debounce (flags.debouncetextchanges):
- Controls how often Neovim's LSP client sends
textDocument/didChangeto spellwand. - Default is
150milliseconds (Neovim built-in default). - Increase this value if you want fewer change notifications sent to the server.
settings.spellwand.debounce_ms):
- Controls how long spellwand waits after receiving a change before re-computing diagnostics.
- Default is
300milliseconds. - Normal mode:
textDocument/didChangetriggers the debounce timer; diagnostics are refreshed after you stop typing for300ms. - Insert/Replace mode:
didChangeis completely ignored to avoid blocking the UI. Old diagnostics are hidden immediately onInsertEnter(by pushing an empty list), and refreshed immediately onInsertLeave. Because of this,vim.diagnostic.config({ updateininsert = true })has no effect on spellwand diagnostics.
vim.lsp.config("spellwand", {
flags = {
debouncetextchanges = 150, -- client-side: throttle didChange notifications
},
settings = {
spellwand = {
debounce_ms = 300, -- server-side: delay before re-computing diagnostics
}
}
})
Customization Examples
Use cond to skip large files, help files, or readonly buffers:
cond = function(bufnr)
-- Skip help files and readonly buffers
local bo = vim.bo[bufnr]
if bo.filetype == "help" or bo.readonly then
return false
end
-- Skip large files (>10K lines)
if vim.api.nvimbufline_count(bufnr) > 10000 then
return false
end
return true
end
Use preprocess to ignore short words (โค2 characters):
preprocess = function(bufnr, spellerrors)
return vim.tbl_filter(function(err)
return #err.word > 2
end, spell_errors)
end
Deduplicate: keep only the first occurrence of each misspelled word:
preprocess = function(bufnr, spellerrors)
local seen = {}
return vim.tbl_filter(function(err)
if seen[err.word] then return false end
seen[err.word] = true
return true
end, spell_errors)
end
Use a function for strategies to dynamically choose based on buffer:
strategies = function(bufnr)
-- Use full scan for gitcommit (typically short, no treesitter parser needed)
if vim.bo[bufnr].filetype == "gitcommit" then
return { "full" }
end
return { "treesitter", "full" }
end
Use a custom messages function for full control:
messages = function(word, type, suggestions)
local icons = { SpellBad = "๐จ", SpellCap = "โ ๏ธ ", SpellLocal = "๐", SpellRare = "๐" }
local icon = icons[type] or "โ"
if suggestions and #suggestions > 0 then
return string.format("%s %s (try: %s)", icon, word, table.concat(suggestions, ", "))
end
return string.format("%s %s", icon, word)
end
Usage
Spell Configuration
spellwand uses Neovim's built-in vim.spell.check() function, which respects your window-local and buffer-local settings:
spell- Enables native spell checking, highlighting, and navigation (]s/[s). spellwand diagnostics work independently of this setting.spelllang- Language dictionaries to use (e.g.,:set spelllang=enus,dede).spellfile- Additional word lists. spellwand reads this to determine where to add words.spelloptions- Additional options likecamelto accept CamelCase words as correct (e.g.,:set spelloptions+=camel).
Standard LSP Commands
Since spellwand is a standard LSP server, you control it using Neovim's built-in LSP commands:
" Enable spellwand (start the LSP client)
:lua vim.lsp.enable('spellwand')
" Disable spellwand (detach from all buffers) :lsp disable spellwand
" Check if spellwand is attached :checkhealth vim.lsp
Key Mappings
spellwand works with native spell keybindings:
]s/s- Navigate to next/previous spelling errorgra- Code action at cursor position (LSP builtin)z=- Suggestions for word under cursor (native)zg- Add word to dictionary (uses first spellfile, native)2zg- Add word to second spellfile (native)zw- Mark word as wrong (native)
zg, zw, etc.) or an external editor, spellwand won't automatically notice the spellfile change. You can manually refresh diagnostics:
" Refresh current buffer
:SpellwandRefresh
" Refresh all attached buffers :SpellwandRefresh!
Or wrap native mappings to refresh automatically:
vim.keymap.set("n", "zg", "zg<cmd>SpellwandRefresh!<cr>", { remap = false })
vim.keymap.set("n", "zw", "zw<cmd>SpellwandRefresh!<cr>", { remap = false })
For zg/zw, SpellwandRefresh! is recommended because the spellfile is shared across buffers.
Code Actions
When your cursor is on a misspelled word, use gra (or :lua vim.lsp.buf.code_action()):
Available actions:
- Add word to each configured spellfile (shown with full path)
- Add all misspelled words in buffer to each configured spellfile
- Change to one of the suggestions
Limitations
As an in-process LSP server, spellwand has different trade-offs compared to external servers that run in a separate process:
Advantages:
- Direct access to Neovim's internal state and APIs (e.g.
vim.spell,vim.fn.spellsuggest(), buffer-localspellfileandspelllang) without RPC serialization overhead. - Seamless integration with native Vim features and runtime Lua functions.
- Spell checking runs synchronously on Neovim's main thread. Large buffers or files with thousands of spelling errors may cause temporary TUI lag. We carefully designed the insert-mode pending strategy and normal-mode debounce mechanism to mitigate this; you can further tune performance with the
maxerrors,debouncems, andcondoptions. - The server must implement the
vim.lsp.rpc.PublicClientinterface correctly and explicitly triggeron_exit, because Neovim'svim.systemcallback mechanism (used for external LSP servers to detect process exit) does not apply to in-process servers. Autocmds and timers are additional performance optimizations that also need careful cleanup.
Native Vim only spell-checks the visible window range during screen rendering. spellwand, as an LSP server, must scan the entire buffer to produce a complete diagnostic list, which is inherently heavier work.
A future vim.lsp.server API may simplify the boilerplate, but it won't remove the fundamental bottleneck of running on the main thread. Offloading to uv threads is possible, yet becomes awkward once you need direct access to Neovim's internal state โ at which point an external LSP server is the cleaner choice.
Alternative Spell Checking LSP Servers
If you need more advanced features or asynchronous processing, consider these dedicated spell checking LSP servers:
- [typos-lsp - Source code spell checker based on typos
- harper-ls - The Grammar Checker for Developers
- codebook - A fast, semantic, cross-platform spell checker
- cspell-lsp - the LSP wrapper for cspell
Acknowledgments
- spellwarn.nvim: Inspired the spell checking approach
- spellsitter.nvim: Precursor to Neovim's built-in Treesitter spell checking (merged in 0.8)
- in-process-lsp-guide: A guide for implementing the in-process LSP pattern