feat(ex): add :log command

This commit is contained in:
Olivia Kinnear
2026-03-28 22:07:04 -05:00
parent 6bea0cdbdc
commit 8715877417
14 changed files with 266 additions and 19 deletions

View File

@@ -1733,4 +1733,27 @@ mark a file as trusted or untrusted using the |:trust| command or the
or |vim.secure.read()|, the user will be prompted to
trust or deny it.
==============================================================================
13. Log Files *log-files*
Nvim keeps log files in `stdpath("log")`. See |standard-path| for where that
location is on your system.
*:log*
:log [logname]
Open a log file that exists in `stdpath("log")`.
`[logname]` is the name of the file with the extension
".log" removed.
Without a provided `[logname]`, opens the log
directory.
Examples: >
:log nvim
< Open the general |log| file for Nvim. >
:log lsp
< Open the |lsp-log| file. >
:log nvim-pack
< Open the |vim.pack| log file.
vim:tw=78:ts=8:noet:ft=help:norl:

View File

@@ -1415,6 +1415,7 @@ Tag Command Action ~
|:loadview| :lo[adview] load view for current window from a file
|:lockmarks| :loc[kmarks] following command keeps marks where they are
|:lockvar| :lockv[ar] lock variables
|:log| :log view a log file
|:lolder| :lol[der] go to older location list
|:lopen| :lop[en] open location window
|:lprevious| :lp[revious] go to previous location

View File

@@ -2472,11 +2472,9 @@ of the LSP client RPC events. Example: >lua
<
Then try to run the language server, and open the log with: >vim
:lua vim.cmd('tabnew ' .. vim.lsp.log.get_filename())
:log lsp
<
(Or use `:LspLog` if you have nvim-lspconfig installed.)
Note:
• Remember to DISABLE verbose logging ("debug" or "trace" level), else you may
encounter performance issues.

View File

@@ -78,7 +78,7 @@ DIAGNOSTICS
EDITOR
todo
|:log| for opening log files
EVENTS

View File

@@ -1449,10 +1449,14 @@ To run Nvim without creating any directories or data files: >
LOG FILE *log* *$NVIM_LOG_FILE* *E5430*
Besides 'debug' and 'verbose', Nvim keeps a general log file for internal
debugging, plugins and RPC clients. >
:echo $NVIM_LOG_FILE
debugging, plugins and RPC clients. The log file can be viewed with: >
:log nvim
Default location is stdpath("log")/nvim.log ($XDG_STATE_HOME/nvim/logs/nvim.log)
unless that path is inaccessible or $NVIM_LOG_FILE was set before |startup|.
See |log-files| for details about other log files that can be created by Nvim
during runtime or by plugins.
vim:et:tw=78:ts=8:sw=4:ft=help:norl:

View File

@@ -311,6 +311,7 @@ Commands:
- |:EditQuery|
- |:Inspect|
- |:InspectTree|
- |:log|
- |:lsp|
- |:Man| is available by default, with many improvements such as completion
- |:match| can be invoked before highlight group is defined

View File

