fix(ui2): only highlight Ex command lines in the cmdline #38182

Problem:  Prompts and message text (in block mode) in the cmdline are
          parsed and highlighted as if it is Vimscript.
          Entering the cmdline while it is expanded can work more like
          it does with UI1, where the press enter prompt is replaced
          and previous messages stay on the message grid, while
          subsequent messages are placed below it.
Solution: Highlight manually with string parser on lines starting with ':'.
          Spoof cmdline block mode when the cmdline is entered while it
          is expanded.
This commit is contained in:
luukvbaal
2026-03-06 19:29:20 +01:00
committed by GitHub
parent ac6cf5b03b
commit 301c7065ca
4 changed files with 74 additions and 51 deletions

View File

@@ -2,7 +2,6 @@ local ui = require('vim._core.ui2')
local api, fn = vim.api, vim.fn local api, fn = vim.api, vim.fn
---@class vim._core.ui2.cmdline ---@class vim._core.ui2.cmdline
local M = { local M = {
highlighter = nil, ---@type vim.treesitter.highlighter?
indent = 0, -- Current indent for block event. indent = 0, -- Current indent for block event.
prompt = false, -- Whether a prompt is active; route to dialog regardless of ui.cfg.msg.target. prompt = false, -- Whether a prompt is active; route to dialog regardless of ui.cfg.msg.target.
dialog = false, -- Whether a dialog window was opened. dialog = false, -- Whether a dialog window was opened.
@@ -44,7 +43,8 @@ local promptlen = 0 -- Current length of the last line in the prompt.
---@alias CmdContent CmdChunk[] ---@alias CmdContent CmdChunk[]
---@param content CmdContent ---@param content CmdContent
---@param prompt string ---@param prompt string
local function set_text(content, prompt) ---@param hl_id integer Prompt highlight group.
local function set_text(content, prompt, hl_id)
local lines = {} ---@type string[] local lines = {} ---@type string[]
for line in (prompt .. '\n'):gmatch('(.-)\n') do for line in (prompt .. '\n'):gmatch('(.-)\n') do
lines[#lines + 1] = fn.strtrans(line) lines[#lines + 1] = fn.strtrans(line)
@@ -55,6 +55,29 @@ local function set_text(content, prompt)
end end
lines[#lines] = ('%s%s '):format(lines[#lines], fn.strtrans(cmdbuff)) lines[#lines] = ('%s%s '):format(lines[#lines], fn.strtrans(cmdbuff))
api.nvim_buf_set_lines(ui.bufs.cmd, M.srow, -1, false, lines) api.nvim_buf_set_lines(ui.bufs.cmd, M.srow, -1, false, lines)
-- Highlight prompt, or parse and highlight line starting with ':' as Vimscript.
if promptlen > 0 and hl_id > 0 then
local opts = { invalidate = true, undo_restore = false, end_col = promptlen, hl_group = hl_id }
opts.end_line = M.erow
api.nvim_buf_set_extmark(ui.bufs.cmd, ui.ns, M.srow, 0, opts)
elseif lines[1]:sub(1, 1) == ':' then
local parser = vim.treesitter.get_string_parser(lines[1], 'vim')
parser:parse(true)
parser:for_each_tree(function(tstree, tree)
local query = tstree and vim.treesitter.query.get(tree:lang(), 'highlights')
if query then
for capture, node in query:iter_captures(tstree:root(), lines[1]) do
local _, start_col, _, end_col = node:range()
if query.captures[capture]:sub(1, 1) ~= '_' then
local opts = { invalidate = true, undo_restore = false, end_col = end_col }
opts.hl_group = ('@%s.%s'):format(query.captures[capture], query.lang)
api.nvim_buf_set_extmark(ui.bufs.cmd, ui.ns, M.srow, start_col, opts)
end
end
end
end)
end
end end
--- Set the cmdline buffer text and cursor position. --- Set the cmdline buffer text and cursor position.
@@ -67,22 +90,17 @@ end
---@param level integer ---@param level integer
---@param hl_id integer ---@param hl_id integer
function M.cmdline_show(content, pos, firstc, prompt, indent, level, hl_id) function M.cmdline_show(content, pos, firstc, prompt, indent, level, hl_id)
M.level, M.indent, M.prompt = level, indent, #prompt > 0 -- When entering the cmdline while it is expanded, place cmdline below messages.
if M.highlighter == nil or M.highlighter.bufnr ~= ui.bufs.cmd then if M.level == 0 and ui.msg.cmd_on_key then
local parser = assert(vim.treesitter.get_parser(ui.bufs.cmd, 'vim', {})) M.srow = api.nvim_buf_line_count(ui.bufs.cmd)
M.highlighter = vim.treesitter.highlighter.new(parser) vim.on_key(nil, ui.msg.cmd_on_key)
end elseif ui.msg.cmd.msg_row ~= -1 and not ui.msg.cmd_on_key then
-- Only enable TS highlighter for Ex commands (not search or filter commands).
M.highlighter.active[ui.bufs.cmd] = firstc == ':' and M.highlighter or nil
if ui.msg.cmd.msg_row ~= -1 then
ui.msg.msg_clear() ui.msg.msg_clear()
end end
ui.msg.virt.last = { {}, {}, {}, {} }
set_text(content, ('%s%s%s'):format(firstc, prompt, (' '):rep(indent))) M.level, M.indent, M.prompt = level, indent, #prompt > 0
if promptlen > 0 and hl_id > 0 then set_text(content, ('%s%s%s'):format(firstc, prompt, (' '):rep(indent)), hl_id)
api.nvim_buf_set_extmark(ui.bufs.cmd, ui.ns, 0, 0, { hl_group = hl_id, end_col = promptlen }) ui.msg.virt.last = { {}, {}, {}, {} }
end
local height = math.max(ui.cmdheight, api.nvim_win_text_height(ui.wins.cmd, {}).all) local height = math.max(ui.cmdheight, api.nvim_win_text_height(ui.wins.cmd, {}).all)
win_config(ui.wins.cmd, false, height) win_config(ui.wins.cmd, false, height)
@@ -125,7 +143,15 @@ end
---@param level integer ---@param level integer
---@param abort boolean ---@param abort boolean
function M.cmdline_hide(level, abort) function M.cmdline_hide(level, abort)
if M.srow > 0 or level > (fn.getcmdwintype() == '' and 1 or 2) then if ui.msg.cmd_on_key then
if abort then
api.nvim_win_close(ui.wins.cmd, true)
ui.check_targets()
else
ui.msg.set_pos('cmd')
end
ui.msg.cmd_on_key, M.srow = nil, 0
elseif M.srow > 0 or level > (fn.getcmdwintype() == '' and 1 or 2) then
return -- No need to hide when still in nested cmdline or cmdline_block. return -- No need to hide when still in nested cmdline or cmdline_block.
end end
@@ -156,7 +182,7 @@ end
---@param lines CmdContent[] ---@param lines CmdContent[]
function M.cmdline_block_show(lines) function M.cmdline_block_show(lines)
for _, content in ipairs(lines) do for _, content in ipairs(lines) do
set_text(content, ':') set_text(content, ':', 0)
M.srow = M.srow + 1 M.srow = M.srow + 1
end end
end end
@@ -165,7 +191,7 @@ end
--- ---
---@param line CmdContent ---@param line CmdContent
function M.cmdline_block_append(line) function M.cmdline_block_append(line)
set_text(line, ':') set_text(line, ':', 0)
M.srow = M.srow + 1 M.srow = M.srow + 1
end end

View File

@@ -36,13 +36,13 @@ local M = {
delayed = false, -- Whether placement of 'last' virt_text is delayed. delayed = false, -- Whether placement of 'last' virt_text is delayed.
}, },
dialog_on_key = nil, ---@type integer? vim.on_key namespace for paging in the dialog window. dialog_on_key = nil, ---@type integer? vim.on_key namespace for paging in the dialog window.
cmd_on_key = nil, ---@type integer? vim.on_key namespace for paging in the dialog window.
} }
local cmd_on_key ---@type integer? Set to vim.on_key namespace while cmdline is expanded.
-- An external redraw indicates the start of a new batch of messages in the cmdline. -- An external redraw indicates the start of a new batch of messages in the cmdline.
api.nvim_set_decoration_provider(ui.ns, { api.nvim_set_decoration_provider(ui.ns, {
on_start = function() on_start = function()
M.cmd.ids = (ui.redrawing or cmd_on_key) and M.cmd.ids or {} M.cmd.ids = (ui.redrawing or M.cmd_on_key) and M.cmd.ids or {}
end, end,
}) })
@@ -86,7 +86,7 @@ end
---@param type 'last'|'msg'|'top'|'bot' ---@param type 'last'|'msg'|'top'|'bot'
---@param tgt? 'cmd'|'msg'|'dialog' ---@param tgt? 'cmd'|'msg'|'dialog'
local function set_virttext(type, tgt) local function set_virttext(type, tgt)
if (type == 'last' and (ui.cmdheight == 0 or M.virt.delayed)) or cmd_on_key then if (type == 'last' and (ui.cmdheight == 0 or M.virt.delayed)) or M.cmd_on_key then
return -- Don't show virtual text while cmdline is expanded or delaying for error. return -- Don't show virtual text while cmdline is expanded or delaying for error.
end end
@@ -213,10 +213,6 @@ local function expand_msg(src)
hlopts.end_col, hlopts.hl_group = mark[4].end_col, mark[4].hl_group hlopts.end_col, hlopts.hl_group = mark[4].end_col, mark[4].hl_group
api.nvim_buf_set_extmark(ui.bufs[tgt], ui.ns, srow + mark[2], mark[3], hlopts) api.nvim_buf_set_extmark(ui.bufs[tgt], ui.ns, srow + mark[2], mark[3], hlopts)
end end
if tgt == 'cmd' and ui.cmd.highlighter then
ui.cmd.highlighter.active[ui.bufs.cmd] = nil
end
else else
M.virt.msg[M.virt.idx.dupe][1] = nil M.virt.msg[M.virt.idx.dupe][1] = nil
for _, id in pairs(M.virt.ids) do for _, id in pairs(M.virt.ids) do
@@ -338,9 +334,6 @@ function M.show_msg(tgt, kind, content, replace_last, append, id)
ui.cmd.srow = row + 1 ui.cmd.srow = row + 1
else else
api.nvim_win_set_cursor(ui.wins.cmd, { 1, 0 }) -- ensure first line is visible api.nvim_win_set_cursor(ui.wins.cmd, { 1, 0 }) -- ensure first line is visible
if ui.cmd.highlighter then
ui.cmd.highlighter.active[buf] = nil
end
-- Place [+x] indicator for lines that spill over 'cmdheight'. -- Place [+x] indicator for lines that spill over 'cmdheight'.
local texth = api.nvim_win_text_height(ui.wins.cmd, {}) local texth = api.nvim_win_text_height(ui.wins.cmd, {})
local spill = texth.all > ui.cmdheight and (' [+%d]'):format(texth.all - ui.cmdheight) local spill = texth.all > ui.cmdheight and (' [+%d]'):format(texth.all - ui.cmdheight)
@@ -371,7 +364,7 @@ function M.show_msg(tgt, kind, content, replace_last, append, id)
-- Reset message state the next event loop iteration. -- Reset message state the next event loop iteration.
if not cmd_timer and (col > 0 or next(M.cmd.ids) ~= nil) then if not cmd_timer and (col > 0 or next(M.cmd.ids) ~= nil) then
cmd_timer = vim.defer_fn(function() cmd_timer = vim.defer_fn(function()
M.cmd.ids, cmd_timer, col = cmd_on_key and M.cmd.ids or {}, nil, 0 M.cmd.ids, cmd_timer, col = M.cmd_on_key and M.cmd.ids or {}, nil, 0
end, 0) end, 0)
end end
end end
@@ -480,7 +473,7 @@ function M.msg_history_show(entries, prev_cmd)
end end
-- Showing output of previous command, clear in case still visible. -- Showing output of previous command, clear in case still visible.
if cmd_on_key or prev_cmd then if M.cmd_on_key or prev_cmd then
M.msg_clear() M.msg_clear()
api.nvim_feedkeys(vim.keycode('<Esc>'), 'n', false) api.nvim_feedkeys(vim.keycode('<Esc>'), 'n', false)
end end
@@ -511,20 +504,20 @@ function M.set_pos(tgt)
cfg.title = tgt == 'dialog' and cfg.height < texth.all and { title } or nil cfg.title = tgt == 'dialog' and cfg.height < texth.all and { title } or nil
api.nvim_win_set_config(win, cfg) api.nvim_win_set_config(win, cfg)
if tgt == 'cmd' and not cmd_on_key then if tgt == 'cmd' and not M.cmd_on_key then
-- Temporarily expand the cmdline, until next key press. -- Temporarily expand the cmdline, until next key press.
local save_spill = M.virt.msg[M.virt.idx.spill][1] local save_spill = M.virt.msg[M.virt.idx.spill][1]
local spill = texth.all > cfg.height and (' [+%d]'):format(texth.all - cfg.height) local spill = texth.all > cfg.height and (' [+%d]'):format(texth.all - cfg.height)
M.virt.msg[M.virt.idx.spill][1] = spill and { 0, spill } or nil M.virt.msg[M.virt.idx.spill][1] = spill and { 0, spill } or nil
set_virttext('msg', 'cmd') set_virttext('msg', 'cmd')
M.virt.msg[M.virt.idx.spill][1] = save_spill M.virt.msg[M.virt.idx.spill][1] = save_spill
cmd_on_key = vim.on_key(function(_, typed) M.cmd_on_key = vim.on_key(function(_, typed)
typed = typed and fn.keytrans(typed) typed = typed and fn.keytrans(typed)
if not typed or typed == '<MouseMove>' then if not typed or typed == '<MouseMove>' or typed == ':' then
return return
end end
vim.on_key(nil, ui.ns) vim.on_key(nil, ui.ns)
cmd_on_key, M.cmd.ids = nil, {} M.cmd_on_key, M.cmd.ids = nil, {}
-- Check if window was entered and reopen with original config. -- Check if window was entered and reopen with original config.
local entered = typed == '<CR>' local entered = typed == '<CR>'

View File

@@ -78,17 +78,17 @@ describe('cmdline2', function()
{1:~ }|*9 {1:~ }|*9
{16::}{15:if} {26:1} | {16::}{15:if} {26:1} |
{16::} {15:echo} {26:"foo"} | {16::} {15:echo} {26:"foo"} |
{15:foo} | foo |
{16::} ^ | {16::} ^ |
]]) ]])
feed([[echo input("foo\nbar:")<CR>]]) feed([[echo input("foo\nbar:")<CR>]])
screen:expect([[ screen:expect([[
| |
{1:~ }|*7 {1:~ }|*7
:if 1 | {16::}{15:if} {26:1} |
: echo "foo" | {16::} {15:echo} {26:"foo"} |
foo | foo |
: echo input("foo\nbar:") | {16::} {15:echo} {25:input}{16:(}{26:"foo\nbar:"}{16:)} |
foo | foo |
bar:^ | bar:^ |
]]) ]])
@@ -96,10 +96,10 @@ describe('cmdline2', function()
screen:expect([[ screen:expect([[
| |
{1:~ }|*7 {1:~ }|*7
:if 1 | {16::}{15:if} {26:1} |
: echo "foo" | {16::} {15:echo} {26:"foo"} |
foo | foo |
: echo input("foo\nbar:") | {16::} {15:echo} {25:input}{16:(}{26:"foo\nbar:"}{16:)} |
foo | foo |
bar:baz^ | bar:baz^ |
]]) ]])
@@ -109,11 +109,11 @@ describe('cmdline2', function()
{1:~ }|*5 {1:~ }|*5
{16::}{15:if} {26:1} | {16::}{15:if} {26:1} |
{16::} {15:echo} {26:"foo"} | {16::} {15:echo} {26:"foo"} |
{15:foo} | foo |
{16::} {15:echo} {25:input}{16:(}{26:"foo\nbar:"}{16:)} | {16::} {15:echo} {25:input}{16:(}{26:"foo\nbar:"}{16:)} |
{15:foo} | foo |
{15:bar}:baz | bar:baz |
{15:baz} | baz |
{16::} ^ | {16::} ^ |
]]) ]])
feed('endif') feed('endif')
@@ -122,11 +122,11 @@ describe('cmdline2', function()
{1:~ }|*5 {1:~ }|*5
{16::}{15:if} {26:1} | {16::}{15:if} {26:1} |
{16::} {15:echo} {26:"foo"} | {16::} {15:echo} {26:"foo"} |
{15:foo} | foo |
{16::} {15:echo} {25:input}{16:(}{26:"foo\nbar:"}{16:)} | {16::} {15:echo} {25:input}{16:(}{26:"foo\nbar:"}{16:)} |
{15:foo} | foo |
{15:bar}:baz | bar:baz |
{15:baz} | baz |
{16::} {15:endif}^ | {16::} {15:endif}^ |
]]) ]])
feed('<CR>') feed('<CR>')
@@ -178,7 +178,7 @@ describe('cmdline2', function()
screen:expect([[ screen:expect([[
{10:f}oo | {10:f}oo |
{1:~ }|*12 {1:~ }|*12
{16::}{15:s}{16:/f}^ | {16::}{15:s}{16:/f^ } |
]]) ]])
end) end)

View File

@@ -334,10 +334,14 @@ describe('messages2', function()
]]) ]])
command('echo "foo\nbar"') command('echo "foo\nbar"')
screen:expect_unchanged() screen:expect_unchanged()
-- Place cmdline below expanded cmdline instead: #37653.
feed(':') feed(':')
screen:expect([[ screen:expect([[
| |
{1:~ }|*12 {1:~ }|*9
{3: }|
foo |
bar |
{16::}^ | {16::}^ |
]]) ]])
end) end)