From 9bdb011a50f25d80980750ca194efd0d18cae56c Mon Sep 17 00:00:00 2001 From: Yochem van Rosmalen Date: Mon, 10 Nov 2025 06:51:39 +0100 Subject: [PATCH] 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|. --- runtime/doc/options.txt | 2 +- runtime/doc/plugins.txt | 52 +++++++++- runtime/doc/spell.txt | 47 +-------- runtime/doc/vim_diff.txt | 3 + runtime/lua/nvim/spellfile.lua | 111 ++++++++++++++++------ runtime/lua/vim/_meta/options.lua | 2 +- runtime/plugin/nvim/spellfile.lua | 18 ++-- src/gen/gen_vimdoc.lua | 8 ++ src/nvim/options.lua | 2 +- src/nvim/spell.c | 2 +- test/functional/plugin/spellfile_spec.lua | 6 +- 11 files changed, 160 insertions(+), 93 deletions(-) diff --git a/runtime/doc/options.txt b/runtime/doc/options.txt index 258398ed90..e48d759f6a 100644 --- 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 index 8c5b707c60..e0166be815 100644 --- 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 @@ -166,6 +166,54 @@ trim_trailing_whitespace *editorconfig.trim_trailing_whitespace* 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* diff --git a/runtime/doc/spell.txt b/runtime/doc/spell.txt index 97845e32ac..59dc794484 100644 --- 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* +SPELL FILE MISSING *spell-SpellFileMissing* -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). +If a spell file is missing, the user is asked whether to download it. See +|spellfile.lua|. -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('')) - -Thus the 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* 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 index 2b486c40ca..11e4500310 100644 --- 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 index 604ca1b0b6..63882c37a3 100644 --- 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]]) + do + config[k] = v + end +end + --- TODO(justinmk): add on_done/on_err callbacks to download(), instead of exposing this? ---@type table 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 index b541a22c7e..6f08f53c72 100644 --- 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 index 9d8bc44511..0c16d95de8 100644 --- a/runtime/plugin/nvim/spellfile.lua +++ b/runtime/plugin/nvim/spellfile.lua @@ -1,16 +1,12 @@ +if vim.g.loaded_spellfile_plugin ~= nil then + return +end 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', { - 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 index cf02b8af0b..e4e2af7b0b 100755 --- 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 index 58975ff992..23057e0e0b 100644 --- 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 index a7a1ad342d..c193f107a2 100644 --- 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 index c891028deb..11cff25437 100644 --- 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 }