spellfile.lua (8174B)
1 --- @brief 2 --- Asks the user to download missing spellfiles. The spellfile is written to 3 --- `stdpath('data') .. 'site/spell'` or the first writable directory in the 4 --- 'runtimepath'. 5 --- 6 --- The plugin can be disabled by setting `g:loaded_spellfile_plugin = 1`. 7 8 local M = {} 9 10 --- @class nvim.spellfile.Info 11 --- @inlinedoc 12 --- @field files string[] 13 --- @field key string 14 --- @field lang string 15 --- @field encoding string 16 --- @field dir string 17 18 --- A table with the following fields: 19 --- @class nvim.spellfile.Opts 20 --- 21 --- The base URL from where the spellfiles are downloaded. Uses `g:spellfile_URL` 22 --- if it's set, otherwise https://ftp.nluug.nl/pub/vim/runtime/spell. 23 --- @field url string 24 --- 25 --- Number of milliseconds after which the [vim.net.request()] times out. 26 --- (default: 15000) 27 --- @field timeout_ms integer 28 --- 29 --- Whether to ask user to confirm download. 30 --- (default: `true`) 31 --- @field confirm boolean 32 33 --- @type nvim.spellfile.Opts 34 local config = { 35 url = vim.g.spellfile_URL or 'https://ftp.nluug.nl/pub/vim/runtime/spell', 36 timeout_ms = 15000, 37 confirm = true, 38 } 39 40 --- Configure spellfile download options. For example: 41 --- ```lua 42 --- require('nvim.spellfile').config({ url = '...' }) 43 --- ``` 44 --- @param opts nvim.spellfile.Opts? When omitted or `nil`, retrieve the 45 --- current configuration. Otherwise, a configuration table. 46 --- @return nvim.spellfile.Opts? : Current config if {opts} is omitted. 47 function M.config(opts) 48 vim.validate('opts', opts, 'table', true) 49 if not opts then 50 return vim.deepcopy(config, true) 51 end 52 for k, v in 53 pairs(opts --[[@as table<any,any>]]) 54 do 55 config[k] = v 56 end 57 end 58 59 --- TODO(justinmk): add on_done/on_err callbacks to download(), instead of exposing this? 60 ---@type table<string, boolean> 61 M._done = {} 62 63 ---@return string[] 64 local function rtp_list() 65 return vim.opt.rtp:get() 66 end 67 68 ---@param msg string 69 ---@param level vim.log.levels? 70 local function notify(msg, level) 71 vim.notify(msg, level or vim.log.levels.INFO) 72 end 73 74 ---@param lang string 75 ---@return string 76 local function normalize_lang(lang) 77 local l = (lang or ''):lower():gsub('-', '_') 78 return (l:match('^[^,%s]+') or l) 79 end 80 81 ---@param path string 82 ---@return boolean 83 local function file_ok(path) 84 local s = vim.uv.fs_stat(path) 85 return s ~= nil and s.type == 'file' and (s.size or 0) > 0 86 end 87 88 ---@param dir string 89 ---@return boolean 90 local function can_use_dir(dir) 91 return not not (vim.fn.isdirectory(dir) == 1 and vim.uv.fs_access(dir, 'W')) 92 end 93 94 ---@return string[] 95 local function writable_spell_dirs_from_rtp() 96 local dirs = {} 97 for _, dir in ipairs(rtp_list()) do 98 local spell = vim.fs.joinpath(vim.fs.abspath(dir), 'spell') 99 if can_use_dir(spell) then 100 table.insert(dirs, spell) 101 end 102 end 103 return dirs 104 end 105 106 ---@return string? 107 local function ensure_target_dir() 108 local dir = vim.fs.abspath(vim.fs.joinpath(vim.fn.stdpath('data'), 'site/spell')) 109 if vim.fn.isdirectory(dir) == 0 and pcall(vim.fn.mkdir, dir, 'p') then 110 notify('Created ' .. dir) 111 end 112 if can_use_dir(dir) then 113 return dir 114 end 115 116 -- Else, look for a spell/ dir in 'runtimepath'. 117 local dirs = writable_spell_dirs_from_rtp() 118 if #dirs > 0 then 119 return dirs[1] 120 end 121 122 -- vim.fs.relpath does not prepend '~/' while fnamemodify does 123 dir = vim.fn.fnamemodify(dir, ':~') 124 error(('cannot find a writable spell/ dir in runtimepath, and %s is not usable'):format(dir)) 125 end 126 127 local function reload_spell_silent() 128 vim.cmd('silent! setlocal spell!') 129 if vim.bo.spelllang and vim.bo.spelllang ~= '' then 130 vim.cmd('silent! setlocal spelllang=' .. vim.bo.spelllang) 131 end 132 vim.cmd('echo ""') 133 end 134 135 --- Fetch file via blocking HTTP GET and write to `outpath`. 136 --- 137 --- Treats status==0 as success if file exists. 138 --- 139 --- @param url string 140 --- @param outpath string 141 --- @return boolean ok, integer|nil status, string|nil err 142 local function fetch_file_sync(url, outpath) 143 local done, err, res = false, nil, nil 144 vim.net.request(url, { outpath = outpath }, function(e, r) 145 err, res, done = e, r, true 146 end) 147 vim.wait(config.timeout_ms, function() 148 return done 149 end, 50, false) 150 151 local status = res and res.status or 0 152 local ok = (not err) and ((status >= 200 and status < 300) or (status == 0 and file_ok(outpath))) 153 return not not ok, (status ~= 0 and status or nil), err 154 end 155 156 ---@param lang string 157 ---@return nvim.spellfile.Info 158 local function parse(lang) 159 local code = normalize_lang(lang) 160 local enc = 'utf-8' 161 local dir = ensure_target_dir() 162 163 local missing = {} 164 local candidates = { 165 string.format('%s.%s.spl', code, enc), 166 string.format('%s.%s.sug', code, enc), 167 } 168 for _, fn in ipairs(candidates) do 169 if not file_ok(vim.fs.joinpath(dir, fn)) then 170 table.insert(missing, fn) 171 end 172 end 173 174 return { 175 files = missing, 176 key = code .. '.' .. enc, 177 lang = code, 178 encoding = enc, 179 dir = dir, 180 } 181 end 182 183 ---@param info nvim.spellfile.Info 184 local function download(info) 185 local dir = info.dir or ensure_target_dir() 186 if not dir then 187 notify('No (writable) spell directory found and could not create one.', vim.log.levels.ERROR) 188 return 189 end 190 191 local lang = info.lang 192 local enc = info.encoding 193 194 local spl_utf8 = string.format('%s.%s.spl', lang, enc) 195 local spl_ascii = string.format('%s.ascii.spl', lang) 196 local sug_name = string.format('%s.%s.sug', lang, enc) 197 198 local url_utf8 = config.url .. '/' .. spl_utf8 199 local out_utf8 = vim.fs.joinpath(dir, spl_utf8) 200 notify('Downloading ' .. spl_utf8 .. ' …') 201 local ok, st, err = fetch_file_sync(url_utf8, out_utf8) 202 if not ok then 203 notify( 204 ('Could not get %s (status %s): trying %s …'):format( 205 spl_utf8, 206 tostring(st or 'nil'), 207 spl_ascii 208 ) 209 ) 210 local url_ascii = config.url .. '/' .. spl_ascii 211 local out_ascii = vim.fs.joinpath(dir, spl_ascii) 212 local ok2, st2, err2 = fetch_file_sync(url_ascii, out_ascii) 213 if not ok2 then 214 notify( 215 ('No spell file available for %s (utf8:%s ascii:%s) — %s'):format( 216 lang, 217 tostring(st or err or 'fail'), 218 tostring(st2 or err2 or 'fail'), 219 url_utf8 220 ), 221 vim.log.levels.WARN 222 ) 223 vim.schedule(function() 224 vim.cmd('echo ""') 225 end) 226 M._done[info.key] = true 227 return 228 end 229 notify('Saved ' .. spl_ascii .. ' to ' .. out_ascii) 230 else 231 notify('Saved ' .. spl_utf8 .. ' to ' .. out_utf8) 232 end 233 234 reload_spell_silent() 235 236 if not file_ok(vim.fs.joinpath(dir, sug_name)) then 237 local url_sug = config.url .. '/' .. sug_name 238 local out_sug = vim.fs.joinpath(dir, sug_name) 239 notify('Downloading ' .. sug_name .. ' …') 240 local ok3, st3, err3 = fetch_file_sync(url_sug, out_sug) 241 if ok3 then 242 notify('Saved ' .. sug_name .. ' to ' .. out_sug) 243 else 244 local is404 = (st3 == 404) or (tostring(err3 or ''):match('%f[%d]404%f[%D]') ~= nil) 245 if is404 then 246 notify('Suggestion file not available: ' .. sug_name, vim.log.levels.DEBUG) 247 else 248 notify( 249 ('Failed to download %s (status %s): %s'):format( 250 sug_name, 251 tostring(st3 or 'nil'), 252 tostring(err3 or '') 253 ), 254 vim.log.levels.INFO 255 ) 256 end 257 vim.schedule(function() 258 vim.cmd('echo ""') 259 end) 260 end 261 end 262 263 M._done[info.key] = true 264 end 265 266 --- Download spellfiles for language {lang} if available. 267 --- @param lang string Language code. 268 --- @return nvim.spellfile.Info? 269 function M.get(lang) 270 local info = parse(lang) 271 if #info.files == 0 then 272 return 273 end 274 if M._done[info.key] then 275 notify('Already attempted spell load for ' .. lang, vim.log.levels.DEBUG) 276 return 277 end 278 279 if config.confirm then 280 local prompt = ('No spell file found for %s (%s). Download? [y/N] '):format( 281 info.lang, 282 info.encoding 283 ) 284 vim.ui.input({ prompt = prompt }, function(input) 285 -- properly clear the message window 286 vim.api.nvim_echo({ { ' ' } }, false, { kind = 'empty' }) 287 if not input or input:lower() ~= 'y' then 288 return 289 end 290 download(info) 291 end) 292 else 293 download(info) 294 end 295 296 return info 297 end 298 299 return M