commit 07461bac27962345ec46feea2e6200f4f82afce7
parent a121ede1bfee2704c26159124f8f61f96c6aa136
Author: Justin M. Keyes <justinkz@gmail.com>
Date: Sat, 25 Oct 2025 20:08:02 -0400
Merge #34940 refactor(spell): migrate to Lua, drop netrw dep
Diffstat:
6 files changed, 406 insertions(+), 212 deletions(-)
diff --git a/runtime/autoload/spellfile.vim b/runtime/autoload/spellfile.vim
@@ -1,203 +0,0 @@
-" Vim script to download a missing spell file
-
-if !exists('g:spellfile_URL')
- " Always use https:// because it's secure. The certificate is for nluug.nl,
- " thus we can't use the alias ftp.vim.org here.
- let g:spellfile_URL = 'https://ftp.nluug.nl/pub/vim/runtime/spell'
-endif
-let s:spellfile_URL = '' " Start with nothing so that s:donedict is reset.
-
-" This function is used for the spellfile plugin.
-function! spellfile#LoadFile(lang)
- " Check for sandbox/modeline. #11359
- try
- :!
- catch /\<E12\>/
- throw 'Cannot download spellfile in sandbox/modeline. Try ":set spell" from the cmdline.'
- endtry
-
- " If the netrw plugin isn't loaded we silently skip everything.
- if !exists(":Nread")
- if &verbose
- echomsg 'spellfile#LoadFile(): Nread command is not available.'
- endif
- return
- endif
- let lang = tolower(a:lang)
-
- " If the URL changes we try all files again.
- if s:spellfile_URL != g:spellfile_URL
- let s:donedict = {}
- let s:spellfile_URL = g:spellfile_URL
- endif
-
- " I will say this only once!
- if has_key(s:donedict, lang . &enc)
- if &verbose
- echomsg 'spellfile#LoadFile(): Tried this language/encoding before.'
- endif
- return
- endif
- let s:donedict[lang . &enc] = 1
-
- " Find spell directories we can write in.
- let [dirlist, dirchoices] = spellfile#GetDirChoices()
- if len(dirlist) == 0
- let dir_to_create = spellfile#WritableSpellDir()
- if &verbose || dir_to_create != ''
- echomsg 'spellfile#LoadFile(): No (writable) spell directory found.'
- endif
- if dir_to_create != ''
- call mkdir(dir_to_create, "p")
- " Now it should show up in the list.
- let [dirlist, dirchoices] = spellfile#GetDirChoices()
- endif
- if len(dirlist) == 0
- echomsg 'Failed to create: '.dir_to_create
- return
- else
- echomsg 'Created '.dir_to_create
- endif
- endif
-
- let msg = 'No spell file for "' . a:lang . '" in ' . &enc
- let msg .= "\nDownload it?"
- if confirm(msg, "&Yes\n&No", 2) == 1
- let enc = &encoding
- if enc == 'iso-8859-15'
- let enc = 'latin1'
- endif
- let fname = a:lang . '.' . enc . '.spl'
-
- " Split the window, read the file into a new buffer.
- " Remember the buffer number, we check it below.
- new
- let newbufnr = winbufnr(0)
- setlocal bin fenc=
- echo 'Downloading ' . fname . '...'
- call spellfile#Nread(fname)
- if getline(2) !~ 'VIMspell'
- " Didn't work, perhaps there is an ASCII one.
- " Careful: Nread() may have opened a new window for the error message,
- " we need to go back to our own buffer and window.
- if newbufnr != winbufnr(0)
- let winnr = bufwinnr(newbufnr)
- if winnr == -1
- " Our buffer has vanished!? Open a new window.
- echomsg "download buffer disappeared, opening a new one"
- new
- setlocal bin fenc=
- else
- exe winnr . "wincmd w"
- endif
- endif
- if newbufnr == winbufnr(0)
- " We are back to the old buffer, remove any (half-finished) download.
- keeppatterns g/^/d_
- else
- let newbufnr = winbufnr(0)
- endif
-
- let fname = lang . '.ascii.spl'
- echo 'Could not find it, trying ' . fname . '...'
- call spellfile#Nread(fname)
- if getline(2) !~ 'VIMspell'
- echo 'Download failed'
- exe newbufnr . "bwipe!"
- return
- endif
- endif
-
- " Delete the empty first line and mark the file unmodified.
- 1d_
- set nomod
-
- if len(dirlist) == 1
- let dirchoice = 0
- else
- let msg = "In which directory do you want to write the file:"
- for i in range(len(dirlist))
- let msg .= "\n" . (i + 1) . '. ' . dirlist[i]
- endfor
- let dirchoice = confirm(msg, dirchoices) - 2
- endif
- if dirchoice >= 0
- if exists('*fnameescape')
- let dirname = fnameescape(dirlist[dirchoice])
- else
- let dirname = escape(dirlist[dirchoice], ' ')
- endif
- setlocal fenc=
- exe "write " . dirname . '/' . fname
-
- " Also download the .sug file.
- keeppatterns g/^/d_
- let fname = substitute(fname, '\.spl$', '.sug', '')
- echo 'Downloading ' . fname . '...'
- call spellfile#Nread(fname)
- if getline(2) =~ 'VIMsug'
- 1d_
- exe "write " . dirname . '/' . fname
- set nomod
- else
- echo 'Download failed'
- " Go back to our own buffer/window, Nread() may have taken us to
- " another window.
- if newbufnr != winbufnr(0)
- let winnr = bufwinnr(newbufnr)
- if winnr != -1
- exe winnr . "wincmd w"
- endif
- endif
- if newbufnr == winbufnr(0)
- set nomod
- endif
- endif
- endif
-
- " Wipe out the buffer we used.
- exe newbufnr . "bwipe"
- endif
-endfunc
-
-" Read "fname" from the server.
-function! spellfile#Nread(fname)
- " We do our own error handling, don't want a window for it.
- if exists("g:netrw_use_errorwindow")
- let save_ew = g:netrw_use_errorwindow
- endif
- let g:netrw_use_errorwindow=0
-
- if g:spellfile_URL =~ '^ftp://'
- " for an ftp server use a default login and password to avoid a prompt
- let machine = substitute(g:spellfile_URL, 'ftp://\([^/]*\).*', '\1', '')
- let dir = substitute(g:spellfile_URL, 'ftp://[^/]*/\(.*\)', '\1', '')
- exe 'Nread "' . machine . ' anonymous vim7user ' . dir . '/' . a:fname . '"'
- else
- exe 'Nread ' g:spellfile_URL . '/' . a:fname
- endif
-
- if exists("save_ew")
- let g:netrw_use_errorwindow = save_ew
- else
- unlet g:netrw_use_errorwindow
- endif
-endfunc
-
-" Get a list of writable spell directories and choices for confirm().
-function! spellfile#GetDirChoices()
- let dirlist = []
- let dirchoices = '&Cancel'
- for dir in split(globpath(&rtp, 'spell'), "\n")
- if filewritable(dir) == 2
- call add(dirlist, dir)
- let dirchoices .= "\n&" . len(dirlist)
- endif
- endfor
- return [dirlist, dirchoices]
-endfunc
-
-function! spellfile#WritableSpellDir()
- " Always use the $XDG_DATA_HOME/…/site directory
- return stdpath('data').'/site/spell'
-endfunction
diff --git a/runtime/lua/nvim/spellfile.lua b/runtime/lua/nvim/spellfile.lua
@@ -0,0 +1,236 @@
+local M = {}
+
+--- @class vim.spellfile.Config
+--- @field url string
+--- @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',
+ timeout_ms = 15000,
+}
+
+--- TODO(justinmk): add on_done/on_err callbacks to download(), instead of exposing this?
+---@type table<string, boolean>
+M._done = {}
+
+---@return string[]
+local function rtp_list()
+ return vim.opt.rtp:get()
+end
+
+local function notify(msg, level)
+ vim.notify(msg, level or vim.log.levels.INFO)
+end
+
+---@param lang string
+---@return string
+local function normalize_lang(lang)
+ local l = (lang or ''):lower():gsub('-', '_')
+ return (l:match('^[^,%s]+') or l)
+end
+
+local function file_ok(path)
+ local s = vim.uv.fs_stat(path)
+ return s and s.type == 'file' and (s.size or 0) > 0
+end
+
+local function can_use_dir(dir)
+ return not not (vim.fn.isdirectory(dir) == 1 and vim.uv.fs_access(dir, 'W'))
+end
+
+local function writable_spell_dirs_from_rtp()
+ local dirs = {}
+ for _, dir in ipairs(rtp_list()) do
+ local spell = vim.fs.joinpath(vim.fs.abspath(dir), 'spell')
+ if can_use_dir(spell) then
+ table.insert(dirs, spell)
+ end
+ end
+ return dirs
+end
+
+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
+ notify('Created ' .. dir)
+ end
+ if can_use_dir(dir) then
+ return dir
+ end
+
+ -- Else, look for a spell/ dir in 'runtimepath'.
+ local dirs = writable_spell_dirs_from_rtp()
+ if #dirs > 0 then
+ return dirs[1]
+ end
+
+ dir = vim.fn.fnamemodify(dir, ':~')
+ error(('cannot find a writable spell/ dir in runtimepath, and %s is not usable'):format(dir))
+end
+
+local function reload_spell_silent()
+ vim.cmd('silent! setlocal spell!')
+ if vim.bo.spelllang and vim.bo.spelllang ~= '' then
+ vim.cmd('silent! setlocal spelllang=' .. vim.bo.spelllang)
+ end
+ vim.cmd('echo ""')
+end
+
+--- Fetch file via blocking HTTP GET and write to `outpath`.
+---
+--- Treats status==0 as success if file exists.
+---
+--- @return boolean ok, integer|nil status, string|nil err
+local function fetch_file_sync(url, outpath, timeout_ms)
+ 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()
+ return done
+ end, 50, false)
+
+ local status = res and res.status or 0
+ local ok = (not err) and ((status >= 200 and status < 300) or (status == 0 and file_ok(outpath)))
+ return not not ok, (status ~= 0 and status or nil), err
+end
+
+local function parse(lang)
+ local code = normalize_lang(lang)
+ local enc = 'utf-8'
+ local dir = ensure_target_dir()
+
+ local missing = {}
+ local candidates = {
+ string.format('%s.%s.spl', code, enc),
+ string.format('%s.%s.sug', code, enc),
+ }
+ for _, fn in ipairs(candidates) do
+ if not file_ok(vim.fs.joinpath(dir, fn)) then
+ table.insert(missing, fn)
+ end
+ end
+
+ return {
+ files = missing,
+ key = code .. '.' .. enc,
+ lang = code,
+ encoding = enc,
+ dir = dir,
+ }
+end
+
+---@param info vim.spellfile.Info
+local function download(info)
+ local dir = info.dir or ensure_target_dir()
+ if not dir then
+ notify('No (writable) spell directory found and could not create one.', vim.log.levels.ERROR)
+ return
+ end
+
+ local lang = info.lang
+ local enc = info.encoding
+
+ local spl_utf8 = string.format('%s.%s.spl', lang, enc)
+ 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 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)
+ if not ok then
+ notify(
+ ('Could not get %s (status %s): trying %s …'):format(
+ spl_utf8,
+ tostring(st or 'nil'),
+ spl_ascii
+ )
+ )
+ local url_ascii = M.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)
+ if not ok2 then
+ notify(
+ ('No spell file available for %s (utf8:%s ascii:%s) — %s'):format(
+ lang,
+ tostring(st or err or 'fail'),
+ tostring(st2 or err2 or 'fail'),
+ url_utf8
+ ),
+ vim.log.levels.WARN
+ )
+ vim.schedule(function()
+ vim.cmd('echo ""')
+ end)
+ M._done[info.key] = true
+ return
+ end
+ notify('Saved ' .. spl_ascii .. ' to ' .. out_ascii)
+ else
+ notify('Saved ' .. spl_utf8 .. ' to ' .. out_utf8)
+ end
+
+ reload_spell_silent()
+
+ if not file_ok(vim.fs.joinpath(dir, sug_name)) then
+ local url_sug = M.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)
+ if ok3 then
+ notify('Saved ' .. sug_name .. ' to ' .. out_sug)
+ else
+ local is404 = (st3 == 404) or (tostring(err3 or ''):match('%f[%d]404%f[%D]') ~= nil)
+ if is404 then
+ notify('Suggestion file not available: ' .. sug_name, vim.log.levels.DEBUG)
+ else
+ notify(
+ ('Failed to download %s (status %s): %s'):format(
+ sug_name,
+ tostring(st3 or 'nil'),
+ tostring(err3 or '')
+ ),
+ vim.log.levels.INFO
+ )
+ end
+ vim.schedule(function()
+ vim.cmd('echo ""')
+ end)
+ end
+ end
+
+ M._done[info.key] = true
+end
+
+function M.load_file(lang)
+ local info = parse(lang)
+ if #info.files == 0 then
+ return
+ end
+ if M._done[info.key] then
+ notify('Already attempted spell load for ' .. lang, vim.log.levels.DEBUG)
+ return
+ end
+
+ local answer = vim.fn.input(
+ string.format('No spell file found for %s (%s). Download? [y/N] ', info.lang, info.encoding)
+ )
+ if (answer or ''):lower() ~= 'y' then
+ return
+ end
+
+ download(info)
+
+ return info
+end
+
+return M
diff --git a/runtime/plugin/nvim/spellfile.lua b/runtime/plugin/nvim/spellfile.lua
@@ -0,0 +1,16 @@
+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 = '*',
+ desc = 'Download missing spell files when setting spelllang',
+ callback = on_spellfile_missing,
+})
diff --git a/runtime/plugin/spellfile.vim b/runtime/plugin/spellfile.vim
@@ -1,8 +0,0 @@
-" Vim plugin for downloading spell files
-
-if exists("loaded_spellfile_plugin") || &cp || exists("#SpellFileMissing")
- finish
-endif
-let loaded_spellfile_plugin = 1
-
-autocmd SpellFileMissing * call spellfile#LoadFile(expand('<amatch>'))
diff --git a/test/functional/plugin/spellfile_spec.lua b/test/functional/plugin/spellfile_spec.lua
@@ -0,0 +1,151 @@
+local n = require('test.functional.testnvim')()
+local t = require('test.testutil')
+
+local eq = t.eq
+local exec_lua = n.exec_lua
+
+describe('nvim.spellfile', function()
+ local data_root = 'Xtest_data'
+ local rtp_dir = 'Xtest_rtp'
+
+ before_each(function()
+ n.clear()
+ n.exec('set runtimepath+=' .. rtp_dir)
+ end)
+ after_each(function()
+ n.rmdir(data_root)
+ n.rmdir(rtp_dir)
+ end)
+
+ it('no-op when .spl and .sug already exist on runtimepath', function()
+ local my_spell = vim.fs.joinpath(vim.fs.abspath(rtp_dir), 'spell')
+ n.mkdir_p(my_spell)
+ t.retry(nil, nil, function()
+ assert(vim.uv.fs_stat(my_spell))
+ end)
+ t.write_file(my_spell .. '/en_gb.utf-8.spl', 'dummy')
+ t.write_file(my_spell .. '/en_gb.utf-8.sug', 'dummy')
+
+ local out = exec_lua(
+ [[
+ local rtp_dir = ...
+ local s = require('nvim.spellfile')
+ local my_spell = vim.fs.joinpath(vim.fs.abspath(rtp_dir), 'spell')
+
+ vim.uv.fs_access = function(p, mode)
+ return p == my_spell
+ end
+
+ local prompted = false
+ vim.fn.input = function() prompted = true; return 'n' end
+
+ local requests = 0
+ vim.net.request = function(...) requests = requests + 1 end
+
+ s.load_file('en_gb')
+
+ return { prompted = prompted, requests = requests }
+ ]],
+ rtp_dir
+ )
+
+ eq(false, out.prompted)
+ eq(0, out.requests)
+ end)
+
+ it('downloads .spl to stdpath(data)/site/spell, .sug 404 is non-fatal, reloads', function()
+ n.mkdir_p(rtp_dir)
+
+ local out = exec_lua(
+ [[
+ local data_root = ...
+ local s = require('nvim.spellfile')
+
+ vim.fn.stdpath = function(k)
+ assert(k == 'data')
+ return data_root
+ end
+
+ vim.fn.input = function() return 'y' end
+
+ local did_reload = false
+ local orig_cmd = vim.cmd
+ vim.cmd = function(cmd)
+ if cmd:match('setlocal%s+spell!') then
+ did_reload = true
+ end
+ return orig_cmd(cmd)
+ end
+
+ vim.net.request = function(url, opts, cb)
+ local name = url:match('/([^/]+)$')
+ if name and name:find('%.spl$') then
+ vim.fn.mkdir(vim.fs.dirname(opts.outpath), 'p')
+ vim.fn.writefile({'ok'}, opts.outpath)
+ cb(nil, { status = 200 })
+ else
+ cb(nil, { status = 404 })
+ end
+ end
+
+ s.load_file('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')
+
+ return {
+ has_spl = vim.uv.fs_stat(spl) ~= nil,
+ has_sug = vim.uv.fs_stat(sug) ~= nil,
+ did_reload = did_reload,
+ }
+ ]],
+ data_root
+ )
+
+ eq(true, out.has_spl)
+ eq(false, out.has_sug)
+ eq(true, out.did_reload)
+ end)
+
+ it('failure mode: 404 for all files => warn once, mark done, no reload', function()
+ local out = exec_lua(
+ [[
+ local data_root = ...
+ local s = require('nvim.spellfile')
+
+ vim.fn.stdpath = function(k)
+ assert(k == 'data')
+ return data_root
+ end
+
+ vim.fn.input = function() return 'y' end
+
+ local warns = 0
+ vim.notify = function(_, lvl)
+ if lvl and lvl >= vim.log.levels.WARN then warns = warns + 1 end
+ end
+
+ local did_reload = false
+ local orig_cmd = vim.cmd
+ vim.cmd = function(c)
+ if c:match('setlocal%s+spell!') then
+ did_reload = true
+ end
+ return orig_cmd(c)
+ end
+
+ vim.net.request = function(_, _, cb) cb(nil, { status = 404 }) end
+
+ local info = s.load_file('zz')
+ local done = s._done[info.key] == true
+
+ return { warns = warns, done = done, did_reload = did_reload }
+ ]],
+ data_root
+ )
+
+ eq(1, out.warns)
+ eq(true, out.done)
+ eq(false, out.did_reload)
+ end)
+end)
diff --git a/test/functional/testnvim.lua b/test/functional/testnvim.lua
@@ -1017,7 +1017,9 @@ end
--- @param path string
--- @return boolean?
function M.mkdir_p(path)
- return os.execute((is_os('win') and 'mkdir ' .. path or 'mkdir -p ' .. path))
+ return os.execute(
+ (is_os('win') and 'mkdir ' .. string.gsub(path, '/', '\\') or 'mkdir -p ' .. path)
+ )
end
local testid = (function()