diff options
| author | Tom Ampuero <tom.ampuero.c@gmail.com> | 2025-08-08 16:31:57 +0100 |
|---|---|---|
| committer | Justin M. Keyes <justinkz@gmail.com> | 2025-10-26 00:33:41 +0200 |
| commit | 7c5ff99e8a0ef7c345596df8a0c2eec80afd16b4 (patch) | |
| tree | a10425e21679048f8c5130ba28bdd918ce1bd0c1 /runtime/lua/nvim/spellfile.lua | |
| parent | 5db35449917de353fb8e35cbd4edca381233f381 (diff) | |
| download | rneovim-7c5ff99e8a0ef7c345596df8a0c2eec80afd16b4.tar.gz rneovim-7c5ff99e8a0ef7c345596df8a0c2eec80afd16b4.tar.bz2 rneovim-7c5ff99e8a0ef7c345596df8a0c2eec80afd16b4.zip | |
refactor(spell): migrate to Lua, drop netrw dependency
Problem:
Spell file downloads relied on Vimscript and netrw (:Nread). If netrw is
disabled, downloads fail.
Solution:
Port the logic to Lua as `nvim.spellfile` and wire it via a Lua plugin that
handles `SpellFileMissing`. Use `vim.net.request()` with a timeout for HTTP,
prompt via `vim.fn.input` and report via `vim.notify`.
Closes #7189
Diffstat (limited to 'runtime/lua/nvim/spellfile.lua')
| -rw-r--r-- | runtime/lua/nvim/spellfile.lua | 278 |
1 files changed, 278 insertions, 0 deletions
diff --git a/runtime/lua/nvim/spellfile.lua b/runtime/lua/nvim/spellfile.lua new file mode 100644 index 0000000000..51c7a90f84 --- /dev/null +++ b/runtime/lua/nvim/spellfile.lua @@ -0,0 +1,278 @@ +local M = {} + +--- @class SpellfileConfig +--- @field url string +--- @field timeout_ms integer + +---@class SpellInfo +---@field files string[] +---@field key string +---@field lang string +---@field encoding string +---@field dir string + +---@type SpellfileConfig +M.config = { + url = 'https://ftp.nluug.nl/pub/vim/runtime/spell', + timeout_ms = 15000, +} + +---@type table<string, boolean> +M._done = {} + +---@return string[] +local function rtp_list() + return vim.opt.rtp:get() +end + +function M.isDone(key) + return M._done[key] +end + +function M.setup(opts) + M._done = {} + M.config = vim.tbl_extend('force', M.config, opts or {}) +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 writable_spell_dirs_from_rtp() + local dirs = {} + for _, dir in ipairs(rtp_list()) do + local spell = vim.fs.joinpath(vim.fn.fnamemodify(dir, ':p'), 'spell') + if vim.fn.isdirectory(spell) == 1 and vim.uv.fs_access(spell, 'W') then + table.insert(dirs, spell) + end + end + return dirs +end + +local function default_spell_dir() + return vim.fs.joinpath(vim.fn.stdpath('data'), 'site', 'spell') +end + +local function ensure_target_dir() + local dirs = writable_spell_dirs_from_rtp() + if #dirs > 0 then + return dirs[1] + end + local target = default_spell_dir() + if vim.fn.isdirectory(target) ~= 1 then + vim.fn.mkdir(target, 'p') + notify('Created ' .. target) + end + return target +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 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 + +--- Blocking GET to file with timeout; treats status==0 as success if file exists. +--- @return boolean ok, integer|nil status, string|nil err +local function http_get_to_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 ok, (status ~= 0 and status or nil), err +end + +---@return string[] +function M.directory_choices() + local opts = {} + for _, dir in ipairs(rtp_list()) do + local spelldir = vim.fs.joinpath(vim.fn.fnamemodify(dir, ':p'), 'spell') + if vim.fn.isdirectory(spelldir) == 1 then + table.insert(opts, spelldir) + end + end + return opts +end + +function M.choose_directory() + local dirs = writable_spell_dirs_from_rtp() + if #dirs == 0 then + return ensure_target_dir() + elseif #dirs == 1 then + return dirs[1] + end + local prompt ---@type string[] + prompt = {} + for i, d in + ipairs(dirs --[[@as string[] ]]) + do + prompt[i] = string.format('%d: %s', i, d) + end + local choice = vim.fn.inputlist(prompt) + if choice < 1 or choice > #dirs then + return dirs[1] + end + return dirs[choice] +end + +function M.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 SpellInfo +function M.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 = http_get_to_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 = http_get_to_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 = http_get_to_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 = M.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 + + M.download(info) +end + +function M.exists(filename) + local stat = (vim.uv or vim.loop).fs_stat + for _, dir in ipairs(M.directory_choices()) do + local p = vim.fs.joinpath(dir, filename) + local s = stat(p) + if s and s.type == 'file' then + return true + end + end + return false +end + +return M |