Merge #34940 refactor(spell): migrate to Lua, drop netrw dep

This commit is contained in:
Justin M. Keyes
2025-10-25 20:08:02 -04:00
committed by GitHub
6 changed files with 406 additions and 212 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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,
})

View File

@@ -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>'))

View File

@@ -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)

View File

@@ -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()