commit 9bdb011a50f25d80980750ca194efd0d18cae56c
parent cf347110c190944c1e59fc15751feebe9e77ccfc
Author: Yochem van Rosmalen <git@yochem.nl>
Date: Mon, 10 Nov 2025 06:51:39 +0100
refactor(spellfile): config() interface, docs #36481
Problem:
- Exposing the raw config as table is a pattern not seen anywhere else
in the Nvim codebase.
- Old spellfile.vim docs still available, no new documentation
Solution:
- Exposing a `config()` function that both acts as "getter" and "setter"
is a much more common idiom (e.g. vim.lsp, vim.diagnostic).
- Add new documentation and link old docs to |spellfile.lua| instead of
|spellfile.vim|.
Diffstat:
11 files changed, 160 insertions(+), 93 deletions(-)
diff --git a/runtime/doc/options.txt b/runtime/doc/options.txt
@@ -6107,7 +6107,7 @@ A jump table for the options with a short description can be found at |Q_op|.
encoding is used, Vim doesn't check it.
How the related spell files are found is explained here: |spell-load|.
- If the |spellfile.vim| plugin is active and you use a language name
+ If the |spellfile.lua| plugin is active and you use a language name
for which Vim cannot find the .spl file in 'runtimepath' the plugin
will ask you if you want to download the file.
diff --git a/runtime/doc/plugins.txt b/runtime/doc/plugins.txt
@@ -16,7 +16,7 @@ loaded by default while others are not loaded until requested by |:packadd|.
==============================================================================
Standard plugins ~
- *standard-plugin-list*
+ *standard-plugin-list*
Help-link Loaded Short description ~
|difftool| No Compares two directories or files side-by-side
|editorconfig| Yes Detect and interpret editorconfig
@@ -37,7 +37,7 @@ Help-link Loaded Short description ~
|pi_tar.txt| Yes Tar file explorer
|pi_tutor.txt| Yes Interactive tutorial
|pi_zip.txt| Yes Zip archive explorer
-|spellfile.vim| Yes Install spellfile if missing
+|spellfile.lua| Yes Install spellfile if missing
|tohtml| Yes Convert buffer to html, syntax included
|undotree| No Interactive textual undotree
@@ -167,6 +167,54 @@ trim_trailing_whitespace *editorconfig.trim_trailing_whitespace*
==============================================================================
+Builtin plugin: spellfile *spellfile.lua*
+
+Asks the user to download missing spellfiles. The spellfile is written to
+`stdpath('data') .. 'site/spell'` or the first writable directory in the
+'runtimepath'.
+
+The plugin can be disabled by setting `g:loaded_spellfile_plugin = 1`.
+
+
+*nvim.spellfile.Opts*
+ A table with the following fields:
+
+ Fields: ~
+ • {url} (`string`) The base URL from where the spellfiles are
+ downloaded. Uses `g:spellfile_URL` if it's set,
+ otherwise https://ftp.nluug.nl/pub/vim/runtime/spell.
+ • {timeout_ms} (`integer`, default: 15000) Number of milliseconds after
+ which the |vim.net.request()| times out.
+
+
+config({opts}) *spellfile.config()*
+ Configure spellfile download options. For example: >lua
+ require('nvim.spellfile').config({ url = '...' })
+<
+
+ Parameters: ~
+ • {opts} (`nvim.spellfile.Opts?`) When omitted or `nil`, retrieve the
+ current configuration. Otherwise, a configuration table.
+
+ Return: ~
+ (`nvim.spellfile.Opts?`) Current config if {opts} is omitted.
+
+get({lang}) *spellfile.get()*
+ Download spellfiles for language {lang} if available.
+
+ Parameters: ~
+ • {lang} (`string`) Language code.
+
+ Return: ~
+ (`table?`) A table with the following fields:
+ • {files} (`string[]`)
+ • {key} (`string`)
+ • {lang} (`string`)
+ • {encoding} (`string`)
+ • {dir} (`string`)
+
+
+==============================================================================
Builtin plugin: tohtml *tohtml*
diff --git a/runtime/doc/spell.txt b/runtime/doc/spell.txt
@@ -313,10 +313,6 @@ Only the first file is loaded, the one that is first in 'runtimepath'. If
this succeeds then additionally files with the name LL.EEE.add.spl are loaded.
All the ones that are found are used.
-If no spell file is found the |SpellFileMissing| autocommand event is
-triggered. This may trigger the |spellfile.vim| plugin to offer you
-downloading the spell file.
-
Additionally, the files related to the names in 'spellfile' are loaded. These
are the files that |zg| and |zw| add good and wrong words to.
@@ -640,48 +636,11 @@ Comment lines with the name of the .spl file are used as a header above the
words that were generated from that .spl file.
-SPELL FILE MISSING *spell-SpellFileMissing* *spellfile.vim*
-
-If the spell file for the language you are using is not available, you will
-get an error message. But if the "spellfile.vim" plugin is active it will
-offer you to download the spell file. Just follow the instructions, it will
-ask you where to write the file (there must be a writable directory in
-'runtimepath' for this).
-
-The plugin has a default place where to look for spell files, on the Vim ftp
-server. The protocol used is TLS (`https://`) for security. If you want to
-use another location or another protocol, set the g:spellfile_URL variable to
-the directory that holds the spell files. You can use `http://` or `ftp://`,
-but you are taking a security risk then. The |netrw| plugin is used for
-getting the file, look there for the specific syntax of the URL. Example: >
- let g:spellfile_URL = 'https://ftp.nluug.nl/vim/runtime/spell'
-You may need to escape special characters.
-
-The plugin will only ask about downloading a language once. If you want to
-try again anyway restart Vim, or set g:spellfile_URL to another value (e.g.,
-prepend a space).
-
-To avoid using the "spellfile.vim" plugin do this in your vimrc file: >
-
- let loaded_spellfile_plugin = 1
-
-Instead of using the plugin you can define a |SpellFileMissing| autocommand to
-handle the missing file yourself. You can use it like this: >
-
- :au SpellFileMissing * call Download_spell_file(expand('<amatch>'))
+SPELL FILE MISSING *spell-SpellFileMissing*
-Thus the <amatch> item contains the name of the language. Another important
-value is 'encoding', since every encoding has its own spell file. With two
-exceptions:
-- For ISO-8859-15 (latin9) the name "latin1" is used (the encodings only
- differ in characters not used in dictionary words).
-- The name "ascii" may also be used for some languages where the words use
- only ASCII letters for most of the words.
+If a spell file is missing, the user is asked whether to download it. See
+|spellfile.lua|.
-The default "spellfile.vim" plugin uses this autocommand, if you define your
-autocommand afterwards you may want to use ":au! SpellFileMissing" to overrule
-it. If you define your autocommand before the plugin is loaded it will notice
-this and not do anything.
*E797*
Note that the SpellFileMissing autocommand must not change or destroy the
buffer the user was editing.
diff --git a/runtime/doc/vim_diff.txt b/runtime/doc/vim_diff.txt
@@ -97,6 +97,8 @@ Defaults *defaults* *nvim-defaults*
- |man.lua| plugin is enabled, so |:Man| is available by default.
- |matchit| plugin is enabled. To disable it in your config: >vim
:let loaded_matchit = 1
+- |spellfile.lua| plugin is enabled, spellfiles are installed by default if
+ missing.
- |g:vimsyn_embed| defaults to "l" to enable Lua highlighting
@@ -741,6 +743,7 @@ Editor:
- *cscope* support was removed in favour of plugin-based solutions such as:
https://github.com/dhananjaylatkar/cscope_maps.nvim
- *popup-window* : Use |floating-windows| instead.
+- *spellfile.vim* : Replaced by |spellfile.lua|.
- *textprop* : Use |extmarks| instead.
Eval:
diff --git a/runtime/lua/nvim/spellfile.lua b/runtime/lua/nvim/spellfile.lua
@@ -1,22 +1,56 @@
+--- @brief
+--- Asks the user to download missing spellfiles. The spellfile is written to
+--- `stdpath('data') .. 'site/spell'` or the first writable directory in the
+--- 'runtimepath'.
+---
+--- The plugin can be disabled by setting `g:loaded_spellfile_plugin = 1`.
+
local M = {}
---- @class vim.spellfile.Config
+--- @class nvim.spellfile.Info
+--- @inlinedoc
+--- @field files string[]
+--- @field key string
+--- @field lang string
+--- @field encoding string
+--- @field dir string
+
+--- A table with the following fields:
+--- @class nvim.spellfile.Opts
+---
+--- The base URL from where the spellfiles are downloaded. Uses `g:spellfile_URL`
+--- if it's set, otherwise https://ftp.nluug.nl/pub/vim/runtime/spell.
--- @field url string
+---
+--- Number of milliseconds after which the [vim.net.request()] times out.
+--- (default: 15000)
--- @field timeout_ms integer
----@class vim.spellfile.Info
----@field files string[]
----@field key string
----@field lang string
----@field encoding string
----@field dir string
-
----@type vim.spellfile.Config
-M.config = {
- url = 'https://ftp.nluug.nl/pub/vim/runtime/spell',
+--- @type nvim.spellfile.Opts
+local config = {
+ url = vim.g.spellfile_URL or 'https://ftp.nluug.nl/pub/vim/runtime/spell',
timeout_ms = 15000,
}
+--- Configure spellfile download options. For example:
+--- ```lua
+--- require('nvim.spellfile').config({ url = '...' })
+--- ```
+--- @param opts nvim.spellfile.Opts? When omitted or `nil`, retrieve the
+--- current configuration. Otherwise, a configuration table.
+--- @return nvim.spellfile.Opts? : Current config if {opts} is omitted.
+function M.config(opts)
+ vim.validate('opts', opts, 'table', true)
+ if not opts then
+ return vim.deepcopy(config, true)
+ end
+ for k, v in
+ pairs(opts --[[@as table<any,any>]])
+ do
+ config[k] = v
+ end
+end
+
--- TODO(justinmk): add on_done/on_err callbacks to download(), instead of exposing this?
---@type table<string, boolean>
M._done = {}
@@ -26,6 +60,8 @@ local function rtp_list()
return vim.opt.rtp:get()
end
+---@param msg string
+---@param level vim.log.levels?
local function notify(msg, level)
vim.notify(msg, level or vim.log.levels.INFO)
end
@@ -37,15 +73,20 @@ local function normalize_lang(lang)
return (l:match('^[^,%s]+') or l)
end
+---@param path string
+---@return boolean
local function file_ok(path)
local s = vim.uv.fs_stat(path)
- return s and s.type == 'file' and (s.size or 0) > 0
+ return s ~= nil and s.type == 'file' and (s.size or 0) > 0
end
+---@param dir string
+---@return boolean
local function can_use_dir(dir)
return not not (vim.fn.isdirectory(dir) == 1 and vim.uv.fs_access(dir, 'W'))
end
+---@return string[]
local function writable_spell_dirs_from_rtp()
local dirs = {}
for _, dir in ipairs(rtp_list()) do
@@ -57,6 +98,7 @@ local function writable_spell_dirs_from_rtp()
return dirs
end
+---@return string?
local function ensure_target_dir()
local dir = vim.fs.abspath(vim.fs.joinpath(vim.fn.stdpath('data'), 'site/spell'))
if vim.fn.isdirectory(dir) == 0 and pcall(vim.fn.mkdir, dir, 'p') then
@@ -88,13 +130,15 @@ end
---
--- Treats status==0 as success if file exists.
---
+--- @param url string
+--- @param outpath string
--- @return boolean ok, integer|nil status, string|nil err
-local function fetch_file_sync(url, outpath, timeout_ms)
+local function fetch_file_sync(url, outpath)
local done, err, res = false, nil, nil
vim.net.request(url, { outpath = outpath }, function(e, r)
err, res, done = e, r, true
end)
- vim.wait(timeout_ms or M.config.timeout_ms, function()
+ vim.wait(config.timeout_ms, function()
return done
end, 50, false)
@@ -103,6 +147,8 @@ local function fetch_file_sync(url, outpath, timeout_ms)
return not not ok, (status ~= 0 and status or nil), err
end
+---@param lang string
+---@return nvim.spellfile.Info
local function parse(lang)
local code = normalize_lang(lang)
local enc = 'utf-8'
@@ -128,7 +174,7 @@ local function parse(lang)
}
end
----@param info vim.spellfile.Info
+---@param info nvim.spellfile.Info
local function download(info)
local dir = info.dir or ensure_target_dir()
if not dir then
@@ -143,10 +189,10 @@ local function download(info)
local spl_ascii = string.format('%s.ascii.spl', lang)
local sug_name = string.format('%s.%s.sug', lang, enc)
- local url_utf8 = M.config.url .. '/' .. spl_utf8
+ local url_utf8 = config.url .. '/' .. spl_utf8
local out_utf8 = vim.fs.joinpath(dir, spl_utf8)
notify('Downloading ' .. spl_utf8 .. ' …')
- local ok, st, err = fetch_file_sync(url_utf8, out_utf8, M.config.timeout_ms)
+ local ok, st, err = fetch_file_sync(url_utf8, out_utf8)
if not ok then
notify(
('Could not get %s (status %s): trying %s …'):format(
@@ -155,9 +201,9 @@ local function download(info)
spl_ascii
)
)
- local url_ascii = M.config.url .. '/' .. spl_ascii
+ local url_ascii = config.url .. '/' .. spl_ascii
local out_ascii = vim.fs.joinpath(dir, spl_ascii)
- local ok2, st2, err2 = fetch_file_sync(url_ascii, out_ascii, M.config.timeout_ms)
+ local ok2, st2, err2 = fetch_file_sync(url_ascii, out_ascii)
if not ok2 then
notify(
('No spell file available for %s (utf8:%s ascii:%s) — %s'):format(
@@ -182,10 +228,10 @@ local function download(info)
reload_spell_silent()
if not file_ok(vim.fs.joinpath(dir, sug_name)) then
- local url_sug = M.config.url .. '/' .. sug_name
+ local url_sug = config.url .. '/' .. sug_name
local out_sug = vim.fs.joinpath(dir, sug_name)
notify('Downloading ' .. sug_name .. ' …')
- local ok3, st3, err3 = fetch_file_sync(url_sug, out_sug, M.config.timeout_ms)
+ local ok3, st3, err3 = fetch_file_sync(url_sug, out_sug)
if ok3 then
notify('Saved ' .. sug_name .. ' to ' .. out_sug)
else
@@ -211,7 +257,10 @@ local function download(info)
M._done[info.key] = true
end
-function M.load_file(lang)
+--- Download spellfiles for language {lang} if available.
+--- @param lang string Language code.
+--- @return nvim.spellfile.Info?
+function M.get(lang)
local info = parse(lang)
if #info.files == 0 then
return
@@ -221,14 +270,18 @@ function M.load_file(lang)
return
end
- local answer = vim.fn.input(
- string.format('No spell file found for %s (%s). Download? [y/N] ', info.lang, info.encoding)
+ local prompt = ('No spell file found for %s (%s). Download? [y/N] '):format(
+ info.lang,
+ info.encoding
)
- if (answer or ''):lower() ~= 'y' then
- return
- end
-
- download(info)
+ vim.ui.input({ prompt = prompt }, function(input)
+ -- properly clear the message window
+ vim.api.nvim_echo({ { ' ' } }, false, { kind = 'empty' })
+ if not input or input:lower() ~= 'y' then
+ return
+ end
+ download(info)
+ end)
return info
end
diff --git a/runtime/lua/vim/_meta/options.lua b/runtime/lua/vim/_meta/options.lua
@@ -6527,7 +6527,7 @@ vim.bo.spf = vim.bo.spellfile
--- encoding is used, Vim doesn't check it.
--- How the related spell files are found is explained here: `spell-load`.
---
---- If the `spellfile.vim` plugin is active and you use a language name
+--- If the `spellfile.lua` plugin is active and you use a language name
--- for which Vim cannot find the .spl file in 'runtimepath' the plugin
--- will ask you if you want to download the file.
---
diff --git a/runtime/plugin/nvim/spellfile.lua b/runtime/plugin/nvim/spellfile.lua
@@ -1,16 +1,12 @@
-vim.g.loaded_spellfile_plugin = true
-
---- Downloads missing .spl file.
----
---- @param args { bufnr: integer, match: string }
-local function on_spellfile_missing(args)
- local spellfile = require('nvim.spellfile')
- spellfile.load_file(args.match)
+if vim.g.loaded_spellfile_plugin ~= nil then
+ return
end
+vim.g.loaded_spellfile_plugin = true
vim.api.nvim_create_autocmd('SpellFileMissing', {
- group = vim.api.nvim_create_augroup('nvim_spellfile', { clear = true }),
- pattern = '*',
+ group = vim.api.nvim_create_augroup('nvim.spellfile', {}),
desc = 'Download missing spell files when setting spelllang',
- callback = on_spellfile_missing,
+ callback = function(args)
+ require('nvim.spellfile').get(args.match)
+ end,
})
diff --git a/src/gen/gen_vimdoc.lua b/src/gen/gen_vimdoc.lua
@@ -415,6 +415,7 @@ local config = {
section_order = {
'difftool.lua',
'editorconfig.lua',
+ 'spellfile.lua',
'tohtml.lua',
'undotree.lua',
},
@@ -423,6 +424,7 @@ local config = {
'runtime/lua/tohtml.lua',
'runtime/pack/dist/opt/nvim.undotree/lua/undotree.lua',
'runtime/pack/dist/opt/nvim.difftool/lua/difftool.lua',
+ 'runtime/lua/nvim/spellfile.lua',
},
fn_xform = function(fun)
if fun.module == 'editorconfig' then
@@ -430,11 +432,17 @@ local config = {
fun.table = true
fun.name = vim.split(fun.name, '.', { plain = true })[2] or fun.name
end
+ if vim.startswith(fun.module, 'nvim.') then
+ fun.module = fun.module:sub(#'nvim.' + 1)
+ end
end,
section_fmt = function(name)
return 'Builtin plugin: ' .. name:lower()
end,
helptag_fmt = function(name)
+ if name:lower() == 'spellfile' then
+ name = 'spellfile.lua'
+ end
return name:lower()
end,
},
diff --git a/src/nvim/options.lua b/src/nvim/options.lua
@@ -8547,7 +8547,7 @@ local options = {
encoding is used, Vim doesn't check it.
How the related spell files are found is explained here: |spell-load|.
- If the |spellfile.vim| plugin is active and you use a language name
+ If the |spellfile.lua| plugin is active and you use a language name
for which Vim cannot find the .spl file in 'runtimepath' the plugin
will ask you if you want to download the file.
diff --git a/src/nvim/spell.c b/src/nvim/spell.c
@@ -1610,7 +1610,7 @@ static void spell_load_lang(char *lang)
// Plugins aren't loaded yet, so nvim/spellfile.lua cannot handle this case.
char autocmd_buf[512] = { 0 };
snprintf(autocmd_buf, sizeof(autocmd_buf),
- "autocmd VimEnter * call v:lua.require'nvim.spellfile'.load_file('%s')|set spell",
+ "autocmd VimEnter * call v:lua.require'nvim.spellfile'.get('%s')|set spell",
lang);
do_cmdline_cmd(autocmd_buf);
} else {
diff --git a/test/functional/plugin/spellfile_spec.lua b/test/functional/plugin/spellfile_spec.lua
@@ -42,7 +42,7 @@ describe('nvim.spellfile', function()
local requests = 0
vim.net.request = function(...) requests = requests + 1 end
- s.load_file('en_gb')
+ s.get('en_gb')
return { prompted = prompted, requests = requests }
]],
@@ -88,7 +88,7 @@ describe('nvim.spellfile', function()
end
end
- s.load_file('en_gb')
+ s.get('en_gb')
local spl = vim.fs.joinpath(data_root, 'site/spell/en_gb.utf-8.spl')
local sug = vim.fs.joinpath(data_root, 'site/spell/en_gb.utf-8.sug')
@@ -136,7 +136,7 @@ describe('nvim.spellfile', function()
vim.net.request = function(_, _, cb) cb(nil, { status = 404 }) end
- local info = s.load_file('zz')
+ local info = s.get('zz')
local done = s._done[info.key] == true
return { warns = warns, done = done, did_reload = did_reload }