@@ -1,5 +1,6 @@
local api = vim.api
local lsp = vim.lsp
local fs = vim.fs
local util = require('vim._core.util')
local M = {}
@@ -11,7 +12,7 @@ end
--- @return string[]
local function get_client_names()
return vim
.iter(lsp.get_clients())
.iter(vim.lsp.get_clients())
:map(function(client)
return client.name
end)
@@ -24,7 +25,7 @@ end
local function filtered_config_names(filter)
return function()
return vim
.iter(lsp.get_configs(filter))
.iter(vim.lsp.get_configs(filter))
:map(function(config)
return config.name
end)
@@ -43,8 +44,8 @@ local complete_args = {
--- @param enable? boolean
local function checked_enable(names, enable)
for _, name in ipairs(names) do
if name:find('*') == nil and lsp.config[name] ~= nil then
lsp.enable(name, enable)
if name:find('*') == nil and vim.lsp.config[name] ~= nil then
vim.lsp.enable(name, enable)
else
echo_err(("No client config named '%s'"):format(name))
end
@@ -56,7 +57,7 @@ local function ex_lsp_enable(config_names)
-- Default to enabling all clients matching the filetype of the current buffer.
if #config_names == 0 then
local filetype = vim.bo.filetype
for _, config in ipairs(lsp.get_configs()) do
for _, config in ipairs(vim.lsp.get_configs()) do
local filetypes = config.filetypes
if filetypes == nil or vim.list_contains(filetypes, filetype) then
table.insert(config_names, config.name)
@@ -80,12 +81,12 @@ local function ex_lsp_disable(config_names)
-- Default to disabling all clients attached to the current buffer.
if #config_names == 0 then
config_names = vim
.iter(lsp.get_clients { bufnr = api.nvim_get_current_buf() })
.iter(vim.lsp.get_clients { bufnr = api.nvim_get_current_buf() })
:map(function(client)
return client.name
end)
:filter(function(name)
return lsp.config[name] ~= nil
return vim.lsp.config[name] ~= nil
end)
:totable()
if #config_names == 0 then
@@ -102,7 +103,7 @@ end
local function get_clients_from_names(client_names)
-- Default to all active clients attached to the current buffer.
if #client_names == 0 then
local clients = lsp.get_clients { bufnr = api.nvim_get_current_buf() }
local clients = vim.lsp.get_clients { bufnr = api.nvim_get_current_buf() }
if #clients == 0 then
echo_err('No clients attached to current buffer')
end
@@ -111,7 +112,7 @@ local function get_clients_from_names(client_names)
return vim
.iter(client_names)
:map(function(name)
local clients = lsp.get_clients { name = name }
local clients = vim.lsp.get_clients { name = name }
if #clients == 0 then
echo_err(("No active clients named '%s'"):format(name))
end
@@ -186,4 +187,45 @@ function M.lsp_complete(line)
end
end
--- @type string
--- @diagnostic disable-next-line: assign-type-mismatch
local log_dir = vim.fn.stdpath('log')
--- Implements command: `:log {file}`.
--- @param filename string
--- @param mods string
M.ex_log = function(filename, mods)
if filename == '' then
util.wrapped_edit(log_dir, mods)
else
local path --- @type string
-- Special case for NVIM_LOG_FILE
local nvim_log_file = vim.env.NVIM_LOG_FILE --- @type string
if filename == 'nvim' and nvim_log_file and nvim_log_file ~= '' then
path = nvim_log_file
else
path = fs.joinpath(log_dir, filename .. '.log')
end
if not vim.uv.fs_stat(path) then
echo_err(("No such log file: '%s'"):format(path))
return
end
util.wrapped_edit(path, mods)
vim.cmd.normal { 'G', bang = true }
end
end
--- Completion logic for `:log` command
--- @return string[] list of completions
function M.log_complete()
local names = { 'nvim' } --- @type string[]
for file, type in vim.fs.dir(log_dir, { depth = math.huge }) do
local name, matches = file:gsub('%.log$', '')
if matches ~= 0 and type == 'file' and name ~= 'nvim' then
names[#names + 1] = name
end
end
return names
end
return M

View File

@@ -22,6 +22,17 @@ function M.space_below()
add_blank()
end
--- Gets a buffer by name
--- @param name string
--- @return integer?
function M.get_buf_by_name(name)
for _, buf in ipairs(vim.api.nvim_list_bufs()) do
if vim.api.nvim_buf_get_name(buf) == name then
return buf
end
end
end
--- Edit a file in a specific window
--- @param winnr number
--- @param file string
@@ -49,6 +60,23 @@ M.edit_in = function(winnr, file)
end)
end
--- :edit, but it respects commands like :hor, :vert, :tab, etc.
--- @param file string
--- @param mods_str string
function M.wrapped_edit(file, mods_str)
local cmdline = table.concat({ mods_str, 'edit' }, ' ')
local mods = vim.api.nvim_parse_cmd(cmdline, {}).mods
--- @diagnostic disable-next-line: need-check-nil
if mods.tab > 0 or mods.split ~= '' or mods.horizontal or mods.vertical then
local buf = M.get_buf_by_name(file)
if buf == nil then
buf = vim.api.nvim_create_buf(true, false)
end
vim.cmd.sbuffer { buf, mods = mods }
end
vim.cmd.edit { file }
end
--- Read a chunk of data from a file
--- @param file string
--- @param size number

View File

@@ -10,11 +10,9 @@
---
--- Then try to run the language server, and open the log with:
--- ```vim
--- :lua vim.cmd('tabnew ' .. vim.lsp.log.get_filename())
--- :log lsp
--- ```
---
--- (Or use `:LspLog` if you have nvim-lspconfig installed.)
---
--- Note:
--- - Remember to DISABLE verbose logging ("debug" or "trace" level), else you may encounter
--- performance issues.

View File

@@ -1304,6 +1304,7 @@ char *addstar(char *fname, size_t len, int context)
&& fname[0] == '/')
|| context == EXPAND_CHECKHEALTH
|| context == EXPAND_LSP
|| context == EXPAND_LOG
|| context == EXPAND_LUA) {
retval = xstrnsave(fname, len);
} else {
@@ -2316,6 +2317,10 @@ static const char *set_context_by_cmdname(const char *cmd, cmdidx_T cmdidx, expa
xp->xp_context = EXPAND_CHECKHEALTH;
break;
case CMD_log:
xp->xp_context = EXPAND_LOG;
break;
case CMD_lsp:
xp->xp_context = EXPAND_LSP;
break;
@@ -2858,6 +2863,43 @@ static char *get_healthcheck_names(expand_T *xp FUNC_ATTR_UNUSED, int idx)
return NULL;
}
/// Completion for |:log| command.
///
/// Given to ExpandGeneric() to obtain `:log` completion.
/// @param[in] idx Index of the item.
/// @param[in] xp Not used.
static char *get_log_arg(expand_T *xp FUNC_ATTR_UNUSED, int idx)
{
static Object names = OBJECT_INIT;
static char *last_xp_line = NULL;
static unsigned last_gen = 0;
if (last_xp_line == NULL || strcmp(last_xp_line,
xp->xp_line) != 0
|| last_gen != get_cmdline_last_prompt_id()) {
xfree(last_xp_line);
last_xp_line = xstrdup(xp->xp_line);
MAXSIZE_TEMP_ARRAY(args, 1);
Error err = ERROR_INIT;
ADD_C(args, CSTR_AS_OBJ(xp->xp_line));
// Build the current command line as a Lua string argument
Object res = NLUA_EXEC_STATIC("return require'vim._core.ex_cmd'.log_complete(...)", args,
kRetObject, NULL,
&err);
api_clear_error(&err);
api_free_object(names);
names = res;
last_gen = get_cmdline_last_prompt_id();
}
if (names.type == kObjectTypeArray && idx < (int)names.data.array.size
&& names.data.array.items[idx].type == kObjectTypeString) {
return names.data.array.items[idx].data.string.data;
}
return NULL;
}
/// Completion for |:lsp| command.
///
/// Given to ExpandGeneric() to obtain `:lsp` completion.
@@ -2937,6 +2979,7 @@ static int ExpandOther(char *pat, expand_T *xp, regmatch_T *rmp, char ***matches
{ EXPAND_SCRIPTNAMES, get_scriptnames_arg, true, false },
{ EXPAND_RETAB, get_retab_arg, true, true },
{ EXPAND_CHECKHEALTH, get_healthcheck_names, true, false },
{ EXPAND_LOG, get_log_arg, true, false },
{ EXPAND_LSP, get_lsp_arg, true, false },
};
int ret = FAIL;

View File

@@ -119,6 +119,7 @@ enum {
EXPAND_CHECKHEALTH,
EXPAND_LUA,
EXPAND_LSP,
EXPAND_LOG,
};
/// Type used by ExpandGeneric()

View File

@@ -1586,6 +1586,12 @@ M.cmds = {
addr_type = 'ADDR_NONE',
func = 'ex_lockvar',
},
{
command = 'log',
flags = bit.bor(EXTRA, TRLBAR),
addr_type = 'ADDR_NONE',
func = 'ex_log',
},
{
command = 'lolder',
flags = bit.bor(RANGE, COUNT, TRLBAR),

View File

@@ -8291,6 +8291,31 @@ static void ex_terminal(exarg_T *eap)
do_cmdline_cmd(ex_cmd);
}
/// ":log {name}"
static void ex_log(exarg_T *eap)
{
Error err = ERROR_INIT;
MAXSIZE_TEMP_ARRAY(args, 2);
char mods[1024];
size_t mods_len = 0;
mods[0] = NUL;
if (cmdmod.cmod_tab > 0 || cmdmod.cmod_split != 0) {
bool multi_mods = false;
mods_len = add_win_cmd_modifiers(mods, &cmdmod, &multi_mods);
assert(mods_len < sizeof(mods));
}
ADD_C(args, CSTR_AS_OBJ(eap->arg));
ADD_C(args, STRING_OBJ(((String){ .data = mods, .size = mods_len })));
NLUA_EXEC_STATIC("require'vim._core.ex_cmd'.ex_log(...)", args, kRetNilBool, NULL, &err);
if (ERROR_SET(&err)) {
emsg_multiline(err.msg, "lua_error", HLF_E, true);
}
api_clear_error(&err);
}
/// ":lsp {subcmd} {clients}"
static void ex_lsp(exarg_T *eap)
{

View File

@@ -0,0 +1,77 @@
local t = require('test.testutil')
local n = require('test.functional.testnvim')()
local clear = n.clear
local eq = t.eq
local exec_lua = n.exec_lua
local is_os = t.is_os
local pathsep = n.get_pathsep()
local write_file = t.write_file
describe(':log', function()
local xstate = 'Xstate'
local name = (is_os('win') and 'nvim-data' or 'nvim')
before_each(function()
clear { env = { XDG_STATE_HOME = xstate } }
local std_state = xstate .. pathsep .. name
n.mkdir_p(std_state .. pathsep .. 'logs')
write_file(std_state .. '/logs/nvim.log', [[nvim log]])
write_file(std_state .. '/logs/foo.log', [[foo log]])
end)
after_each(function()
n.rmdir(xstate)
end)
it('without argument opens log folder', function()
eq(
xstate .. '/' .. name .. '/logs',
exec_lua(function()
vim.cmd('log')
return vim.fs.normalize(vim.fn.expand('%'))
end)
)
end)
it('with argument opens corresponding log file', function()
eq(
xstate .. '/' .. name .. '/logs/foo.log',
exec_lua(function()
vim.cmd('log foo')
return vim.fs.normalize(vim.fn.expand('%'))
end)
)
end)
it('nvim works with non-default $NVIM_LOG_FILE', function()
clear { env = { XDG_STATE_HOME = xstate, NVIM_LOG_FILE = 'Xfoo.log' } }
write_file('Xfoo.log', [[nvim log]])
eq(
'Xfoo.log',
exec_lua(function()
vim.cmd('log nvim')
return vim.fn.expand('%')
end)
)
end)
it('argument completion', function()
local completions = exec_lua(function()
return vim.fn.getcompletion('log ', 'cmdline')
end)
eq({ 'foo', 'nvim' }, completions)
end)
it('works without runtime', function()
clear {
args_rm = { '-u' },
args = { '-u', 'NONE' },
env = { VIMRUNTIME = 'non-existent' },
}
exec_lua(function()
vim.cmd('log')
end)
end)
end)