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|.
This commit is contained in:
Yochem van Rosmalen
2025-11-10 06:51:39 +01:00
committed by GitHub
parent cf347110c1
commit 9bdb011a50
11 changed files with 160 additions and 93 deletions

View File

@@ -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. encoding is used, Vim doesn't check it.
How the related spell files are found is explained here: |spell-load|. 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 for which Vim cannot find the .spl file in 'runtimepath' the plugin
will ask you if you want to download the file. will ask you if you want to download the file.

View File

@@ -16,7 +16,7 @@ loaded by default while others are not loaded until requested by |:packadd|.
============================================================================== ==============================================================================
Standard plugins ~ Standard plugins ~
*standard-plugin-list* *standard-plugin-list*
Help-link Loaded Short description ~ Help-link Loaded Short description ~
|difftool| No Compares two directories or files side-by-side |difftool| No Compares two directories or files side-by-side
|editorconfig| Yes Detect and interpret editorconfig |editorconfig| Yes Detect and interpret editorconfig
@@ -37,7 +37,7 @@ Help-link Loaded Short description ~
|pi_tar.txt| Yes Tar file explorer |pi_tar.txt| Yes Tar file explorer
|pi_tutor.txt| Yes Interactive tutorial |pi_tutor.txt| Yes Interactive tutorial
|pi_zip.txt| Yes Zip archive explorer |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 |tohtml| Yes Convert buffer to html, syntax included
|undotree| No Interactive textual undotree |undotree| No Interactive textual undotree
@@ -166,6 +166,54 @@ trim_trailing_whitespace *editorconfig.trim_trailing_whitespace*
buffer is written. buffer is written.
==============================================================================
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* Builtin plugin: tohtml *tohtml*

View File

@@ -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. this succeeds then additionally files with the name LL.EEE.add.spl are loaded.
All the ones that are found are used. 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 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. 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. words that were generated from that .spl file.
SPELL FILE MISSING *spell-SpellFileMissing* *spellfile.vim* SPELL FILE MISSING *spell-SpellFileMissing*
If the spell file for the language you are using is not available, you will If a spell file is missing, the user is asked whether to download it. See
get an error message. But if the "spellfile.vim" plugin is active it will |spellfile.lua|.
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>'))
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.
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* *E797*
Note that the SpellFileMissing autocommand must not change or destroy the Note that the SpellFileMissing autocommand must not change or destroy the
buffer the user was editing. buffer the user was editing.

View File

@@ -97,6 +97,8 @@ Defaults *defaults* *nvim-defaults*
- |man.lua| plugin is enabled, so |:Man| is available by default. - |man.lua| plugin is enabled, so |:Man| is available by default.
- |matchit| plugin is enabled. To disable it in your config: >vim - |matchit| plugin is enabled. To disable it in your config: >vim
:let loaded_matchit = 1 :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 - |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: - *cscope* support was removed in favour of plugin-based solutions such as:
https://github.com/dhananjaylatkar/cscope_maps.nvim https://github.com/dhananjaylatkar/cscope_maps.nvim
- *popup-window* : Use |floating-windows| instead. - *popup-window* : Use |floating-windows| instead.
- *spellfile.vim* : Replaced by |spellfile.lua|.
- *textprop* : Use |extmarks| instead. - *textprop* : Use |extmarks| instead.
Eval: Eval:

View File

