doc/language.txt — rendered from the plugin's own vimdoc
*language.txt* Spell, grammar and translation tooling *language.nvim* language.nvim manual~ Author: Stefan Bartl
CONTENTS
1. Introduction ......................... |language-introduction| 2. Requirements ......................... |language-requirements| 3. Setup ................................ |language-setup| 4. Commands ............................. |language-commands| 5. Scoping .............................. |language-scoping| 6. Spell providers ...................... |language-spell-providers| 7. Translate engines .................... |language-translate-engines| 8. The review panel ..................... |language-panel| 9. Configuration ........................ |language-config| 10. Health ............................... |language-health|
1. INTRODUCTION
language.nvim bundles three text-language tools behind a small set of commands:
:Spellcheck spelling + grammar review, worked through in one place
:Translate translate a range/selection, shown in a popup (default)
:TranslateReplace translate a range/selection and replace it in place
It is built on lib.nvim as a hard dependency — including for UI (the
translation popup, pickers, menus use ui.kit). Translation is
native — no external Neovim plugin is required; only curl (the default
Google engine is keyless and works out of the box).
Both domains share one explicit scope model (|language-scoping|) and run
long/blocking work asynchronously with cancellation and timeouts.
2. REQUIREMENTS
- Neovim >= 0.9 (0.10+ recommended for vim.system)
- StefanBartl/lib.nvim
- curl (for translation)
- Optional: folke/trouble.nvim (nicer list; pcall-guarded)
- Optional spell CLIs: typos, cspell, codespell
- Optional grammar LSP: harper_ls, ltex
- Optional: trans (translate-shell) for the "shell" engine
3. SETUP
require("language").setup({})
Call once. See |language-config| for the full option tree. With lazy.nvim:
{
"StefanBartl/language.nvim",
dependencies = { "StefanBartl/lib.nvim", "folke/trouble.nvim" },
event = "VeryLazy",
config = function() require("language").setup({}) end,
}
4. COMMANDS
Each command is its own lib.nvim.bindings.usercmd.composer verb (a flat root route, no subcommand tree). An unrecognized--flagon :Translate/:TranslateReplace now reports a clear error instead of being silently ignored, as it was before this command layer was rebuilt on the composer; dispatch for valid input is otherwise unchanged. Likewise, a positional token that is neither the language nor a recognized scope word (typically a typo, e.g.selctionforselection) is reported as an error rather than silently dropped and defaulting to the whole-buffer scope. *:Spellcheck* :Spellcheck [lang] [scope|verb] Run a spell/grammar review. Optional first argument is a language code (e.g.en,de,en,de). The scope/verb may be one of: buffer current buffer (default) visible the visible window range only cwd recursively across the working directory path=<p> a file or directory clear end the session, remove diagnostics refresh re-scan the current buffer session Examples:
:Spellcheck
:Spellcheck en cwd
:Spellcheck de path=~/notes
:Spellcheck clear
*:Translate* :[range]Translate <lang> [--nocode] [--output=<mode>] [scope] Translate the given range/selection to <lang> and **show the result** without touching the buffer (default output:popup, a read-only, focusable, closable (q/<Esc>)ui.kitfloat near the cursor — scroll/yank freely, nothing is written back). Options: --nocode skip fenced code blocks and inline-code lines (only takes effect together with --output=replace) --output=<mode> popup (default) | replace | buffer | vsplit | split | tab | insert | clipboard | notify --files=<mode> output mode when scope is cwd/path=<dir> — see |language-translate-files| scope selection (default with a range) | buffer | cwd | path=<p> Examples:
:'<,'>Translate DE " popup near the cursor, buffer untouched
:Translate EN --output=vsplit " open the translation in a vertical split
:Translate FR --output=buffer " open the translation in a new buffer
*:TranslateReplace* :[range]TranslateReplace <lang> [--nocode] [scope] Translate the given range/selection to <lang> and **replace it in place** — the direct, mutating counterpart to :Translate (this is what :Translate did before language.nvim gained a non-mutating default; the name/behavior matches the classic:TranslateReplace). Always replaces; there is no--output=.--nocodeskips fenced code blocks and inline-code lines.scopeisselection(default with a range) |buffer|cwd|path=<p>— forcwd/path=<dir>this picks and overwrites multiple files in place (with a confirmation prompt), see |language-translate-files|. Examples:
:'<,'>TranslateReplace DE
:TranslateReplace EN --nocode
:TranslateReplace DE cwd " pick files, overwrite in place
The target span/file is re-verified against its current content
immediately before writing; if it changed while the translation was in
flight (an edit elsewhere, another process touching the file), the write
is skipped and reported rather than blindly overwriting newer content.
Both :Translate and :TranslateReplace preserve each line's leading indentation
across the round trip: providers (notably Google's gtx endpoint) normalize
away leading whitespace, so a translated list item like - foo would
otherwise come back as - bar with the indent dropped to column 0. The indent
is stripped before the request and re-applied to the matching output line
(skipped when the provider merges/splits lines, since the 1:1 mapping no longer
holds).
*:Translate!*
:[range]Translate![lang]
Open the interactive translation window (|language-translate-window|),
prefilled from the range if given. The target is [lang], else
translate.default_target, else a picker.
*language-translate-files*
:Translate <lang> cwd
:Translate <lang> path=<dir> [--files=<mode>]
:TranslateReplace <lang> cwd
:TranslateReplace <lang> path=<dir>
Translate multiple files. Gathers translatable files under the directory
(by translate.files.extensions, skipping .git/node_modules/…), opens a
multi-select chooser (<Tab> to toggle, <CR> to confirm), then translates
each picked file. Output per file:
suffix write a sibling name.<LANG>.ext (default, non-destructive)
replace overwrite the file in place (asks for confirmation first)
buffers open each translation in a scratch buffer (no disk write)
:Translate … --files=<mode> overrides translate.files.output for one
call; :TranslateReplace … cwd|path=<dir> always forces replace.
Note: --nocode code-skipping applies to buffer/selection translation,
not to whole-file translation. In replace mode, each file is
re-verified against its on-disk content immediately before it is
overwritten; a file that changed during its (sequential, one-at-a-time)
translation is skipped rather than overwritten, and the final summary
counts files actually written, not files picked. If the directory walk
that gathers translatable files fails partway (permissions, a vanishing
entry, …), the file list may be incomplete rather than "this directory
has nothing to translate" going unremarked; a notify warning names the
directory and the reason.
5. SCOPING
Every action operates over an explicit scope, parsed once from the command
arguments:
buffer whole current buffer
visible only the visible window range (fast; good for live scanning)
cwd recursively across the working directory (always async)
path=<p> a file OR a directory (recursive)
selection a visual/line range (mainly for :Translate)
path=<p> expands ~ and environment variables only (not Vim's %/#/
<cfile>/<cword> specials, and no shell/backtick command substitution) —
exactly what a path typed on a command line needs, nothing more.
For cwd/path scopes with many files, an external CLI spell provider is
preferred (one process over the tree, fastest); otherwise the native scanner
recursively walks the directory on disk (not just already-open buffers),
async and chunked (20 files per tick) so the editor never freezes. Live
(unsaved) buffer content is used for files that happen to be open; closed
files are read fresh from disk. Vendor directories (.git, node_modules, .venv,
dist, build, target, .cache, __pycache__) are skipped, as are files above 5 MB
or spell.max_file_lines.
6. SPELL PROVIDERS
native Neovim's ownvim.spell(always available). Respects'spelllang'. Splits CamelCase/snake_case identifiers and only reports genuinely misspelled subwords; restricts checking to Treesitter @spell regions (comments/strings/prose) and skips URLs/emails. For cwd/path scopes it recursively scans every matching file on disk (see |language-scoping|), not only already-open buffers. typos fast tree-wide scanning via thetyposCLI (async), used for cwd/path scopes when available. cspell, external CLIs (opt-in) for cwd/path scopes. codespell All three exit non-zero on a normal "found something" run, so a genuine failure (the process times out, never starts, or crashes) is told apart by producing no output at all rather than by its exit code; that case is reported via notify instead of showing as a clean 0-issue scan. cspell_server a persistent Node sidecar that keeps cspell-lib loaded, for fast, code-aware, live buffer checks (needs node + cspell). Opt in by adding "cspell_server" tospell.providers.buffer. If it never starts, dies, or a check exceeds its ~6s guard, the other configured providers' results are still shown; a one-time-per-session notify warns that cspell's own contribution is missing, rather than that being silent. harper/ltex grammar diagnostics from a running language server are harvested and shown alongside spelling issues (kind = grammar). custom escape hatch for a checker without a bundled adapter (cwd/path scopes). Configure `spell.providers.custom = { cmd = fun(scope, cfg) -> argv, parse = fun(out, base) -> partial-issue[] }` and add "custom" tospell.providers.cwd.parsereturns a list of `{ path, lnum, col, end_col?, word, kind?, message?, suggestions? };bufnr/source` are filled in automatically. Mirrorstranslate.custom. Acmd/parsethat throws, returns the wrong shape, or whose entries are missingword/pathis reported via notify rather than read as a clean (issue-free) scan. Which providers run per scope is controlled byspell.providers(see |language-config|).
7. TRANSLATE ENGINES
google default, keyless (only curl). Modern gtx endpoint.
deepl official DeepL API. Key from translate.deepl.api_key or the
DEEPL_API_KEY environment variable. Free keys (suffix ":fx")
use api-free.deepl.com automatically.
shell the trans CLI (translate-shell).
custom any CLI: provide `translate.custom = { cmd = fun(lines,
target, source) -> argv, parse = fun(stdout) -> lines }`.
translate.engine selects the engine; translate.fallback is a chain tried
when the chosen engine is unavailable (e.g. deepl without a key -> google).
*language-translate-maps*
Motion / visual mappings (opt-in via translate.keymaps):
operator <lhs>{motion} translates the moved-over text object, e.g.
<lhs>ip for a paragraph or <lhs>iw for a word.
visual <lhs> translates the current visual selection.
Char-wise motions and char-wise visual selections translate and replace the
exact byte span (multibyte-safe); line-wise (V) uses the whole line range.
The target language is translate.default_target when set, otherwise chosen
from translate.default_langs via a picker. These are rewrite-in-place
operators (like gu/gq) — they always replace, independent of
translate.default_output (which only affects :Translate).
require("language").setup({
translate = {
default_target = "DE", -- skip the picker
keymaps = { operator = "gtr", visual = "gtr" },
},
})
8. THE REVIEW PANEL
Withspell.ui.view = "picker"(default), :Spellcheck opens an interactive panel listing every issue.<CR>on an item opens its action menu: Spelling issues: Choose suggestion… pick a replacement (fuzzy) Replace all in buffer… apply a replacement to every occurrence Add to dictionary zg-equivalent (persistent or session) Ignore (session) drop this word for the session Ignore (persistent) drop this word permanently (ignore file) Jump to location go to the issue Grammar/style issues (harper_ls/ltex): Apply LSP fix… run the language server's code actions at the issue Ignore (session/persistent), Jump to location The panel list itself also has direct keys for the actions above, so common ones don't need the menu round-trip: a Add to dictionary i / I Ignore (session / persistent) L Apply LSP fix (grammar/style issues) gd, o Jump to location (closes the panel) ? Show this cheatsheet A key not applicable to the issue under cursor (e.g.aon a grammar issue) is reported rather than silently ignored. *language-translate-window*
Interactive translation window
:Translate! opens two stacked floats: an editable input (top) and a read-only
output (bottom) that translates as you type (debounced). Actions are NORMAL-mode
keys (press <Esc> first) so insert-mode typing keeps its native keys:
<C-l> change target language
<C-r> reverse: use the translation as the new input, then pick a target
(round-trip / translate onward)
<C-h> open the translation-history picker
<C-y> copy the translation to the clipboard (also records to history)
q,<Esc> close (also <C-c> in insert mode)
The window uses the configured engine/fallback like every other translation.
*language-thesaurus*
Thesaurus / synonyms
require("language").synonyms()(or the opt-inthesaurus.keymap) looks up synonyms for the word under the cursor and replaces it with your pick. The default source is the free, keyless Datamuse API (English); setthesaurus.source = "custom"withthesaurus.custom = fun(word, cb)for another source or language.
require("language").setup({
thesaurus = { keymap = "<leader>sy" }, -- opt-in
})
The word's span is re-verified right before the replacement is written; if
the text under it changed while the lookup/menu was open (an edit elsewhere,
another window), the replacement is discarded and reported instead of being
written over unrelated text. A lookup failure (the request fails, or
thesaurus.custom throws) is reported via notify rather than read as "no
synonyms for this word" -- the same distinction the CLI spell providers make
between a genuinely clean result and a broken one.
Live scanning
Setspell.live = truefor always-on inline diagnostics that update as you edit (debounced byscan_debounce_ms). It is decoupled from the panel and runs only forspell.filetypes. Withspell.live_scope = "visible"(default) it scans just the visible window range and follows the viewport; set it to "buffer" to scan the whole buffer. Themax_file_lines, readonly andmax_highlightsgates apply. Whole-buffer native scans are cached bychangedtick, so re-opening the panel or re-scanning an unchanged buffer is instant. *language-spell-silencing*
Silencing false positives
Besides the dictionary (zg / "Add to dictionary") and the ignore list, inline
directives silence spots without touching any list:
language:disable-line skip the line the directive is on
language:disable-next-line skip the following line
language:disable-file skip the whole buffer
Put them in a comment so they don't show in rendered output.
*language-spell-guard*
Block writing on errors (opt-in)
spell.guard.block_write_on_error = trueaborts:wonspell.filetypesbuffers while spelling errors remain (grammar is advisory and never blocks). Bypass a single write with:noautocmd w. *language-spell-replace-all*spell.dictionary.replace_all = true(default) makes "Choose suggestion…" in the panel apply the pick to every occurrence of the word in the buffer; set it to false to replace only the exact occurrence. After each action the panel re-scans and re-opens. Setview = "quickfix"for the classic diagnostics + quickfix/Trouble session flow with z= fixing. *language-spell-highlights*
Buffer highlights (opt-in)
Visibility normally runs entirely through|vim.diagnostic|, whose appearance follows the user's ownvim.diagnostic.config(). Setspell.highlights.enable = trueto additionally mark issues directly in the buffer via extmarks, independent of that config — useful when diagnostics virtual text/underline is muted globally or filtered elsewhere. Two highlight groups are defined (default = true, so a colorscheme or:hican override them):LanguageSpellHighlight(spelling/rare/caps) andLanguageGrammarHighlight(grammar/style).spell.highlights.styleselects"underline"(default) or"undercurl".
require("language").setup({
spell = { highlights = { enable = true, style = "undercurl" } },
})
9. CONFIGURATION
Defaults (abridged; see lua/language/config/DEFAULTS.lua):
require("language").setup({
spell = {
providers = {
buffer = { "native", "lsp" },
cwd = { "typos", "native" },
lsp = { enable = true, servers = { "harper_ls", "ltex" } },
custom = nil, -- { cmd = fun(scope,cfg)->argv, parse = fun(out,base)->issues }
},
filetypes = { "markdown","text","gitcommit","tex","rst","asciidoc","help" },
default_scope = "buffer",
live = false, live_scope = "visible", scan_debounce_ms = 400,
word_split = { enable = true, min_length = 4 },
regions = { treesitter_spell = true, skip_urls = true, skip_emails = true },
programming_dict = false,
extra_wordlists = {}, -- { ["my-list"] = { "word1", ... } }, session-only
max_highlights = 100, max_file_lines = 20000, skip_readonly = true,
ui = { view = "picker", preview = true, group_by = "file", dedupe = true },
dictionary = {
ignore_file = vim.fn.stdpath("state") .. "/language/spell_ignore.txt",
use_spellfile = true, replace_all = true,
},
highlights = { enable = false, style = "underline" }, -- opt-in buffer extmarks
keymaps = { panel = "<leader>ss", next = "]s",
fix = "<leader>z=", fix1 = "<leader>z1" },
},
translate = {
engine = "google", fallback = { "google" },
default_output = "popup", default_input = "selection",
nocode_default = false, timeout_ms = 8000,
deepl = { api_key = nil },
files = { output = "suffix" }, -- :Translate cwd/path=<dir> default
},
commands = true,
})
10. HEALTH
:checkhealth language
Reports Neovim/lib.nvim availability, spell provider tools, grammar LSP
clients, curl and DeepL key status, and 'spelllang'.