neovim

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

health.lua (16896B)


      1 --- @brief
      2 ---
      3 --- vim.health is a minimal framework to help users troubleshoot configuration and any other
      4 --- environment conditions that a plugin might care about. Nvim ships with healthchecks for
      5 --- configuration, performance, python support, ruby support, clipboard support, and more.
      6 ---
      7 --- To run all healthchecks, use:
      8 --- ```vim
      9 --- :checkhealth
     10 --- ```
     11 --- Plugin authors are encouraged to write new healthchecks. |health-dev|
     12 ---
     13 ---<pre>help
     14 --- COMMANDS                                *health-commands*
     15 ---
     16 ---                                                              *:che* *:checkhealth*
     17 --- :che[ckhealth]  Run all healthchecks.
     18 ---                                         *E5009*
     19 ---                 Nvim depends on |$VIMRUNTIME|, 'runtimepath' and 'packpath' to
     20 ---                 find the standard "runtime files" for syntax highlighting,
     21 ---                 filetype-specific behavior, and standard plugins (including
     22 ---                 :checkhealth).  If the runtime files cannot be found then
     23 ---                 those features will not work.
     24 ---
     25 --- :che[ckhealth] {plugins}
     26 ---                 Run healthcheck(s) for one or more plugins. E.g. to run only
     27 ---                 the standard Nvim healthcheck: >vim
     28 ---                         :checkhealth vim.health
     29 --- <
     30 ---                 To run the healthchecks for the "foo" and "bar" plugins
     31 ---                 (assuming they are on 'runtimepath' and they have implemented
     32 ---                 the Lua `require("foo.health").check()` interface): >vim
     33 ---                         :checkhealth foo bar
     34 --- <
     35 ---                 To run healthchecks for Lua submodules, use dot notation or
     36 ---                 "*" to refer to all submodules. For example Nvim provides
     37 ---                 `vim.lsp` and `vim.treesitter`:  >vim
     38 ---                         :checkhealth vim.lsp vim.treesitter
     39 ---                         :checkhealth vim*
     40 --- <
     41 ---
     42 --- USAGE                                                        *health-usage*
     43 ---
     44 --- Local mappings in the healthcheck buffer:
     45 ---
     46 --- q               Closes the window.
     47 ---
     48 --- Global configuration:
     49 ---                                                              *g:health*
     50 --- g:health  Dictionary with the following optional keys:
     51 ---           - `style` (`'float'|nil`) Set to "float" to display :checkhealth in
     52 ---           a floating window instead of the default behavior.
     53 ---
     54 ---           Example: >lua
     55 ---             vim.g.health = { style = 'float' }
     56 ---
     57 ---</pre>
     58 ---
     59 --- Local configuration:
     60 ---
     61 --- Checkhealth sets its buffer filetype to "checkhealth". You can customize the buffer by handling
     62 --- the |FileType| event. For example if you don't want emojis in the health report:
     63 --- ```vim
     64 --- autocmd FileType checkhealth :set modifiable | silent! %s/\v( ?[^\x00-\x7F])//g
     65 --- ```
     66 ---
     67 ---<pre>help
     68 --- --------------------------------------------------------------------------------
     69 --- Create a healthcheck                                    *health-dev*
     70 ---</pre>
     71 ---
     72 --- Healthchecks are functions that check the user environment, configuration, or any other
     73 --- prerequisites that a plugin cares about. Nvim ships with healthchecks in:
     74 --- - $VIMRUNTIME/autoload/health/
     75 --- - $VIMRUNTIME/lua/vim/lsp/health.lua
     76 --- - $VIMRUNTIME/lua/vim/treesitter/health.lua
     77 --- - and more...
     78 ---
     79 --- To add a new healthcheck for your own plugin, simply create a "health.lua" module on
     80 --- 'runtimepath' that returns a table with a "check()" function. Then |:checkhealth| will
     81 --- automatically find and invoke the function.
     82 ---
     83 --- For example if your plugin is named "foo", define your healthcheck module at
     84 --- one of these locations (on 'runtimepath'):
     85 --- - lua/foo/health/init.lua
     86 --- - lua/foo/health.lua
     87 ---
     88 --- If your plugin also provides a submodule named "bar" for which you want a separate healthcheck,
     89 --- define the healthcheck at one of these locations:
     90 --- - lua/foo/bar/health/init.lua
     91 --- - lua/foo/bar/health.lua
     92 ---
     93 --- All such health modules must return a Lua table containing a `check()` function.
     94 ---
     95 --- Copy this sample code into `lua/foo/health.lua`, replacing "foo" in the path with your plugin
     96 --- name:
     97 ---
     98 --- ```lua
     99 --- local M = {}
    100 ---
    101 --- M.check = function()
    102 ---   vim.health.start("foo report")
    103 ---   -- make sure setup function parameters are ok
    104 ---   if check_setup() then
    105 ---     vim.health.ok("Setup is correct")
    106 ---   else
    107 ---     vim.health.error("Setup is incorrect")
    108 ---   end
    109 ---   -- do some more checking
    110 ---   -- ...
    111 --- end
    112 ---
    113 --- return M
    114 --- ```
    115 
    116 local M = {}
    117 
    118 local s_output = {} ---@type string[]
    119 local check_summary = { warn = 0, error = 0 }
    120 
    121 -- From a path return a list [{name}, {func}, {type}] representing a healthcheck
    122 local function filepath_to_healthcheck(path)
    123  path = vim.fs.abspath(vim.fs.normalize(path))
    124  local name --- @type string
    125  local func --- @type string
    126  local filetype --- @type string
    127  if path:find('vim$') then
    128    name = vim.fs.basename(path):gsub('%.vim$', '')
    129    func = 'health#' .. name .. '#check'
    130    filetype = 'v'
    131  else
    132    local rtp_lua = vim
    133      .iter(vim.api.nvim_get_runtime_file('lua/', true))
    134      :map(function(rtp_lua)
    135        return vim.fs.abspath(vim.fs.normalize(rtp_lua))
    136      end)
    137      :find(function(rtp_lua)
    138        return vim.fs.relpath(rtp_lua, path)
    139      end)
    140    -- "/path/to/rtp/lua/foo/bar/health.lua" => "foo/bar/health.lua"
    141    -- "/another/rtp/lua/baz/health/init.lua" => "baz/health/init.lua"
    142    local subpath = path:gsub('^' .. vim.pesc(rtp_lua), ''):gsub('^/+', '')
    143    if vim.fs.basename(subpath) == 'health.lua' then
    144      -- */health.lua
    145      name = vim.fs.dirname(subpath)
    146    else
    147      -- */health/init.lua
    148      name = vim.fs.dirname(vim.fs.dirname(subpath))
    149    end
    150    name = assert(name:gsub('/', '.')) --- @type string
    151 
    152    func = 'require("' .. name .. '.health").check()'
    153    filetype = 'l'
    154  end
    155  return { name, func, filetype }
    156 end
    157 
    158 --- @param plugin_names string
    159 --- @return table<any,string[]> { {name, func, type}, ... } representing healthchecks
    160 local function get_healthcheck_list(plugin_names)
    161  local healthchecks = {} --- @type table<any,string[]>
    162  local plugin_names_list = vim.split(plugin_names, ' ')
    163  for _, p in pairs(plugin_names_list) do
    164    -- support vim/lsp/health{/init/}.lua as :checkhealth vim.lsp
    165 
    166    p = p:gsub('%.', '/')
    167    p = p:gsub('*', '**')
    168 
    169    local paths = vim.api.nvim_get_runtime_file('autoload/health/' .. p .. '.vim', true)
    170    vim.list_extend(
    171      paths,
    172      vim.api.nvim_get_runtime_file('lua/**/' .. p .. '/health/init.lua', true)
    173    )
    174    vim.list_extend(paths, vim.api.nvim_get_runtime_file('lua/**/' .. p .. '/health.lua', true))
    175 
    176    if vim.tbl_count(paths) == 0 then
    177      healthchecks[#healthchecks + 1] = { p, '', '' } -- healthcheck not found
    178    else
    179      local unique_paths = {} --- @type table<string, boolean>
    180      for _, v in pairs(paths) do
    181        unique_paths[v] = true
    182      end
    183      paths = {}
    184      for k, _ in pairs(unique_paths) do
    185        paths[#paths + 1] = k
    186      end
    187 
    188      for _, v in ipairs(paths) do
    189        healthchecks[#healthchecks + 1] = filepath_to_healthcheck(v)
    190      end
    191    end
    192  end
    193  return healthchecks
    194 end
    195 
    196 --- @param plugin_names string
    197 --- @return table<string, string[]> {name: [func, type], ..} representing healthchecks
    198 local function get_healthcheck(plugin_names)
    199  local health_list = get_healthcheck_list(plugin_names)
    200  local healthchecks = {} --- @type table<string, string[]>
    201  for _, c in pairs(health_list) do
    202    if c[1] ~= 'vim' then
    203      healthchecks[c[1]] = { c[2], c[3] }
    204    end
    205  end
    206 
    207  return healthchecks
    208 end
    209 
    210 --- Indents lines *except* line 1 of a multiline string.
    211 ---
    212 --- @param s string
    213 --- @param columns integer
    214 --- @return string
    215 local function indent_after_line1(s, columns)
    216  return (vim.text.indent(columns, s):gsub('^%s+', ''))
    217 end
    218 
    219 --- Changes ':h clipboard' to ':help |clipboard|'.
    220 ---
    221 --- @param s string
    222 --- @return string
    223 local function help_to_link(s)
    224  return vim.fn.substitute(s, [[\v:h%[elp] ([^|][^"\r\n ]+)]], [[:help |\1|]], [[g]])
    225 end
    226 
    227 --- Format a message for a specific report item.
    228 ---
    229 --- @param status string
    230 --- @param msg string
    231 --- @param ... string|string[] Optional advice
    232 --- @return string
    233 local function format_report_message(status, msg, ...)
    234  local output = '- ' .. status
    235  if status ~= '' then
    236    output = output .. ' '
    237  end
    238 
    239  output = output .. indent_after_line1(msg, 2)
    240 
    241  local varargs = ...
    242 
    243  -- Optional parameters
    244  if varargs then
    245    if type(varargs) == 'string' then
    246      varargs = { varargs }
    247    end
    248 
    249    output = output .. '\n  - ADVICE:'
    250 
    251    -- Report each suggestion
    252    for _, v in ipairs(varargs) do
    253      if v then
    254        output = output .. '\n    - ' .. indent_after_line1(v, 6) --- @type string
    255      end
    256    end
    257  end
    258 
    259  return help_to_link(output)
    260 end
    261 
    262 --- @param output string
    263 local function collect_output(output)
    264  vim.list_extend(s_output, vim.split(output, '\n'))
    265 end
    266 
    267 --- Starts a new report. Most plugins should call this only once, but if
    268 --- you want different sections to appear in your report, call this once
    269 --- per section.
    270 ---
    271 --- @param name string
    272 function M.start(name)
    273  local input = string.format('\n%s ~', name)
    274  collect_output(input)
    275 end
    276 
    277 --- Reports an informational message.
    278 ---
    279 --- @param msg string
    280 function M.info(msg)
    281  local input = format_report_message('', msg)
    282  collect_output(input)
    283 end
    284 
    285 --- Reports a "success" message.
    286 ---
    287 --- @param msg string
    288 function M.ok(msg)
    289  local input = format_report_message('✅ OK', msg)
    290  collect_output(input)
    291 end
    292 
    293 --- Reports a warning.
    294 ---
    295 --- @param msg string
    296 --- @param ... string|string[] Optional advice
    297 function M.warn(msg, ...)
    298  local input = format_report_message('⚠️ WARNING', msg, ...)
    299  collect_output(input)
    300  check_summary['warn'] = check_summary['warn'] + 1
    301 end
    302 
    303 --- Reports an error.
    304 ---
    305 --- @param msg string
    306 --- @param ... string|string[] Optional advice
    307 function M.error(msg, ...)
    308  local input = format_report_message('❌ ERROR', msg, ...)
    309  collect_output(input)
    310  check_summary['error'] = check_summary['error'] + 1
    311 end
    312 
    313 local path2name = function(path)
    314  if path:match('%.lua$') then
    315    -- Lua: transform "../lua/vim/lsp/health.lua" into "vim.lsp"
    316 
    317    -- Get full path, make sure all slashes are '/'
    318    path = vim.fs.normalize(path)
    319 
    320    -- Remove everything up to the last /lua/ folder
    321    path = path:gsub('^.*/lua/', '')
    322 
    323    -- Remove the filename (health.lua) or (health/init.lua)
    324    path = vim.fs.dirname(path:gsub('/init%.lua$', ''))
    325 
    326    -- Change slashes to dots
    327    path = path:gsub('/', '.')
    328 
    329    return path
    330  else
    331    -- Vim: transform "../autoload/health/provider.vim" into "provider"
    332    return vim.fn.fnamemodify(path, ':t:r')
    333  end
    334 end
    335 
    336 local PATTERNS = { '/autoload/health/*.vim', '/lua/**/**/health.lua', '/lua/**/**/health/init.lua' }
    337 --- :checkhealth completion function used by cmdexpand.c get_healthcheck_names()
    338 M._complete = function()
    339  local unique = vim ---@type table<string,boolean>
    340    ---@param pattern string
    341    .iter(vim.tbl_map(function(pattern)
    342      return vim.tbl_map(path2name, vim.api.nvim_get_runtime_file(pattern, true))
    343    end, PATTERNS))
    344    :flatten()
    345    ---@param t table<string,boolean>
    346    :fold({}, function(t, name)
    347      t[name] = true -- Remove duplicates
    348      return t
    349    end)
    350  -- vim.health is this file, which is not a healthcheck
    351  unique['vim'] = nil
    352  local rv = vim.tbl_keys(unique)
    353  table.sort(rv)
    354  return rv
    355 end
    356 
    357 --- Gets the results heading for the current report section.
    358 ---
    359 ---@return string
    360 local function get_summary()
    361  local s = ''
    362  local errors = check_summary['error']
    363  local warns = check_summary['warn']
    364 
    365  s = s .. (warns > 0 and (' %2d ⚠️'):format(warns) or '')
    366  s = s .. (errors > 0 and (' %2d ❌'):format(errors) or '')
    367  if errors == 0 and warns == 0 then
    368    s = s .. '✅'
    369  end
    370 
    371  return s
    372 end
    373 
    374 ---Emit progress messages
    375 ---@param len integer
    376 ---@return fun(status: 'success'|'running', idx: integer, fmt: string, ...: any): nil
    377 local function progress_report(len)
    378  local progress = { kind = 'progress', title = 'checkhealth' }
    379 
    380  return function(status, idx, fmt, ...)
    381    progress.status = status
    382    progress.percent = status == 'success' and nil or math.floor(idx / len * 100)
    383    -- percent=0 omits the reporting of percentage, so use 1% instead
    384    -- progress.percent = progress.percent == 0 and 1 or progress.percent
    385    progress.id = vim.api.nvim_echo({ { fmt:format(...) } }, false, progress)
    386    vim.cmd.redraw()
    387  end
    388 end
    389 
    390 --- Runs the specified healthchecks.
    391 --- Runs all discovered healthchecks if plugin_names is empty.
    392 ---
    393 --- @param mods string command modifiers that affect splitting a window.
    394 --- @param plugin_names string glob of plugin names, split on whitespace. For example, using
    395 ---                            `:checkhealth vim.* nvim` will healthcheck `vim.lsp`, `vim.treesitter`
    396 ---                            and `nvim` modules.
    397 function M._check(mods, plugin_names)
    398  local healthchecks = plugin_names == '' and get_healthcheck('*') or get_healthcheck(plugin_names)
    399 
    400  local emptybuf = vim.fn.bufnr('$') == 1 and vim.fn.getline(1) == '' and 1 == vim.fn.line('$')
    401 
    402  local bufnr ---@type integer
    403  if vim.tbl_get(vim.g, 'health', 'style') == 'float' then
    404    local available_lines = vim.o.lines - 12
    405    local max_height = math.min(math.floor(vim.o.lines * 0.8), available_lines)
    406    local max_width = 80
    407    local float_winid
    408    bufnr, float_winid = vim.lsp.util.open_floating_preview({}, '', {
    409      height = max_height,
    410      width = max_width,
    411      offset_x = math.floor((vim.o.columns - max_width) / 2),
    412      offset_y = math.floor((available_lines - max_height) / 2),
    413      relative = 'editor',
    414      close_events = {},
    415    })
    416    vim.api.nvim_set_current_win(float_winid)
    417    vim.bo[bufnr].modifiable = true
    418    vim.wo[float_winid].list = false
    419  else
    420    bufnr = vim.api.nvim_create_buf(true, true)
    421    -- When no command modifiers are used:
    422    -- - If the current buffer is empty, open healthcheck directly.
    423    -- - If not specified otherwise open healthcheck in a tab.
    424    local buf_cmd = #mods > 0 and (mods .. ' sbuffer') or emptybuf and 'buffer' or 'tab sbuffer'
    425    vim.cmd(buf_cmd .. ' ' .. bufnr)
    426  end
    427 
    428  if vim.fn.bufexists('health://') == 1 then
    429    vim.cmd.bwipe('health://')
    430  end
    431  vim.cmd.file('health://')
    432 
    433  -- This should only happen when doing `:checkhealth vim`
    434  if next(healthchecks) == nil then
    435    vim.fn.setline(1, 'ERROR: No healthchecks found.')
    436    return
    437  end
    438 
    439  local total_checks = #vim.tbl_keys(healthchecks)
    440  local progress_msg = progress_report(total_checks)
    441  local check_idx = 1
    442  for name, value in vim.spairs(healthchecks) do
    443    progress_msg('running', check_idx, 'checking %s', name)
    444    check_idx = check_idx + 1
    445    local func = value[1]
    446    local type = value[2]
    447    s_output = {}
    448    check_summary = { warn = 0, error = 0 }
    449 
    450    if func == '' then
    451      M.error('No healthcheck found for "' .. name .. '" plugin.')
    452    end
    453    if type == 'v' then
    454      vim.fn.call(func, {})
    455    else
    456      local f = assert(loadstring(func))
    457      local ok, output = pcall(f) ---@type boolean, string
    458      if not ok then
    459        M.error(
    460          string.format('Failed to run healthcheck for "%s" plugin. Exception:\n%s\n', name, output)
    461        )
    462      end
    463    end
    464    -- in the event the healthcheck doesn't return anything
    465    -- (the plugin author should avoid this possibility)
    466    if next(s_output) == nil then
    467      s_output = {}
    468      M.error('The healthcheck report for "' .. name .. '" plugin is empty.')
    469    end
    470 
    471    local report = get_summary()
    472    local replen = vim.fn.strwidth(report)
    473    local header = {
    474      string.rep('=', 78),
    475      -- Example: `foo.health: [ …] 1 ⚠️  5 ❌`
    476      ('%s: %s%s'):format(name, (' '):rep(76 - name:len() - replen), report),
    477      '',
    478    }
    479 
    480    -- remove empty line after header from report_start
    481    if s_output[1] == '' then
    482      local tmp = {} ---@type string[]
    483      for i = 2, #s_output do
    484        tmp[#tmp + 1] = s_output[i]
    485      end
    486      s_output = {}
    487      for _, v in ipairs(tmp) do
    488        s_output[#s_output + 1] = v
    489      end
    490    end
    491    s_output[#s_output + 1] = ''
    492    s_output = vim.list_extend(header, s_output)
    493    vim.fn.append(vim.fn.line('$'), s_output)
    494  end
    495 
    496  progress_msg('success', 0, 'checks done')
    497 
    498  -- Quit with 'q' inside healthcheck buffers.
    499  vim._with({ buf = bufnr }, function()
    500    if
    501      vim.tbl_get(vim.g, 'health', 'style') == 'float'
    502      or vim.fn.maparg('q', 'n', false, false) == ''
    503    then
    504      vim.keymap.set('n', 'q', function()
    505        if not pcall(vim.cmd.close) then
    506          vim.cmd.bdelete()
    507        end
    508      end, { buffer = bufnr, silent = true, noremap = true, nowait = true })
    509    end
    510  end)
    511 
    512  -- Once we're done writing checks, set nomodifiable.
    513  vim.bo[bufnr].modifiable = false
    514  vim.cmd.setfiletype('checkhealth')
    515 end
    516 
    517 return M