@@ -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 = {} 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 --- @field url string
---
--- Number of milliseconds after which the [vim.net.request()] times out.
--- (default: 15000)
--- @field timeout_ms integer --- @field timeout_ms integer
---@class vim.spellfile.Info --- @type nvim.spellfile.Opts
---@field files string[] local config = {
---@field key string url = vim.g.spellfile_URL or 'https://ftp.nluug.nl/pub/vim/runtime/spell',
---@field lang string
---@field encoding string
---@field dir string
---@type vim.spellfile.Config
M.config = {
url = 'https://ftp.nluug.nl/pub/vim/runtime/spell',
timeout_ms = 15000, 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? --- TODO(justinmk): add on_done/on_err callbacks to download(), instead of exposing this?
---@type table<string, boolean> ---@type table<string, boolean>
M._done = {} M._done = {}
@@ -26,6 +60,8 @@ local function rtp_list()
return vim.opt.rtp:get() return vim.opt.rtp:get()
end end
---@param msg string
---@param level vim.log.levels?
local function notify(msg, level) local function notify(msg, level)
vim.notify(msg, level or vim.log.levels.INFO) vim.notify(msg, level or vim.log.levels.INFO)
end end
@@ -37,15 +73,20 @@ local function normalize_lang(lang)
return (l:match('^[^,%s]+') or l) return (l:match('^[^,%s]+') or l)
end end
---@param path string
---@return boolean
local function file_ok(path) local function file_ok(path)
local s = vim.uv.fs_stat(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 end
---@param dir string
---@return boolean
local function can_use_dir(dir) local function can_use_dir(dir)
return not not (vim.fn.isdirectory(dir) == 1 and vim.uv.fs_access(dir, 'W')) return not not (vim.fn.isdirectory(dir) == 1 and vim.uv.fs_access(dir, 'W'))
end end
---@return string[]
local function writable_spell_dirs_from_rtp() local function writable_spell_dirs_from_rtp()
local dirs = {} local dirs = {}
for _, dir in ipairs(rtp_list()) do for _, dir in ipairs(rtp_list()) do
@@ -57,6 +98,7 @@ local function writable_spell_dirs_from_rtp()
return dirs return dirs
end end
---@return string?
local function ensure_target_dir() local function ensure_target_dir()
local dir = vim.fs.abspath(vim.fs.joinpath(vim.fn.stdpath('data'), 'site/spell')) 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 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. --- Treats status==0 as success if file exists.
--- ---
--- @param url string
--- @param outpath string
--- @return boolean ok, integer|nil status, string|nil err --- @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 local done, err, res = false, nil, nil
vim.net.request(url, { outpath = outpath }, function(e, r) vim.net.request(url, { outpath = outpath }, function(e, r)
err, res, done = e, r, true err, res, done = e, r, true
end) end)
vim.wait(timeout_ms or M.config.timeout_ms, function() vim.wait(config.timeout_ms, function()
return done return done
end, 50, false) 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 return not not ok, (status ~= 0 and status or nil), err
end end
---@param lang string
---@return nvim.spellfile.Info
local function parse(lang) local function parse(lang)
local code = normalize_lang(lang) local code = normalize_lang(lang)
local enc = 'utf-8' local enc = 'utf-8'
@@ -128,7 +174,7 @@ local function parse(lang)
} }
end end
---@param info vim.spellfile.Info ---@param info nvim.spellfile.Info
local function download(info) local function download(info)
local dir = info.dir or ensure_target_dir() local dir = info.dir or ensure_target_dir()
if not dir then if not dir then
@@ -143,10 +189,10 @@ local function download(info)
local spl_ascii = string.format('%s.ascii.spl', lang) local spl_ascii = string.format('%s.ascii.spl', lang)
local sug_name = string.format('%s.%s.sug', lang, enc) 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) local out_utf8 = vim.fs.joinpath(dir, spl_utf8)
notify('Downloading ' .. 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 if not ok then
notify( notify(
('Could not get %s (status %s): trying %s …'):format( ('Could not get %s (status %s): trying %s …'):format(
@@ -155,9 +201,9 @@ local function download(info)
spl_ascii 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 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 if not ok2 then
notify( notify(
('No spell file available for %s (utf8:%s ascii:%s) — %s'):format( ('No spell file available for %s (utf8:%s ascii:%s) — %s'):format(
@@ -182,10 +228,10 @@ local function download(info)
reload_spell_silent() reload_spell_silent()
if not file_ok(vim.fs.joinpath(dir, sug_name)) then 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) local out_sug = vim.fs.joinpath(dir, sug_name)
notify('Downloading ' .. 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 if ok3 then
notify('Saved ' .. sug_name .. ' to ' .. out_sug) notify('Saved ' .. sug_name .. ' to ' .. out_sug)
else else
@@ -211,7 +257,10 @@ local function download(info)
M._done[info.key] = true M._done[info.key] = true
end 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) local info = parse(lang)
if #info.files == 0 then if #info.files == 0 then
return return
@@ -221,14 +270,18 @@ function M.load_file(lang)
return return
end end
local answer = vim.fn.input( local prompt = ('No spell file found for %s (%s). Download? [y/N] '):format(
string.format('No spell file found for %s (%s). Download? [y/N] ', info.lang, info.encoding) info.lang,
info.encoding
) )
if (answer or ''):lower() ~= 'y' then vim.ui.input({ prompt = prompt }, function(input)
return -- properly clear the message window
end vim.api.nvim_echo({ { ' ' } }, false, { kind = 'empty' })
if not input or input:lower() ~= 'y' then
download(info) return
end
download(info)
end)
return info return info
end end

View File

@@ -6527,7 +6527,7 @@ vim.bo.spf = vim.bo.spellfile
--- encoding is used, Vim doesn't check it. --- encoding is used, Vim doesn't check it.
--- How the related spell files are found is explained here: `spell-load`. --- 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 --- for which Vim cannot find the .spl file in 'runtimepath' the plugin
--- will ask you if you want to download the file. --- will ask you if you want to download the file.
--- ---

View File

@@ -1,16 +1,12 @@
if vim.g.loaded_spellfile_plugin ~= nil then
return
end
vim.g.loaded_spellfile_plugin = true 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)
end
vim.api.nvim_create_autocmd('SpellFileMissing', { vim.api.nvim_create_autocmd('SpellFileMissing', {
group = vim.api.nvim_create_augroup('nvim_spellfile', { clear = true }), group = vim.api.nvim_create_augroup('nvim.spellfile', {}),
pattern = '*',
desc = 'Download missing spell files when setting spelllang', desc = 'Download missing spell files when setting spelllang',
callback = on_spellfile_missing, callback = function(args)
require('nvim.spellfile').get(args.match)
end,
}) })

View File

@@ -415,6 +415,7 @@ local config = {
section_order = { section_order = {
'difftool.lua', 'difftool.lua',
'editorconfig.lua', 'editorconfig.lua',
'spellfile.lua',
'tohtml.lua', 'tohtml.lua',
'undotree.lua', 'undotree.lua',
}, },
@@ -423,6 +424,7 @@ local config = {
'runtime/lua/tohtml.lua', 'runtime/lua/tohtml.lua',
'runtime/pack/dist/opt/nvim.undotree/lua/undotree.lua', 'runtime/pack/dist/opt/nvim.undotree/lua/undotree.lua',
'runtime/pack/dist/opt/nvim.difftool/lua/difftool.lua', 'runtime/pack/dist/opt/nvim.difftool/lua/difftool.lua',
'runtime/lua/nvim/spellfile.lua',
}, },
fn_xform = function(fun) fn_xform = function(fun)
if fun.module == 'editorconfig' then if fun.module == 'editorconfig' then
@@ -430,11 +432,17 @@ local config = {
fun.table = true fun.table = true
fun.name = vim.split(fun.name, '.', { plain = true })[2] or fun.name fun.name = vim.split(fun.name, '.', { plain = true })[2] or fun.name
end end
if vim.startswith(fun.module, 'nvim.') then
fun.module = fun.module:sub(#'nvim.' + 1)
end
end, end,
section_fmt = function(name) section_fmt = function(name)
return 'Builtin plugin: ' .. name:lower() return 'Builtin plugin: ' .. name:lower()
end, end,
helptag_fmt = function(name) helptag_fmt = function(name)
if name:lower() == 'spellfile' then
name = 'spellfile.lua'
end
return name:lower() return name:lower()
end, end,
}, },

View File

@@ -8547,7 +8547,7 @@ local options = {
encoding is used, Vim doesn't check it. encoding is used, Vim doesn't check it.
How the related spell files are found is explained here: |spell-load|. 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 for which Vim cannot find the .spl file in 'runtimepath' the plugin
will ask you if you want to download the file. will ask you if you want to download the file.

View File

@@ -1610,7 +1610,7 @@ static void spell_load_lang(char *lang)
// Plugins aren't loaded yet, so nvim/spellfile.lua cannot handle this case. // Plugins aren't loaded yet, so nvim/spellfile.lua cannot handle this case.
char autocmd_buf[512] = { 0 }; char autocmd_buf[512] = { 0 };
snprintf(autocmd_buf, sizeof(autocmd_buf), 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); lang);
do_cmdline_cmd(autocmd_buf); do_cmdline_cmd(autocmd_buf);
} else { } else {

View File

@@ -42,7 +42,7 @@ describe('nvim.spellfile', function()
local requests = 0 local requests = 0
vim.net.request = function(...) requests = requests + 1 end vim.net.request = function(...) requests = requests + 1 end
s.load_file('en_gb') s.get('en_gb')
return { prompted = prompted, requests = requests } return { prompted = prompted, requests = requests }
]], ]],
@@ -88,7 +88,7 @@ describe('nvim.spellfile', function()
end end
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 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') 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 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 local done = s._done[info.key] == true
return { warns = warns, done = done, did_reload = did_reload } return { warns = warns, done = done, did_reload = did_reload }