summaryrefslogtreecommitdiffstatshomepage
path: root/runtime/lua/nvim/spellfile.lua
blob: f964d78a2f1d18b4ae77677db4edb9aeb201aac6 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
--- @brief
--- Asks the user to download missing spellfiles. The spellfile is written to
--- `stdpath('data') .. 'site/spell'` or the first writable directory in the
--- 'runtimepath'.
---
--- The plugin can be disabled by setting `g:loaded_spellfile_plugin = 1`.

local M = {}

--- @class nvim.spellfile.Info
--- @inlinedoc
--- @field files string[]
--- @field key string
--- @field lang string
--- @field encoding string
--- @field dir string

--- A table with the following fields:
--- @class nvim.spellfile.Opts
---
--- The base URL from where the spellfiles are downloaded. Uses `g:spellfile_URL`
--- if it's set, otherwise https://ftp.nluug.nl/pub/vim/runtime/spell.
--- @field url string
---
--- Number of milliseconds after which the [vim.net.request()] times out.
--- (default: 15000)
--- @field timeout_ms integer
---
--- Whether to ask user to confirm download.
--- (default: `true`)
--- @field confirm boolean

--- @type nvim.spellfile.Opts
local config = {
  url = vim.g.spellfile_URL or 'https://ftp.nluug.nl/pub/vim/runtime/spell',
  timeout_ms = 15000,
  confirm = true,
}

--- Configure spellfile download options. For example:
--- ```lua
--- require('nvim.spellfile').config({ url = '...' })
--- ```
--- @param opts nvim.spellfile.Opts? When omitted or `nil`, retrieve the
---   current configuration. Otherwise, a configuration table.
--- @return nvim.spellfile.Opts? : Current config if {opts} is omitted.
function M.config(opts)
  vim.validate('opts', opts, 'table', true)
  if not opts then
    return vim.deepcopy(config, true)
  end
  for k, v in
    pairs(opts --[[@as table<any,any>]])
  do
    config[k] = v
  end
end

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

---@param msg string
---@param level vim.log.levels?
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

---@param path string
---@return boolean
local function file_ok(path)
  local s = vim.uv.fs_stat(path)
  return s ~= nil and s.type == 'file' and (s.size or 0) > 0
end

---@param dir string
---@return boolean
local function can_use_dir(dir)
  return not not (vim.fn.isdirectory(dir) == 1 and vim.uv.fs_access(dir, 'W'))
end

---@return string[]
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

---@return string?
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

  -- vim.fs.relpath does not prepend '~/' while fnamemodify does
  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.
---
--- @param url string
--- @param outpath string
--- @return boolean ok, integer|nil status, string|nil err
local function fetch_file_sync(url, outpath)
  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(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

---@param lang string
---@return nvim.spellfile.Info
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 nvim.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 = 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)
  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 = config.url .. '/' .. spl_ascii
    local out_ascii = vim.fs.joinpath(dir, spl_ascii)
    local ok2, st2, err2 = fetch_file_sync(url_ascii, out_ascii)
    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 = 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)
    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

--- Download spellfiles for language {lang} if available.
--- @param lang string Language code.
--- @return nvim.spellfile.Info?
function M.get(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

  if config.confirm then
    local prompt = ('No spell file found for %s (%s). Download? [y/N] '):format(
      info.lang,
      info.encoding
    )
    vim.ui.input({ prompt = prompt }, function(input)
      -- properly clear the message window
      vim.api.nvim_echo({ { ' ' } }, false, { kind = 'empty' })
      if not input or input:lower() ~= 'y' then
        return
      end
      download(info)
    end)
  else
    download(info)
  end

  return info
end

return M