Files
neovim/runtime/lua/vim/health.lua
dundargoc 66220d164a revert: "feat(health): fold successful healthchecks #22866"
This reverts commit 4382d2ed56.

The story for this feature was left in an incomplete state. It was never
the intention to unilaterally fold all information, only the ones that
did not contain relevant information. This feature does more harm than
good in its incomplete state.
2024-04-13 01:18:40 +02:00

466 lines
13 KiB
Lua

local M = {}
local s_output = {} ---@type string[]
-- From a path return a list [{name}, {func}, {type}] representing a healthcheck
local function filepath_to_healthcheck(path)
path = vim.fs.normalize(path)
local name --- @type string
local func --- @type string
local filetype --- @type string
if path:find('vim$') then
name = vim.fs.basename(path):gsub('%.vim$', '')
func = 'health#' .. name .. '#check'
filetype = 'v'
else
local subpath = path:gsub('.*lua/', '')
if vim.fs.basename(subpath) == 'health.lua' then
-- */health.lua
name = assert(vim.fs.dirname(subpath))
else
-- */health/init.lua
name = assert(vim.fs.dirname(assert(vim.fs.dirname(subpath))))
end
name = name:gsub('/', '.')
func = 'require("' .. name .. '.health").check()'
filetype = 'l'
end
return { name, func, filetype }
end
--- @param plugin_names string
--- @return table<any,string[]> { {name, func, type}, ... } representing healthchecks
local function get_healthcheck_list(plugin_names)
local healthchecks = {} --- @type table<any,string[]>
local plugin_names_list = vim.split(plugin_names, ' ')
for _, p in pairs(plugin_names_list) do
-- support vim/lsp/health{/init/}.lua as :checkhealth vim.lsp
p = p:gsub('%.', '/')
p = p:gsub('*', '**')
local paths = vim.api.nvim_get_runtime_file('autoload/health/' .. p .. '.vim', true)
vim.list_extend(
paths,
vim.api.nvim_get_runtime_file('lua/**/' .. p .. '/health/init.lua', true)
)
vim.list_extend(paths, vim.api.nvim_get_runtime_file('lua/**/' .. p .. '/health.lua', true))
if vim.tbl_count(paths) == 0 then
healthchecks[#healthchecks + 1] = { p, '', '' } -- healthcheck not found
else
local unique_paths = {} --- @type table<string, boolean>
for _, v in pairs(paths) do
unique_paths[v] = true
end
paths = {}
for k, _ in pairs(unique_paths) do
paths[#paths + 1] = k
end
for _, v in ipairs(paths) do
healthchecks[#healthchecks + 1] = filepath_to_healthcheck(v)
end
end
end
return healthchecks
end
--- @param plugin_names string
--- @return table<string, string[]> {name: [func, type], ..} representing healthchecks
local function get_healthcheck(plugin_names)
local health_list = get_healthcheck_list(plugin_names)
local healthchecks = {} --- @type table<string, string[]>
for _, c in pairs(health_list) do
if c[1] ~= 'vim' then
healthchecks[c[1]] = { c[2], c[3] }
end
end
return healthchecks
end
--- Indents lines *except* line 1 of a string if it contains newlines.
---
--- @param s string
--- @param columns integer
--- @return string
local function indent_after_line1(s, columns)
local lines = vim.split(s, '\n')
local indent = string.rep(' ', columns)
for i = 2, #lines do
lines[i] = indent .. lines[i]
end
return table.concat(lines, '\n')
end
--- Changes ':h clipboard' to ':help |clipboard|'.
---
--- @param s string
--- @return string
local function help_to_link(s)
return vim.fn.substitute(s, [[\v:h%[elp] ([^|][^"\r\n ]+)]], [[:help |\1|]], [[g]])
end
--- Format a message for a specific report item.
---
--- @param status string
--- @param msg string
--- @param ... string|string[] Optional advice
--- @return string
local function format_report_message(status, msg, ...)
local output = '- ' .. status
if status ~= '' then
output = output .. ' '
end
output = output .. indent_after_line1(msg, 2)
local varargs = ...
-- Optional parameters
if varargs then
if type(varargs) == 'string' then
varargs = { varargs }
end
output = output .. '\n - ADVICE:'
-- Report each suggestion
for _, v in ipairs(varargs) do
if v then
output = output .. '\n - ' .. indent_after_line1(v, 6)
end
end
end
return help_to_link(output)
end
--- @param output string
local function collect_output(output)
vim.list_extend(s_output, vim.split(output, '\n'))
end
--- Starts a new report.
---
--- @param name string
function M.start(name)
local input = string.format('\n%s ~', name)
collect_output(input)
end
--- Reports a message in the current section.
---
--- @param msg string
function M.info(msg)
local input = format_report_message('', msg)
collect_output(input)
end
--- Reports a successful healthcheck.
---
--- @param msg string
function M.ok(msg)
local input = format_report_message('OK', msg)
collect_output(input)
end
--- Reports a health warning.
---
--- @param msg string
--- @param ... string|string[] Optional advice
function M.warn(msg, ...)
local input = format_report_message('WARNING', msg, ...)
collect_output(input)
end
--- Reports a failed healthcheck.
---
--- @param msg string
--- @param ... string|string[] Optional advice
function M.error(msg, ...)
local input = format_report_message('ERROR', msg, ...)
collect_output(input)
end
--- @param type string
local function deprecate(type)
local before = string.format('vim.health.report_%s()', type)
local after = string.format('vim.health.%s()', type)
local message = vim.deprecate(before, after, '0.11')
if message then
M.warn(message)
end
vim.cmd.redraw()
vim.print('Running healthchecks...')
end
--- @deprecated
--- @param name string
function M.report_start(name)
deprecate('start')
M.start(name)
end
--- @deprecated
--- @param msg string
function M.report_info(msg)
deprecate('info')
M.info(msg)
end
--- @deprecated
--- @param msg string
function M.report_ok(msg)
deprecate('ok')
M.ok(msg)
end
--- @deprecated
--- @param msg string
function M.report_warn(msg, ...)
deprecate('warn')
M.warn(msg, ...)
end
--- @deprecated
--- @param msg string
function M.report_error(msg, ...)
deprecate('error')
M.error(msg, ...)
end
function M.provider_disabled(provider)
local loaded_var = 'loaded_' .. provider .. '_provider'
local v = vim.g[loaded_var]
if v == 0 then
M.info('Disabled (' .. loaded_var .. '=' .. v .. ').')
return true
end
return false
end
-- Handler for s:system() function.
local function system_handler(self, _, data, event)
if event == 'stderr' then
if self.add_stderr_to_output then
self.output = self.output .. table.concat(data, '')
else
self.stderr = self.stderr .. table.concat(data, '')
end
elseif event == 'stdout' then
self.output = self.output .. table.concat(data, '')
end
end
-- Attempts to construct a shell command from an args list.
-- Only for display, to help users debug a failed command.
local function shellify(cmd)
if type(cmd) ~= 'table' then
return cmd
end
local escaped = {}
for i, v in ipairs(cmd) do
if v:match('[^A-Za-z_/.-]') then
escaped[i] = vim.fn.shellescape(v)
else
escaped[i] = v
end
end
return table.concat(escaped, ' ')
end
function M.cmd_ok(cmd)
local out = vim.fn.system(cmd)
return vim.v.shell_error == 0, out
end
--- Run a system command and timeout after 30 seconds.
---
--- @param cmd table List of command arguments to execute
--- @param args? table Optional arguments:
--- - stdin (string): Data to write to the job's stdin
--- - stderr (boolean): Append stderr to stdout
--- - ignore_error (boolean): If true, ignore error output
--- - timeout (number): Number of seconds to wait before timing out (default 30)
function M.system(cmd, args)
args = args or {}
local stdin = args.stdin or ''
local stderr = vim.F.if_nil(args.stderr, false)
local ignore_error = vim.F.if_nil(args.ignore_error, false)
local shell_error_code = 0
local opts = {
add_stderr_to_output = stderr,
output = '',
stderr = '',
on_stdout = system_handler,
on_stderr = system_handler,
on_exit = function(_, data)
shell_error_code = data
end,
}
local jobid = vim.fn.jobstart(cmd, opts)
if jobid < 1 then
local message =
string.format('Command error (job=%d): %s (in %s)', jobid, shellify(cmd), vim.loop.cwd())
error(message)
return opts.output, 1
end
if stdin:find('^%s$') then
vim.fn.chansend(jobid, stdin)
end
local res = vim.fn.jobwait({ jobid }, vim.F.if_nil(args.timeout, 30) * 1000)
if res[1] == -1 then
error('Command timed out: ' .. shellify(cmd))
vim.fn.jobstop(jobid)
elseif shell_error_code ~= 0 and not ignore_error then
local emsg = string.format(
'Command error (job=%d, exit code %d): %s (in %s)',
jobid,
shell_error_code,
shellify(cmd),
vim.loop.cwd()
)
if opts.output:find('%S') then
emsg = string.format('%s\noutput: %s', emsg, opts.output)
end
if opts.stderr:find('%S') then
emsg = string.format('%s\nstderr: %s', emsg, opts.stderr)
end
error(emsg)
end
-- return opts.output
return vim.trim(vim.fn.system(cmd)), shell_error_code
end
local path2name = function(path)
if path:match('%.lua$') then
-- Lua: transform "../lua/vim/lsp/health.lua" into "vim.lsp"
-- Get full path, make sure all slashes are '/'
path = vim.fs.normalize(path)
-- Remove everything up to the last /lua/ folder
path = path:gsub('^.*/lua/', '')
-- Remove the filename (health.lua)
path = vim.fs.dirname(path)
-- Change slashes to dots
path = path:gsub('/', '.')
return path
else
-- Vim: transform "../autoload/health/provider.vim" into "provider"
return vim.fn.fnamemodify(path, ':t:r')
end
end
local PATTERNS = { '/autoload/health/*.vim', '/lua/**/**/health.lua', '/lua/**/**/health/init.lua' }
--- :checkhealth completion function used by cmdexpand.c get_healthcheck_names()
M._complete = function()
local names = vim.tbl_flatten(vim.tbl_map(function(pattern)
return vim.tbl_map(path2name, vim.api.nvim_get_runtime_file(pattern, true))
end, PATTERNS))
-- Remove duplicates
local unique = {}
vim.tbl_map(function(f)
unique[f] = true
end, names)
-- vim.health is this file, which is not a healthcheck
unique['vim'] = nil
return vim.tbl_keys(unique)
end
--- Runs the specified healthchecks.
--- Runs all discovered healthchecks if plugin_names is empty.
---
--- @param mods string command modifiers that affect splitting a window.
--- @param plugin_names string glob of plugin names, split on whitespace. For example, using
--- `:checkhealth vim.* nvim` will healthcheck `vim.lsp`, `vim.treesitter`
--- and `nvim` modules.
function M._check(mods, plugin_names)
local healthchecks = plugin_names == '' and get_healthcheck('*') or get_healthcheck(plugin_names)
local emptybuf = vim.fn.bufnr('$') == 1 and vim.fn.getline(1) == '' and 1 == vim.fn.line('$')
-- When no command modifiers are used:
-- - If the current buffer is empty, open healthcheck directly.
-- - If not specified otherwise open healthcheck in a tab.
local buf_cmd = #mods > 0 and (mods .. ' sbuffer') or emptybuf and 'buffer' or 'tab sbuffer'
local bufnr = vim.api.nvim_create_buf(true, true)
vim.cmd(buf_cmd .. ' ' .. bufnr)
if vim.fn.bufexists('health://') == 1 then
vim.cmd.bwipe('health://')
end
vim.cmd.file('health://')
vim.cmd.setfiletype('checkhealth')
-- This should only happen when doing `:checkhealth vim`
if next(healthchecks) == nil then
vim.fn.setline(1, 'ERROR: No healthchecks found.')
return
end
vim.cmd.redraw()
vim.print('Running healthchecks...')
for name, value in vim.spairs(healthchecks) do
local func = value[1]
local type = value[2]
s_output = {}
if func == '' then
s_output = {}
M.error('No healthcheck found for "' .. name .. '" plugin.')
end
if type == 'v' then
vim.fn.call(func, {})
else
local f = assert(loadstring(func))
local ok, output = pcall(f)
if not ok then
M.error(
string.format('Failed to run healthcheck for "%s" plugin. Exception:\n%s\n', name, output)
)
end
end
-- in the event the healthcheck doesn't return anything
-- (the plugin author should avoid this possibility)
if next(s_output) == nil then
s_output = {}
M.error('The healthcheck report for "' .. name .. '" plugin is empty.')
end
local header = { string.rep('=', 78), name .. ': ' .. func, '' }
-- remove empty line after header from report_start
if s_output[1] == '' then
local tmp = {} ---@type string[]
for i = 2, #s_output do
tmp[#tmp + 1] = s_output[i]
end
s_output = {}
for _, v in ipairs(tmp) do
s_output[#s_output + 1] = v
end
end
s_output[#s_output + 1] = ''
s_output = vim.list_extend(header, s_output)
vim.fn.append('$', s_output)
vim.cmd.redraw()
end
-- Clear the 'Running healthchecks...' message.
vim.cmd.redraw()
vim.print('')
end
return M