neovim

Neovim text editor
git clone https://git.dasho.dev/neovim.git
Log | Files | Refs | README

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