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