refactor(exmode): Ex-mode as cmdwin + Lua #40991

Problem:
POSIX-compatible Ex-mode requires special-cases all over the codebase to
match various quirks that don't actually matter to users.
- The main utility of *interactive* Ex-mode is its REPL behavior, and
  that can be achieved with `cmdwin`, which also gains extra UX
  benefits.
- The main utility of *non-interactive* `nvim -es` is for shell
  scripting, where Ex-mode quirks are mostly unhelpful (e.g. the
  "Entering Ex mode" message).

Solution:
- Reimplement *interactive* Ex-mode as a "persistent, insert-mode
  cmdwin" in Lua.
  - "nvim -e/-E" is simply an alias to "gQ".
- Reframe *non-interactive* Ex-mode (`nvim -es`) as "script mode".
  - Drop POSIX Ex-mode quirks.

Improvements:
- "nvim -V1 -es" output ends with a final newline!
- "nvim -V1 -es" no longer shows the "Entering Ex mode" msg. (This was
  pointless noise, unwanted for scripting purposes.)
- stdin is no longer typeahead. Scripts (":lua io.read()") can read
  stdin as data.
- Empty line is a no-op: a stray blank line no longer moves the cursor
  (deviates from POSIX ex "+1"), no longer exits 1 at EOF (E501).

Preserved behavior:
- cursor starts at "$"
- mode()=="cv" (for non-interactive)
- multiline commands (:append/:function/heredoc pull continuation lines)
- bare-range print
- :print=>stdout
- -V1=>stderr
- CRLF input
- continue-after-error and exit codes

Dropped (regressed) POSIX behavior (non-interactive):
- Event loop only ticks while/between commands, not while blocked
  waiting for a stdin line.
- ":g/pat/visual...Q"
- input()/getchar()/":s/x/y/c" no longer consume stdin lines as
  answers: Nvim stops at end-of-input, skipping the rest of the script,
  exit 0. Use ":lua io.read()" instead.
  - If users care about this they should use interactive Ex-mode (`gQ`).
- ":@r" stops at end of the register instead of continuing to read
  cmdline input from stdin.
This commit is contained in:
Justin M. Keyes
2026-07-27 06:25:21 -04:00
committed by GitHub
parent faaa4d57f1
commit 359459dec6
49 changed files with 900 additions and 1056 deletions

View File

