aboutsummaryrefslogtreecommitdiff
path: root/runtime/lua/nvim/spellfile.lua
diff options
context:
space:
mode:
authorTom Ampuero <tom.ampuero.c@gmail.com>2025-08-08 16:31:57 +0100
committerJustin M. Keyes <justinkz@gmail.com>2025-10-26 00:33:41 +0200
commit7c5ff99e8a0ef7c345596df8a0c2eec80afd16b4 (patch)
treea10425e21679048f8c5130ba28bdd918ce1bd0c1 /runtime/lua/nvim/spellfile.lua
parent5db35449917de353fb8e35cbd4edca381233f381 (diff)
downloadrneovim-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.lua278
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