mirror of
https://github.com/neovim/neovim.git
synced 2026-08-28 01:51:52 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
223
runtime/lua/vim/_core/exmode.lua
Normal file
223
runtime/lua/vim/_core/exmode.lua
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
5
runtime/lua/vim/_meta/vimfn.gen.lua
generated
5
runtime/lua/vim/_meta/vimfn.gen.lua
generated
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user