@@ -302,7 +302,7 @@ do
local function cmd(opts)
local ok, err = pcall(vim.api.nvim_cmd, opts, {})
if not ok then
vim.api.nvim_echo({ { err:sub(#'Vim:' + 1) } }, true, { err = true })
vim.api.nvim_echo({ { require('vim._core.util').cmd_errmsg(err) } }, true, { err = true })
end
end

View File

@@ -0,0 +1,223 @@
--- Ex mode.
--- - `run()`: non-interactive (`nvim -es`): executes stdin as Ex commands.
--- - `open()`: interactive (`gQ`, `nvim -e`), a keep-open "cmdwin" REPL.
local api = vim.api
local N_ = vim.fn.gettext
local util = require('vim._core.util')
local M = {}
--- Commands that end Ex mode before executing (in the caller window).
local exit_cmds = {
cquit = true,
exit = true,
quit = true,
quitall = true,
restart = true,
view = true,
visual = true,
wq = true,
wqall = true,
xall = true,
xit = true,
}
--- Continuation lines (of a multiline cmd), for the statuscolumn.
--- @type table<integer, true>
local cont_lines = {}
--- Non-interactive "script mode" ("nvim -es", fka "Ex-mode", but POSIX behavior was dropped):
--- Executes stdin as Ex commands, until EOF.
---
--- Stdin is not consumed as typeahead: fd 0 is read directly (`io.lines()`), so `:lua` script lines
--- can also read stdin as data via `io.read()`.
function M.run()
local lines = io.lines()
local getline = function()
local line = lines()
-- Strip trailing CR so CRLF input (e.g. Windows pipes) works like LF.
return line and (line:gsub('\r$', '')) or nil
end
-- Instead of per-line `nvim_exec2`, this runs one `do_cmdline` per logical command with
-- `getline` as the line-getter, in order to preserve these behaviors:
-- - :append/:function/:if/heredocs pull continuation lines from stdin. #7679
-- - Errors are emitted, not thrown (nvim_exec2 turns the first error into an exception):
-- execution continues with the next command, `ex_exitval` still sets the exit code, and
-- "-V1" shows errors on stderr.
-- - Output (:print, :set, …) to stdout as each command executes.
--
-- TODO: could enhance nvim_exec2 instead?
while vim._core.ex_docmd(getline) do
vim.wait(0) -- Run pending events (vim.schedule() callbacks, RPC).
end
end
--- Whether `lines` form a complete command block: no `:append` text, `:function` body, heredoc, or
--- :if/:while/:for/:try block is awaiting continuation lines. Feeds the lines to do_cmdline(),
--- which groups multiline constructs even when skipping (false `:if`), without executing anything.
--- If the construct is still open it consumes the sentinel "endif" and asks the getter for more.
--- @param lines string[]
--- @return boolean
local function block_complete(lines)
-- :append/etc. not grouped by the skipped-:if below: collect until the "." terminator.
local ok, p = pcall(api.nvim_parse_cmd, lines[1], {})
if ok and (p.cmd == 'append' or p.cmd == 'insert' or p.cmd == 'change') then
return vim.list_contains({ unpack(lines, 2) }, '.')
end
local feed = { 'if v:false' }
vim.list_extend(feed, lines)
feed[#feed + 1] = 'endif'
local i = 0
local complete = true
-- Silenced: probing an incomplete block emits e.g. E126/E990 when the reader hits EOF.
vim._with({ silent = true, emsg_silent = true }, function()
vim._core.ex_docmd(function()
i = i + 1
if i > #feed then
complete = false
return nil
end
return feed[i]
end)
end)
return complete
end
--- Ex-mode 'statuscolumn': ":", or blank for continuation lines (`:func` body, `:append` text, …).
function M._statuscolumn()
return cont_lines[vim.v.lnum] and ' ' or ':'
end
--- Interactive Ex-mode: keep-open cmdwin REPL. `<CR>` executes the current line against the caller
--- window and keeps the cmdwin open; command output is presented as comment ('"') lines. Multiline
--- commands (`:append`, etc.) are collected until the block is complete.
function M.open()
local cmdwin = require('vim._core.cmdwin')
local caller = api.nvim_get_current_win()
cmdwin.open(':')
if api.nvim_get_current_win() == caller then
return -- cmdwin failed to open (E1292 already echoed).
end
local buf = api.nvim_get_current_buf()
local block_start = api.nvim_buf_line_count(buf)
cont_lines = {}
vim.wo[0][0].statuscolumn = '%#NonText#%{v:lua.require("vim._core.exmode")._statuscolumn()}'
--- Formats `text` (possibly multiline) as transcript comment lines.
--- @param out string[]
--- @param text string
local function put(out, text)
for _, l in ipairs(vim.split(text, '\n', { trimempty = true })) do
out[#out + 1] = '" ' .. l
end
end
api.nvim_echo({ { N_('Entering Ex mode. Type "visual" to go to Normal mode.') } }, true, {})
local function run()
if not api.nvim_win_is_valid(caller) then
cmdwin._cleanup() -- Caller window is gone: end Ex mode.
return
end
local line = api.nvim_get_current_line()
local lnum = api.nvim_win_get_cursor(0)[1]
if lnum >= block_start then
-- Multiline commands: collect continuation lines until the block is complete.
local block = api.nvim_buf_get_lines(buf, block_start - 1, lnum, false)
if not block_complete(block) then
cont_lines[lnum + 1] = true -- Mark the continuation line.
api.nvim_buf_set_lines(buf, lnum, lnum, false, { '' })
api.nvim_win_set_cursor(0, { lnum + 1, 0 })
return
end
line = table.concat(block, '\n')
end
local parse_ok, parsed = pcall(api.nvim_parse_cmd, line:match('^[^\n]*'), {})
if parse_ok and not line:find('\n') and exit_cmds[parsed.cmd] then
vim.fn.histadd('cmd', line)
cmdwin._cleanup() -- Focuses the caller window.
vim.cmd.stopinsert()
if parsed.cmd ~= 'visual' and parsed.cmd ~= 'view' or #parsed.args > 0 then
--- @type boolean, string?
local ok, err = pcall(vim.cmd --[[@as function]], line) -- May exit Nvim.
if not ok then
util.echo_err(util.cmd_errmsg(tostring(err)))
end
end
return
end
local out = {} --- @type string[]
local prev_lnum = api.nvim_win_get_cursor(caller)[1]
local prev_tick = vim.b[api.nvim_win_get_buf(caller)].changedtick
if line == '' then
-- Empty line: advance one line (POSIX ex "+"); auto-print below shows it.
if prev_lnum >= api.nvim_buf_line_count(api.nvim_win_get_buf(caller)) then
put(out, N_('E501: At end-of-file'))
else
api.nvim_win_set_cursor(caller, { prev_lnum + 1, 0 })
end
else
local ok, res = pcall(api.nvim_win_call, caller, function()
return api.nvim_exec2(line, { output = true })
end)
if ok then
put(out, res.output)
else
put(out, util.cmd_errmsg(tostring(res)))
end
vim.fn.histadd('cmd', line)
end
if not api.nvim_win_is_valid(caller) then
-- The command closed the caller window (e.g. ":close"): continue Ex mode against a
-- remaining window, or end it.
local wins = vim.tbl_filter(
--- @param w integer
function(w)
return api.nvim_win_get_buf(w) ~= buf
end,
api.nvim_list_wins()
)
if #wins == 0 then
vim.cmd.quit() -- Only the cmdwin is left: exit.
return
end
caller = wins[1]
end
-- Ex-mode feature: auto-print the current line after a cursor move or buffer change, unless the
-- cmd already printed something (e.g. ":5,6print" should not redundantly auto-print).
local typed_text = parse_ok
and (parsed.cmd == 'append' or parsed.cmd == 'insert' or parsed.cmd == 'change')
local cbuf = api.nvim_win_get_buf(caller)
local cur = api.nvim_win_get_cursor(caller)[1]
if
#out == 0
and not typed_text
-- Bare range (":1") prints even when the cursor is already on line 1.
and (
(parse_ok and parsed.cmd == '')
or cur ~= prev_lnum
or vim.b[cbuf].changedtick ~= prev_tick
)
then
put(out, api.nvim_buf_get_lines(cbuf, cur - 1, cur, false)[1] or '')
end
-- Keep-open: record the transcript and park the cursor on a fresh last line.
out[#out + 1] = ''
api.nvim_buf_set_lines(buf, -1, -1, false, out)
block_start = api.nvim_buf_line_count(buf)
api.nvim_win_set_cursor(0, { block_start, 0 })
end
vim.keymap.set({ 'n', 'i' }, '<CR>', run, { buffer = buf })
vim.keymap.set({ 'n', 'i' }, '<NL>', run, { buffer = buf })
-- Enter Insert mode before any already-typed keys, so `gQcmd<CR>` types "cmd" into the REPL.
api.nvim_feedkeys('i', 'ni', false)
end
return M

View File

@@ -210,9 +210,8 @@ function M.ex_session_restart(eap, extra)
vim.fs.rm(session, { force = true })
-- Trim error message to be equivalent to `:restart!`
local trimmed_msg = msg:match('Vim:.*$')
if trimmed_msg then
util.echo_err(trimmed_msg:sub(5))
if msg:find('Vim:') then
util.echo_err(util.cmd_errmsg(msg))
else
error(msg)
end

View File

@@ -166,6 +166,18 @@ function M.get_forge_url(repo, target, target_type)
return ('%s/%s/%s'):format(repo, middle, target)
end
--- Gets a scrubbed message from a pcall'd command error (drops Lua context/traceback):
--- "…/editor.lua:123: Vim(put):E484: xx" => "E484: xx"
---
--- @param err string
--- @return string
function M.cmd_errmsg(err)
err = err:match('^[^\n]*') or err
--- @type string
err = err:match('Vim%b():%s*(.*)') or err:match('Vim:%s*(.*)') or (err:gsub('^.-:%d+:%s*', ''))
return (err:gsub('^Lua:%s*', ''))
end
--- Utility function for displaying vim error codes (EXX)
--- @param msg string
function M.echo_err(msg)

View File

@@ -202,6 +202,13 @@ function vim._core.ui_flush() end
--- @return boolean
function vim._core.check_interrupt() end
--- @nodoc
--- Executes one Ex command line obtained from `getline`, which is also called for any
--- continuation lines (`:append` text, `:function` body, heredoc, …). See `vim._core.exmode`.
--- @param getline fun(): string?
--- @return boolean # false if {getline} returned nil before any line was read (EOF).
function vim._core.ex_docmd(getline) end
--- @nodoc
--- Parses `keys` (internal representation) into a list of key chords. See |vim.keycode()|.
--- @param keys string

View File

@@ -3644,7 +3644,7 @@ function vim.fn.getpid() end
--- 'x Position of mark x (if the mark is not set, 0 is
--- returned for all values).
--- w0 First line visible in current window (one if the
--- display isn't updated, e.g. in silent Ex mode).
--- display isn't updated, e.g. in |silent-mode|).
--- w$ Last line visible in current window (this is one
--- less than "w0" if no lines are visible).
--- v End of the current Visual selection (unlike |'<|
@@ -6573,8 +6573,7 @@ function vim.fn.mkdir(name, flags, prot) end
--- Rvx Virtual Replace mode |i_CTRL-X| completion
--- c Command-line editing
--- cr Command-line editing overstrike mode |c_<Insert>|
--- cv Vim Ex mode |gQ|
--- cvr Vim Ex mode while in overstrike mode |c_<Insert>|
--- cv Non-interactive Ex mode |-es|
--- r Hit-enter prompt
--- rm The -- more -- prompt
--- r? A |:confirm| query of some sort