From 64a301184ef39a0653c99c8edc1b63cd3fd16236 Mon Sep 17 00:00:00 2001 From: "Justin M. Keyes" Date: Fri, 14 Aug 2026 09:30:31 -0400 Subject: [PATCH] feat(input)!: CmdAtom event #41297 Problem: There is no unified notion of a "user action". Vim processes input by one-char-at-a-time, and mostly throws away any hints it might gather about the user's action, with one exception: it stores the last _edit_ action (the "redo buffer", encoded as unstructured `["x][v][count]body` bytes). Plugins can only observe individual keys (vim.on_key) and high-level effects (TextChanged, CursorMoved). Solution: - Users can subscribe to `CmdAtom` events to handle any user action. - Event is deferred; handlers cannot cancel or interfere with user actions. - Capture `CmdSpec` from the normal/insert/visual subsystems. - typeahead/readahead stay unstructured (`buffheader_T`): they are key streams, not commands. - the redo/record buffers become `StringBuilder`: fewer allocations/copies. - Repurpose the input/redo engine to accept `CmdSpec` objects. "atom": one repeatable unit of user input, as a resolved (post-mapping) keysequence plus structured fields. Only user actions, not `:normal`, API calls, or non-"t" `feedkeys`. BREAKING: dot-repeat of an Insert session, replays the entire session including cursor-moves (:help ins-repeat). BREAKING: dot-repeat of a Visual operation, replays the selection instead of operating on a fixed-size region. --- runtime/doc/autocmd.txt | 79 ++ runtime/doc/dev_arch.txt | 136 ++ runtime/doc/insert.txt | 10 +- runtime/doc/news.txt | 16 + runtime/doc/options.txt | 1 + runtime/doc/repeat.txt | 43 +- runtime/doc/vim_diff.txt | 10 +- runtime/doc/visual.txt | 44 +- runtime/lua/vim/_meta/api_keysets.gen.lua | 1 + runtime/lua/vim/_meta/events.lua | 17 + runtime/lua/vim/_meta/options.gen.lua | 1 + src/nvim/api/vim.c | 6 +- src/nvim/auevents.lua | 1 + src/nvim/autocmd.c | 9 +- src/nvim/context.c | 55 +- src/nvim/context.h | 3 +- src/nvim/context_defs.h | 24 +- src/nvim/cursor.c | 4 +- src/nvim/eval/funcs.c | 2 +- src/nvim/eval/userfunc.c | 6 +- src/nvim/event/loop.c | 2 +- src/nvim/ex_cmds.c | 4 +- src/nvim/ex_docmd.c | 32 +- src/nvim/ex_getln.c | 6 + src/nvim/input.c | 726 +++++------ src/nvim/input_cmdatom.c | 1123 +++++++++++++++++ src/nvim/input_cmdatom.h | 30 + src/nvim/input_cmdatom_defs.h | 81 ++ src/nvim/input_defs.h | 61 +- src/nvim/insert.c | 251 ++-- src/nvim/insert_defs.h | 25 +- src/nvim/insexpand.c | 8 +- src/nvim/memline.c | 6 + src/nvim/memory.c | 6 +- src/nvim/menu.c | 6 +- src/nvim/message.c | 2 +- src/nvim/mouse.c | 14 +- src/nvim/msgpack_rpc/channel.c | 1 + src/nvim/normal.c | 185 +-- src/nvim/normal_defs.h | 10 +- src/nvim/ops.c | 231 ++-- src/nvim/ops.h | 1 + src/nvim/os/shell.c | 5 +- src/nvim/os/time.c | 2 +- src/nvim/os/time_defs.h | 2 + src/nvim/profile.c | 2 +- src/nvim/register.c | 39 +- src/nvim/shada.c | 48 +- src/nvim/shada.h | 4 + src/nvim/spellsuggest.c | 9 +- src/nvim/terminal.c | 2 +- src/nvim/undo.c | 2 + test/functional/editor/atom_testutil.lua | 94 ++ test/functional/editor/cmdatom_spec.lua | 866 +++++++++++++ .../legacy/094_visual_mode_operators_spec.lua | 60 +- test/functional/legacy/listlbr_spec.lua | 4 +- test/functional/legacy/mapping_spec.lua | 4 +- test/functional/lua/comment_spec.lua | 6 +- test/old/testdir/test_increment.vim | 19 +- test/old/testdir/test_listlbr.vim | 6 +- test/old/testdir/test_mapping.vim | 6 +- test/old/testdir/test_normal.vim | 8 +- test/old/testdir/test_visual.vim | 19 +- 63 files changed, 3577 insertions(+), 909 deletions(-) create mode 100644 src/nvim/input_cmdatom.c create mode 100644 src/nvim/input_cmdatom.h create mode 100644 src/nvim/input_cmdatom_defs.h create mode 100644 test/functional/editor/atom_testutil.lua create mode 100644 test/functional/editor/cmdatom_spec.lua diff --git a/runtime/doc/autocmd.txt b/runtime/doc/autocmd.txt index 0dbff5a5fc..2a2d94c336 100644 --- a/runtime/doc/autocmd.txt +++ b/runtime/doc/autocmd.txt @@ -419,6 +419,85 @@ ChanOpen Just after a channel was opened. Sets these |v:event| keys: info as from |nvim_get_chan_info()| + *CmdAtom* +CmdAtom After a user action (an input "atom"): any + motion, operator, insert session, Visual-mode + keysequence, Ex cmdline (":cnext"), + mapping (and its sub-atoms), or scroll/mouse. + + Only for user input, not programmatic input: + INPUT ATOM ~ + typed keys yes + |nvim_input()| yes + |nvim_feedkeys()| with "t" (see note) + dot-repeat |.| yes + mapping, typed "@q" yes + |:normal| no + API requests no, lol + "@q" fed by a script no + "multicursor" replays no + aborted operation no + terminal-mode keys no + mouse drag/release no + Hydrogen yes + + Note: keys fed from a typed mapping's own + execution (e.g. its Lua callback) count as + the mapping's expansion and fold into its + atom, "t" or not. + Fired at the next event-loop tick, not + synchronously. + (the pattern) is the atom type. + + The |event-data| has these fields; a field + that does not apply is omitted (type, keys, + changed, and cascade are always present): + - arg: Typed operand of `cmd`: the "x" of "fx", + the replacement char of |r|. + |key-notation|, like `cmd`. + - atoms: The constituent atoms of a composite + (multi-command mapping, Visual sequence), + in-order. A consumer of e.g. "motion" atoms + may want to inspect these "children". + - cascade: Queued for "multicursor" replay. + - changed: Buffer was changed. + - cmd: Command/motion/object name: "w", "fx" is + "f", "iw", "gJ", "", …. |key-notation| + (not raw bytes). + - count: Effective |count|; omitted if none. + - keys: Resolved (post-mapping) keysequence, + raw internal bytes (not |key-notation|): + feed directly to |feedkeys()| or + |nvim_feedkeys()| (mode "n") to replay. Use + |keytrans()| to key-notation. Empty for + a mapping whose commands have no replayable + keys (||/Lua commands). + - lhs: LHS (user input). Raw bytes, like `keys`. + - motionforce |forced-motion|: "v", "V", or + "" (|key-notation|). + - operator: Operator: "d", "g@", "zf", …. + |key-notation|, like `cmd`. + - pending: What is awaiting input after + a mapping: "operator", "visual". + - reg: Register name. + - text: Payload text: inserted text of an + insert session (after its last cursor-move, + like |quote.|), Ex cmdline, or search. + Literal text, not key encoding. Examples: + - typed "iab" → text="ab" + - typed ":cnext" → text="cnext" + - typed "iabc" → text="c" + - type: "command", "ex", "insert", "jump", + "mapping", "motion", "mouse", "operator", + "scroll", "visual". + - "command": neither edits nor moves the + cursor (undo, folds, CTRL-W commands, …). + - "jump": moves the cursor via + absolute/shared navigation state + (|jumplist|, marks, |star|). + - "mouse"/"scroll" atoms are emit-only: + never cascaded, not replayable. + *CmdlineChanged* CmdlineChanged After EVERY change inside command line. Also triggered during mappings! Use || instead diff --git a/runtime/doc/dev_arch.txt b/runtime/doc/dev_arch.txt index 4928ead1ca..8d594e0ab6 100644 --- a/runtime/doc/dev_arch.txt +++ b/runtime/doc/dev_arch.txt @@ -169,6 +169,39 @@ Other references: - https://github.com/neovim/neovim/pull/18375 - https://github.com/neovim/neovim/pull/21605 +============================================================================== +State *dev-state* + +"Editor state" is represented by these (sometimes overlapping) concepts: + +- shada (`src/nvim/shada.c`): user data: registers, marks, …. Msgpack format. +- sessions/views (`src/nvim/ex_session.c`): windows/layout, per-window + cursor/options, CWD, …. Vimscript format: commands, not merged data. + Overlaps shada for the buffer list and global variables. +- context (`src/nvim/context.c`): |nvim_get_context()|. + Currently interfaces shada and other state. Needs more thought. +- scoped execution ("temporary state"): `ctx_switch()`, `vim._with()`, + |nvim_win_call()|, |nvim_buf_call()|, `cmdmod_T`. Temporary switches, not + snapshots. The C mechanisms overlap with each other; `vim._with()` is the + unified interface. +- reentrancy protection: save/restore around nested execution. `InsState`, + `RedoState`. + - TODO: ad hoc pairs remain for typeahead (`tasave_T`), |v:event|, view + state, cmdline, with inconsistent nesting semantics (stack vs refcount vs + single-shot). + +LONG-TERM VISION(?): shada is the authority on all user-data state, sessions +on layout state? New state kinds extend the shada schema instead of adding +a format or an in-memory sidecar. Eliminate context.c? + +LUA FUNCTIONS ACROSS PROCESSES + +The test harness transfers Lua closures between Nvim instances +(`test/functional/testnvim/exec_lua.lua`). This works ONLY between same-build +instances (LuaJIT bytecode not portable across versions/builds) and only for +trusted peers. Upvalues are limited to msgpack-able values (not +function/userdata; tables lose metatables and identity). + ============================================================================== Filesystem *dev-filesystem* @@ -502,6 +535,109 @@ When doing an `i` or `a` command, `normal_cmd()` will call the `edit()` function It contains a loop that waits for the next character and handles it. It returns when leaving Insert mode. +============================================================================== +Multicursor *dev-multicursor* *dev-cmdatom* + +Multicursor is essentially a bunch of extmarks that "cascade" the user action +(|CmdAtom|) driven by the primary cursor. + +An "atom" is any user action, as a resolved ("elemental", post-mapping) +keysequence, the same material ("redobuf") replayed by dot-repeat +(|single-repeat|). Whereas a macro is a series of meaningless characters +whose meaning is context-dependent, assigned at the moment of execution, an +atom scopes an input sequence to one, semantic, user action. Examples: "x" is +captured as "dl", an insert session as `ciwfoo`, a Visual-mode edit as +`viweed`. + +CONCEPTS + +- ATOM: A repeatable unit of user input: the resolved keysequence, plus + structured fields (`CmdSpec`). Every user action emits a |CmdAtom| event. + - SPAN: Fragment of an Insert or Visual sequence, cascades but does not emit + a CmdAtom. +- COMPOSITE: An atom composed of subatoms (`CmdAtom.atoms`): a mapping/macro's + commands, or a Visual sequence. +- INSERT-SESSION: All changes from an insert-mode session, until . + - INSERTION: An `Ins.start`..cursor "undo unit" within a session. + - INSERT-CASCADE: Replay an insert-session span-by-span as it is typed (each + span is packaged as a quasi-session: `i` + keys + ``), via nested + `edit()`. Pending literal text is a PREVIEW until its span completes. +- REPLAY: Execute keys (dot-repeat, or atom). +- CASCADE: Replay queued atoms at the "clock edge" at every cursor. Visual + cascade "dry-runs" spans to display the selection. +- VOID: When a pending (Visual) atom's keys were tainted/poisoned (by: mouse, + `gv`, a scroll that drags the cursor, …), thus not replayable. + +IMPLEMENTATION + +Capture happens at the `atom_xx()` hooks. +- `prep_redo()` marks a redoable command (operators, `r`, `J`, `p`, …). Then + `atom_cmd_end()` takes the final redobuff as the atom, (including + payloads, e.g. `d/pat`). Prep-exempt commands (yank, `D`, folds) are + reconstructed instead, in `atom_capture_op()`. +- `atom_cmd_start()` delimits a command in `normal_execute()`. Everything not + covered above is decided here (motions, jumps, scrolls, mouse). +- `atom_ins_start()` delimits an insert session. +- `atom_map_start()`, `atom_macro_start()` delimits a composite. +- `atom_visual_end()` completes the pending Visual atom, `viweex` => `viweed`. + discards it; non-replayable commands VOID it. +- `atom_cmdline_set()` gets an invoked cmdline payload (e.g. `/pat` is + a motion atom, `:cnext` is an "ex" atom.) + +DOT-REPEAT + +The redo buffer itself is the pending change atom (`RedoBuf`); the register, +Visual flag and count are FIELDS, and the command body is bytes appended +during execution by the `redo_append_xx()` family (Vim's `AppendToRedobuff*`). +The old `["x][v][count]body` byte encoding no longer exists; `prep_redo()` +stores the prefix structurally, and consumers recompose it at the boundaries +(`redo_keys()`, `start_redo()`, `atom_from_redo()`). + +THE CASCADE + +Cursors are `Context` entries in `mc_cursors`, tracked as extmarks. The +extmark is authoritative: it follows edits, and user code may delete cursors +by deleting their extmarks. Cursor-local state is swapped in on replay, so +e.g. each cursor reads/writes its own registers. + +CmdAtoms queued for cascade (`g_atoms`) are replayed at every cursor on the +"clock edge": the completion of a toplevel `normal_execute()`. While +a mapping is executing (its keys are still in typebuf) the edge is deferred +(subatoms queue/accumulate). + +Undo is buffer-global and applied in bulk. Each cascade adds exactly one undo +state, and undo/redo is itself never cascaded (`u_doit()` calls `atom_op_global_set()`). + +The insert-cascade PREVIEW/COMMIT model (why text is previewed instead of +replayed) is documented at `mc_ins_span`. Sessions that cannot insert-cascade +(count `3iZ`, Replace mode, blockwise `CTRL-V c`) are captured at . +- See `CmdAtomType` and `atom_key_class()` to understand how input is + classified to decide whether and how it can be replayed. + +RENDERING + +Display is owned by `runtime/lua/vim/_core/mcursor.lua`. the C core maintains +the model, as extmarks: + +- "nvim.multicursor": the tracking marks (cursor positions). +- "nvim.multicursor.cursor": selection-END display cursors, during Visual mode + (each cursor displays at its own selection end). Consumers (UIs) that + display cursors use these positions + while any exist, instead of the tracking marks. +- "nvim.multicursor.visual": the pending selection ranges. + +GUI SUPPORT + +A GUI gets a working display for free, via cell highlights. If it wants to +draw "real" cursors, it is expected to: +- receive positions via the `win_extmark` UI event: the tracking marks are + ui_watched, so visible cursor positions are pushed per-redraw. (Known + issue: win_extmark does not give a removal signal; re-query when in doubt.) +- or query the namespaces above with |nvim_buf_get_extmarks()|; prefer the + "nvim.multicursor.cursor" positions while any exist (in-progress Visual + selection). +- render selections from "nvim.multicursor.visual", or simply keep their + "hl-MultiCursorVisual" grid highlights. ============================================================================== diff --git a/runtime/doc/insert.txt b/runtime/doc/insert.txt index 04132d592f..9377c3c1e0 100644 --- a/runtime/doc/insert.txt +++ b/runtime/doc/insert.txt @@ -343,9 +343,13 @@ out of Insert mode. This is very handy if you prefer to use the Insert mode all the time, just like editors that don't have a separate Normal mode. You can use CTRL-O if you want to map a function key to a command. + *ins-repeat* The changes (inserted or deleted characters) before and after these keys can -be undone separately. Only the last change can be redone and always behaves -like an "i" command. +be undone separately. Relative cursor-moves (the cursor keys, , +, CTRL-G j, …) are part of the insert: |.| repeats the whole insert, +including cursor-moves. After a jump (mouse, scroll, , …) only the +last change can be redone, and it behaves like an "i" command. The |quote.| +register always holds just the text typed after the last cursor-move. char action ~ ----------------------------------------------------------------------- @@ -445,6 +449,8 @@ will be repeatable by using |.| to the expected Lorem ipsum (dolor) +Cursor keys are captured by |.| (|ins-repeat|), so these mappings are only +needed to keep the cursor-move from starting a new undo block. Using CTRL-O splits undo: the text typed before and after it is undone separately. If you want to avoid this (e.g., in a mapping) you might be able diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt index dc94fc5aef..838fa6fcc5 100644 --- a/runtime/doc/news.txt +++ b/runtime/doc/news.txt @@ -122,6 +122,20 @@ EDITOR cmdwin via |c_CTRL-F|. • Behavior of |:restart| changed. Use "!" (|:restart!|) to get the old behavior. • |ZR| now performs |:restart|. Add a count to change the behavior. +• |ins-repeat| replays the full Insert session, including cursor-moves. + Undo and |quote.| still restart at each cursor-move. Use |i_CTRL-O| to get + the old behavior: >vim + inoremap + inoremap +• |visual-repeat| is "semantic" instead of "fixed-size". See + |visual-fixed-size| if you prefer Vim's "fixed-size" Visual-repeat. +• |Q| adds a |multicursor| instead of replaying the last recorded register. + To get the old behavior: >lua + vim.keymap.set('n', 'Q', function() + local reg = vim.fn.reg_recorded() + return reg == '' and '' or ('@' .. reg) + end, { expr = true }) +< EVENTS @@ -297,6 +311,8 @@ EVENTS • |:delmarks| now triggers the |MarkSet| autocommand with line==col==0, same as |nvim_buf_del_mark()| • |ChanClose| is triggered after a channel is closed, before it is removed. +• |CmdAtom| fires after each atomic user action (motion, mapping, operator, + insert session, visual, Ex command, …). • |SessionWritePre| event emits just before |:mksession|. • |TextPutPre| and |TextPutPost| are triggered before/after putting text. • |TabMoved| is triggered when tabs are reordered. diff --git a/runtime/doc/options.txt b/runtime/doc/options.txt index 8086129f68..ce252108ea 100644 --- a/runtime/doc/options.txt +++ b/runtime/doc/options.txt @@ -2570,6 +2570,7 @@ A jump table for the options with a short description can be found at |Q_op|. |ChanClose|, |ChanInfo|, |ChanOpen|, + |CmdAtom|, |CmdUndefined|, |CmdlineChanged|, |CmdlineEnter|, diff --git a/runtime/doc/repeat.txt b/runtime/doc/repeat.txt index 370c158d63..10f1813873 100644 --- a/runtime/doc/repeat.txt +++ b/runtime/doc/repeat.txt @@ -1,7 +1,7 @@ *repeat.txt* Nvim - VIM REFERENCE MANUAL by Bram Moolenaar + NVIM REFERENCE MANUAL Repeating commands, Vim scripts and debugging *repeating* @@ -19,6 +19,9 @@ Single repeats *single-repeat* included in 'cpoptions'. Does not repeat a command-line command. + *@:* +@: Repeat last command-line [count] times. + Simple changes can be repeated with the "." command. Without a count, the count of the last change is used. If you enter a count, it will replace the last one. |v:count| and |v:count1| will be set. @@ -27,12 +30,34 @@ If the last change included a specification of a numbered register, the register number will be incremented. See |redo-register| for an example how to use this. -Note that when repeating a command that used a Visual selection, the same SIZE -of area is used, see |visual-repeat|. +Repeating an insert |ins-repeat| re-executes the whole session, including +non-jump cursor-moves: "iabc." produces "acb", not "c". Note that +jumps (mouse, |i_|, …) split an insert session. - *@:* -@: Repeat last command-line [count] times. +Repeating a Visual-mode command re-executes the captured keysequence, +selection included. See |visual-repeat|. + *motion-repeat* +Example: a "," mapping that repeats the last motion, like |;| but for any +motion ("3w", "fx", "/pat"): remember the last "motion" |CmdAtom| and +replay its keys: >lua + local last ---@type string? + vim.api.nvim_create_autocmd('CmdAtom', { + pattern = 'motion', + callback = function(ev) + last = ev.data.keys + end, + }) + vim.keymap.set('n', ',', function() + -- CmdAtom is deferred: schedule the replay after any pending event, + -- so `last` is fresh when "," follows a motion. + vim.schedule(function() + if last then + vim.api.nvim_feedkeys(last, 'n', false) -- "n": already resolved + end + end) + end) +< ============================================================================== Multiple repeats *multi-repeat* @@ -156,6 +181,9 @@ Q Repeat the last recorded register [count] times. register for each selected line. See |visual-repeat|, |default-mappings|. + *v_Q* +{Visual}Q Place a cursor on each line of the Visual selection. + *:@* :[addr]@{0-9a-z".=*+} Execute the contents of register {0-9a-z".=*+} as an Ex command. First set cursor at line [addr] (default @@ -180,6 +208,11 @@ Q Repeat the last recorded register [count] times. :[addr]@@ Repeat the previous :@{register}. First set cursor at line [addr] (default is current line). +============================================================================== +Multiple cursors *mcursor* *multicursor* + +todo + ============================================================================== Using Vim scripts *using-scripts* diff --git a/runtime/doc/vim_diff.txt b/runtime/doc/vim_diff.txt index ee33bf2242..ea57d83dc1 100644 --- a/runtime/doc/vim_diff.txt +++ b/runtime/doc/vim_diff.txt @@ -150,7 +150,6 @@ you never want any default mappings, call |:mapclear| early in your config. - |CTRL-L-default| - & |&-default| - - |dir-mappings| -- Q |v_Q-default| - @ |v_@-default| - # |v_#-default| - * |v_star-default| @@ -232,6 +231,7 @@ New Features *nvim-features* MAJOR COMPONENTS - API |API| +- Command atoms |CmdAtom| - Job control |job-control| - LSP framework |lsp| - Lua scripting |lua| |-l| @@ -335,6 +335,10 @@ Commands: - |:uptime| Editor: +- |ins-repeat| replays the full Insert session, including cursor-moves: + "iabc." produces "acb", not "c". +- |visual-repeat| is "semantic" instead of "fixed-size". If you prefer Vim's + "fixed-size" Visual-repeat, see |visual-fixed-size|. - Interactive |Ex-mode| is implemented as persistent, insert-mode |cmdwin|. - |prompt-buffer| supports multiline input/paste, undo/redo, and o/O normal commands. @@ -402,7 +406,9 @@ Input/Mappings: Normal commands: - |gO| shows a filetype-defined "outline" of the current buffer. -- |Q| replays the last recorded macro instead of switching to Ex mode. +- |gQ| restores |multicursor|s. +- |Q| adds a |multicursor| ([count]Q: at each match; {Visual}Q: at each + selected line |v_Q|) instead of switching to Ex mode. - [count]q: enters |Ex-mode|. - |ZR| performs |:restart| - |v_al| selects the whole buffer; |v_il| selects the current line without diff --git a/runtime/doc/visual.txt b/runtime/doc/visual.txt index c23bf3f732..3dfbeca758 100644 --- a/runtime/doc/visual.txt +++ b/runtime/doc/visual.txt @@ -359,17 +359,39 @@ See |v_b_r_example|. ============================================================================== 6. Repeating *visual-repeat* -When repeating a Visual mode operator, the operator will be applied to the -same amount of text as the last time: -- Linewise Visual mode: The same number of lines. -- Blockwise Visual mode: The same number of lines and columns. -- Normal Visual mode within one line: The same number of characters. -- Normal Visual mode with several lines: The same number of lines, in the - last line the same number of characters as in the last line the last time. -The start of the text is the Cursor position. If the "$" command was used as -one of the last commands to extend the highlighted text, the repeating will -be applied up to the rightmost column of the longest line. Any count passed -to the `.` command is not used. +Repeating a Visual operation works "semantically": |.| replays the captured +keysequence (Visual selection plus operator) at the cursor: "viwd" then "." +deletes the word at the cursor, whatever its size. (Note: this is unlike Vim, +where Visual-repeat works on the same fixed-size region. If you prefer that +behavior, see |visual-fixed-size|.) + +A [count] given to "." is prepended to the replayed keys. If the selection was +extended by a command that cannot be replayed (mouse, search, Ex command, +scroll), "." instead applies the operator to a same-size area, as if "1v" +(|v|) preceded it. + + *visual-fixed-size* +Example: To mimic Vim's fixed-size Visual dot-repeat: after a Visual +operation, reselect the fixed-size region ("1v", |v|) and applies the +operator: >lua + + local vop ---@type string? + vim.api.nvim_create_autocmd('CmdAtom', { + ---@param ev {data: vim.event.cmdatom.data} + callback = function(ev) + if ev.data.type == 'visual' then + vop = ev.data.atoms and ev.data.atoms[#ev.data.atoms].keys or nil + elseif ev.data.changed then + vop = nil -- the last change is no longer the Visual one + end + end, + }) + vim.keymap.set('n', '.', function() + vim.schedule(function() + vim.api.nvim_feedkeys(vop and ('1v' .. vop) or '.', 'n', false) + end) + end) +< Visual mode |default-mappings| "@" and "Q" repeat a register for all selected lines if the selection is linewise. See |v_@-default| and |v_Q-default| for diff --git a/runtime/lua/vim/_meta/api_keysets.gen.lua b/runtime/lua/vim/_meta/api_keysets.gen.lua index 185f658ad1..d4982d2daf 100644 --- a/runtime/lua/vim/_meta/api_keysets.gen.lua +++ b/runtime/lua/vim/_meta/api_keysets.gen.lua @@ -105,6 +105,7 @@ error('Cannot require a meta file') --- |'ChanClose' --- |'ChanInfo' --- |'ChanOpen' +--- |'CmdAtom' --- |'CmdUndefined' --- |'CmdlineChanged' --- |'CmdlineEnter' diff --git a/runtime/lua/vim/_meta/events.lua b/runtime/lua/vim/_meta/events.lua index a88464ac5c..ae2130d90e 100644 --- a/runtime/lua/vim/_meta/events.lua +++ b/runtime/lua/vim/_meta/events.lua @@ -4,6 +4,23 @@ -- See also `vim.api.keyset.events` in `api_keysets.gen.lua`. error('Cannot require a meta file') +--- Data for the CmdAtom event. +--- @class vim.event.cmdatom.data +--- @field arg? string Typed operand of `cmd` ("fx" => "x"). +--- @field atoms? vim.event.cmdatom.data[] Subatoms of a composite (mapping, Visual sequence). +--- @field cascade boolean Queued for multicursor replay. +--- @field changed boolean Changed the buffer. +--- @field cmd? string Command/motion/object name ("w", "f", "iw", "gJ"). +--- @field count? integer Effective count. +--- @field keys string Resolved keysequence, raw bytes: feed to nvim_feedkeys() to replay. +--- @field lhs? string Mapping LHS or macro register ("gj", "@q"). Raw bytes, like `keys`. +--- @field motionforce? 'v'|'V'|'' forced-motion type. +--- @field operator? string Operator name ("d", "g~", "g@"). key-notation. +--- @field pending? 'operator'|'visual' Mapping ended mid-operation. +--- @field reg? string Register name. +--- @field text? string Inserted text, or the Ex/search cmdline. +--- @field type 'command'|'ex'|'insert'|'jump'|'mapping'|'motion'|'mouse'|'operator'|'scroll'|'visual' + --- @class vim.event.lspattach.data --- @field client_id integer diff --git a/runtime/lua/vim/_meta/options.gen.lua b/runtime/lua/vim/_meta/options.gen.lua index 6932bbfca5..65d243e4be 100644 --- a/runtime/lua/vim/_meta/options.gen.lua +++ b/runtime/lua/vim/_meta/options.gen.lua @@ -2193,6 +2193,7 @@ vim.go.ei = vim.go.eventignore --- `ChanClose`, --- `ChanInfo`, --- `ChanOpen`, +--- `CmdAtom`, --- `CmdUndefined`, --- `CmdlineChanged`, --- `CmdlineEnter`, diff --git a/src/nvim/api/vim.c b/src/nvim/api/vim.c index 4aa1e1823c..fd3f5eb21a 100644 --- a/src/nvim/api/vim.c +++ b/src/nvim/api/vim.c @@ -31,6 +31,7 @@ #include "nvim/drawline.h" #include "nvim/drawscreen.h" #include "nvim/errors.h" +#include "nvim/eval.h" #include "nvim/eval/typval.h" #include "nvim/eval/typval_defs.h" #include "nvim/eval/vars.h" @@ -45,6 +46,7 @@ #include "nvim/highlight_group.h" #include "nvim/input.h" #include "nvim/input_defs.h" +#include "nvim/insert.h" #include "nvim/insexpand.h" #include "nvim/keycodes.h" #include "nvim/log.h" @@ -1508,7 +1510,7 @@ Dict nvim_get_context(Dict(context) *opts, Arena *arena, Error *err) types = opts->types; } - int int_types = types.size > 0 ? 0 : kCtxAll; + CtxStateFlags int_types = types.size > 0 ? 0 : kCtxAll; if (types.size > 0) { for (size_t i = 0; i < types.size; i++) { if (types.items[i].type == kObjectTypeString) { @@ -1554,7 +1556,7 @@ Object nvim_load_context(Dict dict, Error *err) ctx_from_dict(dict, &ctx, err); if (!ERROR_SET(err)) { - ctx_load(&ctx, kCtxAll); + ctx_load(&ctx, kCtxAll, 0); } ctx_free(&ctx); diff --git a/src/nvim/auevents.lua b/src/nvim/auevents.lua index 2497d90a17..1f4c186840 100644 --- a/src/nvim/auevents.lua +++ b/src/nvim/auevents.lua @@ -25,6 +25,7 @@ return { ChanClose = false, ChanInfo = false, -- info was received about channel ChanOpen = false, -- channel was opened + CmdAtom = false, -- after an atomic user operation (motion, operator, insert, mapping, …) CmdUndefined = false, -- command undefined CmdlineChanged = false, -- command line was modified CmdlineEnter = false, -- after entering cmdline mode diff --git a/src/nvim/autocmd.c b/src/nvim/autocmd.c index 4cd1485ff1..65d1543f96 100644 --- a/src/nvim/autocmd.c +++ b/src/nvim/autocmd.c @@ -1449,7 +1449,7 @@ bool apply_autocmds_group(event_T event, char *fname, char *fname_io, bool force static bool filechangeshell_busy = false; proftime_T wait_time; bool did_save_redobuff = false; - save_redo_T save_redo; + RedoState save_redo; const bool save_KeyTyped = KeyTyped; ESTACK_CHECK_DECLARATION; CtxSwitch aco = { 0 }; @@ -1578,7 +1578,8 @@ bool apply_autocmds_group(event_T event, char *fname, char *fname_io, bool force } else { sfname = TO_SLASH_SAVE(fname); // Don't try expanding the following events. - if (event == EVENT_CMDLINECHANGED + if (event == EVENT_CMDATOM + || event == EVENT_CMDLINECHANGED || event == EVENT_CMDLINEENTER || event == EVENT_CMDLINELEAVEPRE || event == EVENT_CMDLINELEAVE @@ -1652,7 +1653,7 @@ bool apply_autocmds_group(event_T event, char *fname, char *fname_io, bool force if (!autocmd_busy) { save_search_patterns(); if (!ins_compl_active()) { - saveRedobuff(&save_redo); + save_redobuff(&save_redo); did_save_redobuff = true; } curbuf->b_did_filetype = curbuf->b_keep_filetype; @@ -1768,7 +1769,7 @@ bool apply_autocmds_group(event_T event, char *fname, char *fname_io, bool force if (!autocmd_busy) { restore_search_patterns(); if (did_save_redobuff) { - restoreRedobuff(&save_redo); + restore_redobuff(&save_redo); } curbuf->b_did_filetype = false; while (au_pending_free_buf != NULL) { diff --git a/src/nvim/context.c b/src/nvim/context.c index 6575f79000..7afab8d891 100644 --- a/src/nvim/context.c +++ b/src/nvim/context.c @@ -43,15 +43,15 @@ #include "nvim/option_defs.h" #include "nvim/option_vars.h" #include "nvim/os/fs.h" +#include "nvim/register.h" #include "nvim/shada.h" +#include "nvim/state_defs.h" #include "nvim/vim_defs.h" #include "nvim/window.h" #include "nvim/winfloat.h" #include "context.c.generated.h" -int kCtxAll = (kCtxRegs | kCtxJumps | kCtxBufs | kCtxGVars | kCtxSFuncs | kCtxFuncs); - /// Nesting depth of ctx_switch() calls that changed curwin. static int _ctx_switch_depth = 0; @@ -74,17 +74,22 @@ void ctx_free(Context *ctx) api_free_array(ctx->funcs); } -/// Saves the editor state to a context. -/// -/// Use "flags" to select particular types of context. +/// Saves the editor state (ALL THE THINGS!!!1) to a context. /// /// @param ctx Save to this context. -/// @param flags Flags, see ContextTypeFlags enum. -void ctx_save(Context *ctx, const int flags) +/// @param flags State types to save. +void ctx_save(Context *ctx, const CtxStateFlags flags) FUNC_ATTR_NONNULL_ALL { + ctx->buf = curbuf->handle; + ctx->pos = (pos_T) { + .lnum = curwin->w_cursor.lnum, + .col = curwin->w_cursor.col, + .coladd = curwin->w_cursor.coladd, + }; + if (flags & kCtxRegs) { - ctx->regs = shada_encode_regs(); + ctx->regs = shada_encode_regs(false, 0); } if (flags & kCtxJumps) { @@ -125,32 +130,37 @@ void ctx_save(Context *ctx, const int flags) } } -/// Loads (restores) the editor state from a Context snapshot. -/// -/// Use "flags" to select particular types of context. +/// Loads (restores) the editor state from a Context snapshot. Restores registers EXACTLY, unless +/// kCtxMergeReg is specified. /// /// @param ctx Load from this context. -/// @param flags Flags, see ContextTypeFlags enum. -void ctx_load(Context *ctx, const int flags) +/// @param flags State types to load. +/// @param loadflags Controls load behavior. +void ctx_load(Context *ctx, const CtxStateFlags flags, const CtxLoadFlags loadflags) FUNC_ATTR_NONNULL_ALL { - Object op_shada = get_option_value(kOptShada, OPT_GLOBAL); - set_option_value(kOptShada, STATIC_CSTR_AS_OBJ("!,'100,%"), OPT_GLOBAL); + // TODO(jkeyes): restore window, mode, pos? if (flags & kCtxRegs) { - shada_read_string(ctx->regs, kShaDaWantInfo | kShaDaForceit); + if (!(loadflags & kCtxMergeReg)) { + // Avoid shada "merge" behavior for registers; restore "exact", don't merge. + for (int i = 0; i < NUM_SAVED_REGISTERS; i++) { + free_register(get_y_register(i)); + } + } + shada_read_string(ctx->regs, kShaDaWantInfo | kShaDaForceit | kShaDaNanos | kShaDaNoHistory); } if (flags & kCtxJumps) { - shada_read_string(ctx->jumps, kShaDaWantInfo | kShaDaForceit); + shada_read_string(ctx->jumps, kShaDaWantInfo | kShaDaForceit | kShaDaNoHistory); } if (flags & kCtxBufs) { - shada_read_string(ctx->bufs, kShaDaWantInfo | kShaDaForceit); + shada_read_string(ctx->bufs, kShaDaWantInfo | kShaDaForceit | kShaDaNoHistory | kShaDaNoOpt); } if (flags & kCtxGVars) { - shada_read_string(ctx->gvars, kShaDaWantInfo | kShaDaForceit); + shada_read_string(ctx->gvars, kShaDaWantInfo | kShaDaForceit | kShaDaNoHistory | kShaDaNoOpt); } if (flags & kCtxFuncs) { @@ -158,9 +168,6 @@ void ctx_load(Context *ctx, const int flags) do_cmdline_cmd(ctx->funcs.items[i].data.string.data); } } - - set_option_value(kOptShada, op_shada, OPT_GLOBAL); - optval_free(op_shada); } /// Convert readfile()-style array to String @@ -215,12 +222,12 @@ Dict ctx_to_dict(Context *ctx, Arena *arena) /// @param[out] err Error object. /// /// @return types of included context items. -int ctx_from_dict(Dict dict, Context *ctx, Error *err) +CtxStateFlags ctx_from_dict(Dict dict, Context *ctx, Error *err) FUNC_ATTR_NONNULL_ALL { assert(ctx != NULL); - int types = 0; + CtxStateFlags types = 0; for (size_t i = 0; i < dict.size && !ERROR_SET(err); i++) { KeyValuePair item = dict.items[i]; if (item.value.type != kObjectTypeArray) { diff --git a/src/nvim/context.h b/src/nvim/context.h index 3dd1930f8a..336bd43b05 100644 --- a/src/nvim/context.h +++ b/src/nvim/context.h @@ -1,12 +1,11 @@ #pragma once #include // IWYU pragma: keep +#include #include "nvim/context_defs.h" // IWYU pragma: export #include "nvim/macros_defs.h" -extern int kCtxAll; - /// Pool of temporary scratch windows (fka "autocmd windows"), for ctx_switch(). EXTERN kvec_t(CtxWin) ctx_win_vec INIT( = KV_INITIAL_VALUE); #define ctx_win (ctx_win_vec.items) diff --git a/src/nvim/context_defs.h b/src/nvim/context_defs.h index c1474ab736..c45e744fec 100644 --- a/src/nvim/context_defs.h +++ b/src/nvim/context_defs.h @@ -10,15 +10,23 @@ #include "nvim/types_defs.h" typedef struct { - String regs; ///< Registers. - String jumps; ///< Jumplist. - String bufs; ///< Buffer list. - String gvars; ///< Global variables. - Array funcs; ///< Functions. + pos_T pos; ///< Current cursor position (cache, see `mark`). + uint32_t mark; ///< Extmark id tracking `pos` across buffer edits. + colnr_T curswant; ///< Preferred column ("curswant"); -1 if unset. + handle_T buf; ///< Current buffer handle. + String regs; ///< Registers (shada msgpack string). + String jumps; ///< Jumplist (shada msgpack string). + String bufs; ///< Buffer list (shada msgpack string). + String gvars; ///< Global variables (shada msgpack string). + Array funcs; ///< Functions. } Context; typedef kvec_t(Context) ContextVec; #define CONTEXT_INIT (Context) { \ + .pos = { 0 }, \ + .mark = 0, \ + .curswant = -1, \ + .buf = 0, \ .regs = STRING_INIT, \ .jumps = STRING_INIT, \ .bufs = STRING_INIT, \ @@ -33,8 +41,14 @@ typedef enum { kCtxGVars = 8, ///< Global variables kCtxSFuncs = 16, ///< Script functions kCtxFuncs = 32, ///< Functions + kCtxAll = kCtxRegs | kCtxJumps | kCtxBufs | kCtxGVars | kCtxSFuncs | kCtxFuncs, } CtxStateFlags; +/// "How" to load, orthogonal to "what" (CtxStateFlags). +typedef enum { + kCtxMergeReg = 1, ///< Merge incoming registers with existing. +} CtxLoadFlags; + /// Temporary, hidden window (fka "autocmd window"): a pooled window created to temporarily show /// a buffer that has no window (ctx_switch() on a buffer target), to handle the side effects. When /// switches nest we may need more than one. diff --git a/src/nvim/cursor.c b/src/nvim/cursor.c index 239f024c93..4cb6463835 100644 --- a/src/nvim/cursor.c +++ b/src/nvim/cursor.c @@ -260,9 +260,7 @@ int inc_cursor(void) return inc(&curwin->w_cursor); } -/// Decrement the line pointer 'p' crossing line boundaries as necessary. -/// -/// @return 1 when crossing a line, -1 when at start of file, 0 otherwise. +/// Decrement the cursor position. See dec() for return values. int dec_cursor(void) { return dec(&curwin->w_cursor); diff --git a/src/nvim/eval/funcs.c b/src/nvim/eval/funcs.c index 038f716be0..a54056164e 100644 --- a/src/nvim/eval/funcs.c +++ b/src/nvim/eval/funcs.c @@ -6778,7 +6778,7 @@ static void f_reltimefloat(typval_T *argvars, typval_T *rettv, EvalFuncData fptr rettv->v_type = VAR_FLOAT; rettv->vval.v_float = 0; if (list2proftime(&argvars[0], &tm) == OK) { - rettv->vval.v_float = (float_T)profile_signed(tm) / 1000000000.0; + rettv->vval.v_float = (float_T)profile_signed(tm) / NS_PER_SEC; } } diff --git a/src/nvim/eval/userfunc.c b/src/nvim/eval/userfunc.c index 4ba8cc0563..b37d8ff92a 100644 --- a/src/nvim/eval/userfunc.c +++ b/src/nvim/eval/userfunc.c @@ -1009,7 +1009,7 @@ void call_user_func(ufunc_T *fp, int argcount, typval_T *argvars, typval_T *rett proftime_T call_start; bool started_profiling = false; bool did_save_redo = false; - save_redo_T save_redo; + RedoState save_redo; ESTACK_CHECK_DECLARATION; // If depth of calling is getting too high, don't execute the function @@ -1023,7 +1023,7 @@ void call_user_func(ufunc_T *fp, int argcount, typval_T *argvars, typval_T *rett // Save search patterns and redo buffer. save_search_patterns(); if (!ins_compl_active()) { - saveRedobuff(&save_redo); + save_redobuff(&save_redo); did_save_redo = true; } fp->uf_calls++; @@ -1368,7 +1368,7 @@ void call_user_func(ufunc_T *fp, int argcount, typval_T *argvars, typval_T *rett } // restore search patterns and redo buffer if (did_save_redo) { - restoreRedobuff(&save_redo); + restore_redobuff(&save_redo); } restore_search_patterns(); } diff --git a/src/nvim/event/loop.c b/src/nvim/event/loop.c index f47b3a334c..85b8dc8b36 100644 --- a/src/nvim/event/loop.c +++ b/src/nvim/event/loop.c @@ -165,7 +165,7 @@ bool loop_close(Loop *loop, bool wait) if ((uv_loop_close(&loop->uv) != UV_EBUSY) || !wait) { break; } - uint64_t elapsed_s = (os_hrtime() - start) / 1000000000; // seconds + uint64_t elapsed_s = (os_hrtime() - start) / NS_PER_SEC; if (elapsed_s >= 2) { // Some libuv resource was not correctly deref'd. Log and bail. rv = false; diff --git a/src/nvim/ex_cmds.c b/src/nvim/ex_cmds.c index 4074f778c9..7f6c807b1c 100644 --- a/src/nvim/ex_cmds.c +++ b/src/nvim/ex_cmds.c @@ -1201,9 +1201,9 @@ void do_bang(int addr_count, exarg_T *eap, bool forceit, bool do_in, bool do_out // buffername. char *cmd = vim_strsave_escaped(prevcmd, "%#"); - AppendToRedobuffLit(cmd, -1); + redo_append_lit(cmd, -1); xfree(cmd); - AppendToRedobuff("\n"); + redo_append_str(S_LEN("\n")); bangredo = false; } // Add quotes around the command, for shells that need them. diff --git a/src/nvim/ex_docmd.c b/src/nvim/ex_docmd.c index 10ad94c51c..09a1817867 100644 --- a/src/nvim/ex_docmd.c +++ b/src/nvim/ex_docmd.c @@ -7075,9 +7075,7 @@ void update_topline_cursor(void) } /// Save the current State and go to Normal mode. -/// -/// @return true if the typeahead could be saved. -bool save_current_state(save_state_T *sst) +void save_current_state(save_state_T *sst) FUNC_ATTR_NONNULL_ALL { sst->save_msg_scroll = msg_scroll; @@ -7096,7 +7094,6 @@ bool save_current_state(save_state_T *sst) // from an event handler and makes sure we don't hang when the argument // ends with half a command. save_typeahead(&sst->tabuf); - return sst->tabuf.typebuf_valid; } void restore_current_state(save_state_T *sst) @@ -7185,21 +7182,20 @@ static void ex_normal(exarg_T *eap) ex_normal_busy++; save_state_T save_state; - if (save_current_state(&save_state)) { - // Repeat the :normal command for each line in the range. When no - // range given, execute it just once, without positioning the cursor - // first. - do { - if (eap->addr_count != 0) { - curwin->w_cursor.lnum = eap->line1++; - curwin->w_cursor.col = 0; - check_cursor_moved(curwin); - } + save_current_state(&save_state); + // Repeat the :normal command for each line in the range. When no + // range given, execute it just once, without positioning the cursor + // first. + do { + if (eap->addr_count != 0) { + curwin->w_cursor.lnum = eap->line1++; + curwin->w_cursor.col = 0; + check_cursor_moved(curwin); + } - exec_normal_cmd((arg != NULL ? arg : eap->arg), - eap->forceit ? REMAP_NONE : REMAP_YES, false); - } while (eap->addr_count > 0 && eap->line1 <= eap->line2 && !got_int); - } + exec_normal_cmd((arg != NULL ? arg : eap->arg), + eap->forceit ? REMAP_NONE : REMAP_YES, false); + } while (eap->addr_count > 0 && eap->line1 <= eap->line2 && !got_int); // Might not return to the main loop when in an event handler. update_topline_cursor(); diff --git a/src/nvim/ex_getln.c b/src/nvim/ex_getln.c index 95c1302c97..5a50b3842d 100644 --- a/src/nvim/ex_getln.c +++ b/src/nvim/ex_getln.c @@ -48,6 +48,7 @@ #include "nvim/highlight_defs.h" #include "nvim/highlight_group.h" #include "nvim/input.h" +#include "nvim/input_cmdatom.h" #include "nvim/insert.h" #include "nvim/keycodes.h" #include "nvim/lua/executor.h" @@ -952,6 +953,11 @@ static uint8_t *command_line_enter(int firstc, int count, int indent, bool clear } } + if (!s->gotesc && ccline.level == 1) { + // An accepted toplevel cmdline is the executing command's payload (":cnext", "/pat"). + atom_cmdline_set(s->firstc, ccline.cmdbuff, (size_t)ccline.cmdlen); + } + if (s->gotesc) { abandon_cmdline(); } diff --git a/src/nvim/input.c b/src/nvim/input.c index 59dd74d657..b6d53499bf 100644 --- a/src/nvim/input.c +++ b/src/nvim/input.c @@ -1,9 +1,10 @@ -// input.c: the input engine. +// input.c: The input engine "bytes layer" (input_cmdatom.c. is the "policy layer"). +// // - Get a character from the user, a script file, or the keybufs described below. // - Apply mappings and abbreviations to typed keys (the :map tables live in mapping.c; the // matching loop is handle_mapping()). // - Assemble K_SPECIAL/multibyte byte sequences into keys (vgetc()). -// - Record macros (recordbuff) and the redo keys (redobuff: "." capture and replay). +// - Record macros (recordbuff); capture the redo state (redobuff: "." capture and replay). // // Concepts: // - "Stuffing" = when some internal logic pushes keys to execute next. This is how a cmd @@ -21,8 +22,11 @@ // - TWO stuff buffers, because stuffing nests: a cmd executed FROM redo keys (readbuf2) may // itself stuff a translation (readbuf1), which must be consumed before the remaining redo. // - `typebuf`: typeahead (see below). -// - `redobuff`: the keys of the last change ("." stuffs them, start_redo()); -// `old_redobuff` is the previous change, for "CTRL-O ." in Insert mode. +// - `redobuff` (RedoState): the last change; dot-repeat "." replays it (start_redo()). +// - `redobuff.cur` = the last change. "." replays it IN-PLACE (start_redo()), the multicursor +// cascade replays it PER-CURSOR (mc_cascade()). Same keysequence, either way: a Visual-mode +// change re-executes its captured selection). +// - `redobuff.old` = the previous change; see `redo_new`. // - `recordbuff`: accumulates the keys of a recording ("q"). // // Buffer bytes are encoded as follows: @@ -66,6 +70,7 @@ #include "nvim/gettext_defs.h" #include "nvim/globals.h" #include "nvim/input.h" +#include "nvim/input_cmdatom.h" #include "nvim/insert.h" #include "nvim/insexpand.h" #include "nvim/keycodes.h" @@ -118,15 +123,16 @@ static FileDescriptor scriptin[NSCRIPT] = { 0 }; #define MINIMAL_SIZE 20 // minimal size for b_str -static buffheader_T redobuff = { { NULL, 0, { NUL } }, NULL, 0, 0, false }; -static buffheader_T old_redobuff = { { NULL, 0, { NUL } }, NULL, 0, 0, false }; -static buffheader_T recordbuff = { { NULL, 0, { NUL } }, NULL, 0, 0, false }; - -/// First read ahead buffer. Used for translated commands. -static buffheader_T readbuf1 = { { NULL, 0, { NUL } }, NULL, 0, 0, false }; - -/// Second read ahead buffer. Used for redo. -static buffheader_T readbuf2 = { { NULL, 0, { NUL } }, NULL, 0, 0, false }; +#define REDO_INIT { { 0 }, KV_INITIAL_VALUE } +/// The redo state: redo_append_*() captures keys in `redobuff.cur`; "." replays it (start_redo()). +static RedoState redobuff = { REDO_INIT, REDO_INIT }; +/// Macro recording. Perf: StringBuilder (not buffheader_T) => fewer allocs/copies. +static StringBuilder recordbuff = KV_INITIAL_VALUE; +/// First readahead buffer ("stuffbuf"): command translations ("x" => "dl"). Drains before readbuf2. +static buffheader_T readbuf1 = BUFFHEADER_INIT; +/// Second readahead buffer ("stuffbuf"): "." replay (start_redo()). +static buffheader_T readbuf2 = BUFFHEADER_INIT; +static UngotKey ungot = { .c = -1 }; /// Buffer used to store typed characters for vim.on_key(). static kvec_withinit_t(char, MAXMAPLEN + 1) on_key_buf = KVI_INITIAL_VALUE(on_key_buf); @@ -187,8 +193,9 @@ static const char e_cmd_mapping_must_end_with_cr[] static const char e_cmd_mapping_must_end_with_cr_before_second_cmd[] = N_("E1136: mapping must end with before second "); -/// Free and clear a buffer. +/// Frees every block; the buffer becomes empty (add_buff() re-seeds it). static void free_buff(buffheader_T *buf) + FUNC_ATTR_NONNULL_ALL { buffblock_T *np; @@ -200,55 +207,15 @@ static void free_buff(buffheader_T *buf) buf->bh_curr = NULL; } -/// Return the contents of a buffer as a single string. -/// K_SPECIAL in the returned string is escaped. -/// -/// @param dozero count == zero is not an error -/// @param len the length of the returned buffer -static char *get_buffcont(buffheader_T *buffer, int dozero, size_t *len) -{ - size_t count = 0; - char *p = NULL; - size_t i = 0; - - // compute the total length of the string - for (const buffblock_T *bp = buffer->bh_first.b_next; - bp != NULL; bp = bp->b_next) { - count += bp->b_strlen; - } - - if (count > 0 || dozero) { - p = xmalloc(count + 1); - char *p2 = p; - for (const buffblock_T *bp = buffer->bh_first.b_next; - bp != NULL; bp = bp->b_next) { - for (const char *str = bp->b_str; *str;) { - *p2++ = *str++; - } - } - *p2 = NUL; - i = (size_t)(p2 - p); - } - - if (len != NULL) { - *len = i; - } - - return p; -} - /// Return the contents of the record buffer as a single string /// and clear the record buffer. /// K_SPECIAL in the returned string is escaped. char *get_recorded(void) { - size_t len; - char *p = get_buffcont(&recordbuff, true, &len); - if (p == NULL) { - return NULL; - } - - free_buff(&recordbuff); + size_t len = recordbuff.size; + kv_push(recordbuff, NUL); + char *p = recordbuff.items; // ownership moves to the caller + recordbuff = (StringBuilder)KV_INITIAL_VALUE; // Remove the characters that were added the last time, these must be the // (possibly mapped) characters that stopped the recording. @@ -266,21 +233,89 @@ char *get_recorded(void) return p; } -/// Return the contents of the redo buffer as a single string. -/// K_SPECIAL in the returned string is escaped. -String get_inserted(void) +/// Appends the composed `["x][count]` keysequence prefix of `spec` to `buf`. +/// Every prefix emission goes through here. +/// +/// @param replay Composing an actual replay (start_redo()): a `"=` register spec appends , +/// re-evaluating the last expression. +void redo_prefix(const CmdSpec *spec, StringBuilder *buf, bool replay) + FUNC_ATTR_NONNULL_ARG(1) { - size_t len = 0; - char *str = get_buffcont(&redobuff, false, &len); - return cbuf_as_string(str, len); + if (spec->regname != 0) { + sb_add_char(buf, '"'); + sb_add_char(buf, spec->regname); + if (replay && spec->regname == '=') { + sb_add_char(buf, CAR); + } + } + if (spec->count != 0) { + kv_printf(*buf, "%d", (int)spec->count); + } } -/// Add string after the current block of the given buffer +/// Appends the composed command chars of `spec` to `buf`. +/// Every command-char emission goes through here (see redo_prefix()). +/// +/// @param arg_meta Skip the `arg` byte (see prep_redo()). +void redo_chars(const CmdSpec *spec, StringBuilder *buf, bool arg_meta) + FUNC_ATTR_NONNULL_ALL +{ + if (spec->op != NUL) { + sb_add_char(buf, spec->op); + } + if (spec->op_extra != NUL) { + sb_add_char(buf, spec->op_extra); + } + if (spec->motion_force != NUL) { + sb_add_char(buf, spec->motion_force); + } + if (spec->cmd != NUL) { + sb_add_char(buf, spec->cmd); + } + if (spec->cmd2 != NUL) { + sb_add_char(buf, spec->cmd2); + } + if (spec->arg != NUL && !arg_meta) { + sb_add_char(buf, spec->arg); + } +} + +/// Composes a redo's full keysequence: the `["x][v][count]` prefix (from the fields) followed by +/// the command body. +/// +/// @return allocated String; .data == NULL if the redo is empty. +static String redo_compose(RedoBuf *r) + FUNC_ATTR_NONNULL_ALL FUNC_ATTR_WARN_UNUSED_RESULT +{ + StringBuilder buf = KV_INITIAL_VALUE; + redo_prefix(&r->spec, &buf, false); + kv_splice(buf, r->keys); + if (buf.size == 0) { + return (String)STRING_INIT; + } + kv_push(buf, NUL); + return cbuf_as_string(buf.items, buf.size - 1); +} + +/// Gets the pending change's keysequence (redo_compose()), allocated. +String redo_keys(void) + FUNC_ATTR_WARN_UNUSED_RESULT +{ + return redo_compose(&redobuff.cur); +} + +/// Gets the pending change's CmdSpec. +CmdSpec redo_spec(void) +{ + return redobuff.cur.spec; +} + +/// Append string after the current block of the given buffer /// /// K_SPECIAL should have been escaped already. /// -/// @param[out] buf Buffer to add to. -/// @param[in] s String to add. +/// @param[out] buf Buffer to append to. +/// @param[in] s String to append. /// @param[in] slen String length or -1 for NUL-terminated string. static void add_buff(buffheader_T *const buf, const char *const s, ptrdiff_t slen) { @@ -324,70 +359,76 @@ static void add_buff(buffheader_T *const buf, const char *const s, ptrdiff_t sle } } -/// Delete "slen" bytes from the end of "buf". -/// Only works when it was just added. -static void delete_buff_tail(buffheader_T *buf, int slen) -{ - if (buf->bh_curr == NULL) { - return; // nothing to delete - } - if (buf->bh_curr->b_strlen < (size_t)slen) { - return; - } - - buf->bh_curr->b_str[buf->bh_curr->b_strlen - (size_t)slen] = NUL; - buf->bh_curr->b_strlen -= (size_t)slen; - buf->bh_space += (size_t)slen; -} - -/// Add number "n" to buffer "buf". +/// Append number "n" to buffer "buf". static void add_num_buff(buffheader_T *buf, int n) + FUNC_ATTR_NONNULL_ALL { char number[32]; int numberlen = snprintf(number, sizeof(number), "%d", n); add_buff(buf, number, numberlen); } -/// Add byte or special key 'c' to buffer "buf". -/// Translates special keys, NUL and K_SPECIAL. -static void add_byte_buff(buffheader_T *buf, int c) +/// Encodes byte or special key `c` into `temp`: the K_SPECIAL three-byte sequence for special +/// keys, NUL and K_SPECIAL itself; the byte otherwise. +/// +/// @return Encoded length. +static ptrdiff_t key_byte_encode(int c, char temp[4]) + FUNC_ATTR_NONNULL_ALL { - char temp[4]; - ptrdiff_t templen; if (IS_SPECIAL(c) || c == K_SPECIAL || c == NUL) { // Translate special key code into three byte sequence. temp[0] = (char)K_SPECIAL; temp[1] = (char)K_SECOND(c); temp[2] = (char)K_THIRD(c); temp[3] = NUL; - templen = 3; - } else { - temp[0] = (char)c; - temp[1] = NUL; - templen = 1; + return 3; } - add_buff(buf, temp, templen); + temp[0] = (char)c; + temp[1] = NUL; + return 1; } -/// Add character 'c' to buffer "buf". -/// Translates special keys, NUL, K_SPECIAL and multibyte characters. -static void add_char_buff(buffheader_T *buf, int c) +/// Encodes char `c` into `buf` (at least `MB_MAXBYTES * 3 + 1` bytes): a special key as its +/// K_SPECIAL sequence, a multibyte char byte-wise with K_SPECIAL escaping. +/// +/// @return Encoded length. +static size_t key_char_encode(int c, char *buf) + FUNC_ATTR_NONNULL_ALL { uint8_t bytes[MB_MAXBYTES + 1]; - - int len; - if (IS_SPECIAL(c)) { - len = 1; - } else { - len = utf_char2bytes(c, (char *)bytes); - } - + int len = IS_SPECIAL(c) ? 1 : utf_char2bytes(c, (char *)bytes); + size_t off = 0; for (int i = 0; i < len; i++) { - if (!IS_SPECIAL(c)) { - c = bytes[i]; - } - add_byte_buff(buf, c); + off += (size_t)key_byte_encode(IS_SPECIAL(c) ? c : bytes[i], buf + off); } + return off; +} + +/// Append character 'c' to buffer "buf". +/// Translates special keys, NUL, K_SPECIAL and multibyte characters. +static void add_char_buff(buffheader_T *buf, int c) + FUNC_ATTR_NONNULL_ALL +{ + char temp[MB_MAXBYTES * 3 + 1]; + add_buff(buf, temp, (ptrdiff_t)key_char_encode(c, temp)); +} + +/// Appends byte or special key `c` to `sb`. +static void sb_add_byte(StringBuilder *sb, int c) + FUNC_ATTR_NONNULL_ALL +{ + char temp[4]; + size_t templen = (size_t)key_byte_encode(c, temp); + kv_concat_len(*sb, temp, templen); +} + +/// Dual of add_char_buff(), for a StringBuilder. +void sb_add_char(StringBuilder *sb, int c) + FUNC_ATTR_NONNULL_ALL +{ + char temp[MB_MAXBYTES * 3 + 1]; + size_t len = key_char_encode(c, temp); + kv_concat_len(*sb, temp, len); } /// Get one byte from the read buffers. Use readbuf1 one first, use readbuf2 @@ -404,6 +445,7 @@ static int read_readbuffers(bool advance) } static int read_readbuf(buffheader_T *buf, bool advance) + FUNC_ATTR_NONNULL_ALL { if (buf->bh_first.b_next == NULL) { // buffer is empty return NUL; @@ -422,7 +464,7 @@ static int read_readbuf(buffheader_T *buf, bool advance) return c; } -/// Prepare the read buffers for reading (if they contain something). +/// Makes the next stuffed text be read before the existing readahead. static void start_stuff(void) { if (readbuf1.bh_first.b_next != NULL) { @@ -435,7 +477,7 @@ static void start_stuff(void) } } -/// @return true if the stuff buffer is empty. +/// @return true if the readahead ("stuff") buffer is empty. bool stuff_empty(void) FUNC_ATTR_PURE { @@ -463,8 +505,14 @@ void flush_buffers(flush_buffers_T flush_typeahead) { init_typebuf(); - start_stuff(); - while (read_readbuffers(true) != NUL) {} + if (typebuf.tb_maplen > 0) { + // Discarding a mapping's unconsumed keys: the mapping will never + // complete, discard its subatoms. + atom_composite_abort(); + } + + free_buff(&readbuf1); + free_buff(&readbuf2); if (flush_typeahead == FLUSH_MINIMAL) { // remove mapped characters at the start only, @@ -511,88 +559,119 @@ void beep_flush(void) } } -/// The previous contents of the redo buffer is kept in old_redobuffer. -/// This is used for the CTRL-O <.> command in insert mode. -void ResetRedobuff(void) +/// Starts capturing a new change: stores `spec`, caller appends the body (redo_append_x). The +/// outgoing change moves to `redobuff.old`, which may be repeated by "CTRL-O ." in Insert mode, +/// or restored by redo_cancel() if this change aborts. +/// +/// @param spec Structured command fields; zeroed if the caller only appends keys. +void redo_new(CmdSpec spec) { if (block_redo) { return; } - free_buff(&old_redobuff); - old_redobuff = redobuff; - redobuff.bh_first.b_next = NULL; + kv_destroy(redobuff.old.keys); + redobuff.old = redobuff.cur; + redobuff.cur = (RedoBuf)REDO_INIT; + redobuff.cur.spec = spec; +} + +#ifdef EXITFREE +/// Frees both redo buffers. +void redo_free_all(void) +{ + kv_destroy(redobuff.cur.keys); + kv_destroy(redobuff.old.keys); + redobuff = (RedoState){ REDO_INIT, REDO_INIT }; +} +#endif + +/// Prepare for redo of any command: stores `spec` and appends its command chars (redo_chars()). +/// +/// @param keys Visual-mode command: the body opens with the selection's captured keys, so "." +/// re-executes the selection at cursor. Register/count compose into the body +/// after them (zeroed in the stored spec, so replay doesn't also prefix them). +/// NULL for a plain command: register/count stay spec fields. +/// @param arg_meta Skip the `arg` byte: an interactively-typed operand may need CTRL-V quoting +/// or its composing-char string form, which the caller appends itself. +void prep_redo(const char *keys, size_t len, bool arg_meta, CmdSpec spec) +{ + CmdSpec stored = spec; + if (keys != NULL) { // Visual-mode command. + stored.regname = 0; + stored.count = 0; + } + atom_redo_set(stored); + redo_new(stored); + if (block_redo) { + return; + } + if (keys != NULL) { // Visual-mode command. + kv_concat_len(redobuff.cur.keys, keys, len); + redo_prefix(&spec, &redobuff.cur.keys, false); + } + redo_chars(&spec, &redobuff.cur.keys, arg_meta); } /// Discard the contents of the redo buffer and restore the previous redo /// buffer. -void CancelRedo(void) +void redo_cancel(void) { if (block_redo) { return; } - free_buff(&redobuff); - redobuff = old_redobuff; - old_redobuff.bh_first.b_next = NULL; - start_stuff(); - while (read_readbuffers(true) != NUL) {} + kv_destroy(redobuff.cur.keys); + redobuff.cur = redobuff.old; + redobuff.old = (RedoBuf)REDO_INIT; + free_buff(&readbuf1); + free_buff(&readbuf2); } -/// Save redobuff and old_redobuff to save_redobuff and save_old_redobuff. +/// Saves the redo state to "save_redo" and detaches it. /// Used before executing autocommands and user functions. -void saveRedobuff(save_redo_T *save_redo) +void save_redobuff(RedoState *save_redo) + FUNC_ATTR_NONNULL_ALL { - save_redo->sr_redobuff = redobuff; - redobuff.bh_first.b_next = NULL; - save_redo->sr_old_redobuff = old_redobuff; - old_redobuff.bh_first.b_next = NULL; + *save_redo = redobuff; + redobuff.cur.keys = (StringBuilder)KV_INITIAL_VALUE; + redobuff.old = (RedoBuf)REDO_INIT; - // Make a copy, so that ":normal ." in a function works. - size_t slen; - char *const s = get_buffcont(&save_redo->sr_redobuff, false, &slen); - if (s == NULL) { - return; - } - - add_buff(&redobuff, s, (ptrdiff_t)slen); - xfree(s); + // Make a copy (the fields stayed, copy the body), so that ":normal ." in a + // function works. + kv_splice(redobuff.cur.keys, save_redo->cur.keys); } -/// Restore redobuff and old_redobuff from save_redobuff and save_old_redobuff. +/// Restores the redo state from "save_redo". /// Used after executing autocommands and user functions. -void restoreRedobuff(save_redo_T *save_redo) +void restore_redobuff(RedoState *save_redo) + FUNC_ATTR_NONNULL_ALL { - free_buff(&redobuff); - redobuff = save_redo->sr_redobuff; - free_buff(&old_redobuff); - old_redobuff = save_redo->sr_old_redobuff; + kv_destroy(redobuff.cur.keys); + kv_destroy(redobuff.old.keys); + redobuff = *save_redo; } -/// Append "s" to the redo buffer. +/// Append `len` bytes of `s` (-1: up to the NUL) to the redo buffer. /// K_SPECIAL should already have been escaped. -void AppendToRedobuff(const char *s) +void redo_append_str(const char *s, ptrdiff_t len) { if (!block_redo) { - add_buff(&redobuff, s, -1); + size_t slen = len < 0 ? strlen(s) : (size_t)len; + kv_concat_len(redobuff.cur.keys, s, slen); } } -/// Append to Redo buffer literally, escaping special characters with CTRL-V. +/// Appends `str` to `buf` literally, escaping special characters with CTRL-V. /// K_SPECIAL is escaped as well. /// -/// @param str String to append +/// @param str String to append. /// @param len Length of `str` or -1 for up to the NUL. -void AppendToRedobuffLit(const char *str, int len) +void sb_add_lit(StringBuilder *buf, const char *str, int len) { - if (block_redo) { - return; - } - const char *s = str; while (len < 0 ? *s != NUL : s - str < len) { - // Put a string of normal characters in the redo buffer (that's - // faster). + // Put a string of normal characters in the buffer (that's faster). const char *start = s; while (*s >= ' ' && *s < DEL && (len < 0 || s - str < len)) { s++; @@ -604,7 +683,7 @@ void AppendToRedobuffLit(const char *str, int len) s--; } if (s > start) { - add_buff(&redobuff, start, s - start); + kv_concat_len(*buf, start, (size_t)(s - start)); } if (*s == NUL || (len >= 0 && s - str >= len)) { @@ -615,21 +694,31 @@ void AppendToRedobuffLit(const char *str, int len) // Composing chars separately are handled separately. const int c = mb_cptr2char_adv(&s); if (c < ' ' || c == DEL || (*s == NUL && (c == '0' || c == '^'))) { - add_char_buff(&redobuff, Ctrl_V); + sb_add_char(buf, Ctrl_V); } // CTRL-V '0' must be inserted as CTRL-V 048. if (*s == NUL && c == '0') { - add_buff(&redobuff, "048", 3); + kv_concat_len(*buf, "048", 3); } else { - add_char_buff(&redobuff, c); + sb_add_char(buf, c); } } } +/// Append to RedoBuf buffer literally; no-op when `block_redo` is set; Insert dot-repeat consumes +/// the redo buffer and must not append to it while doing so. +void redo_append_lit(const char *str, int len) +{ + if (block_redo) { + return; + } + sb_add_lit(&redobuff.cur.keys, str, len); +} + /// Append "s" to the redo buffer, leaving 3-byte special key codes unmodified /// and escaping other K_SPECIAL bytes. -void AppendToRedobuffSpec(const char *s) +void redo_append_spec(const char *s) { if (block_redo) { return; @@ -638,34 +727,35 @@ void AppendToRedobuffSpec(const char *s) while (*s != NUL) { if ((uint8_t)(*s) == K_SPECIAL && s[1] != NUL && s[2] != NUL) { // Insert special key literally. - add_buff(&redobuff, s, 3); + kv_concat_len(redobuff.cur.keys, s, 3); s += 3; } else { - add_char_buff(&redobuff, mb_cptr2char_adv(&s)); + sb_add_char(&redobuff.cur.keys, mb_cptr2char_adv(&s)); } } } /// Append a character to the redo buffer. /// Translates special keys, NUL, K_SPECIAL and multibyte characters. -void AppendCharToRedobuff(int c) +void redo_append_char(int c) { if (!block_redo) { - add_char_buff(&redobuff, c); + sb_add_char(&redobuff.cur.keys, c); } } // Append a number to the redo buffer. -void AppendNumberToRedobuff(int n) +void redo_append_num(int n) { if (!block_redo) { - add_num_buff(&redobuff, n); + kv_printf(redobuff.cur.keys, "%d", n); } } /// Append string "s" to the stuff buffer. /// K_SPECIAL must already have been escaped. void stuffReadbuff(const char *s) + FUNC_ATTR_NONNULL_ALL { add_buff(&readbuf1, s, -1); } @@ -673,6 +763,7 @@ void stuffReadbuff(const char *s) /// Append string "s" to the redo stuff buffer. /// @remark K_SPECIAL must already have been escaped. void stuffRedoReadbuff(const char *s) + FUNC_ATTR_NONNULL_ALL { add_buff(&readbuf2, s, -1); } @@ -690,6 +781,7 @@ void stuffReadbuffLen(const char *s, ptrdiff_t len) /// escaping other K_SPECIAL bytes. /// Change CR, LF and ESC into a space. void stuffReadbuffSpec(const char *s) + FUNC_ATTR_NONNULL_ALL { while (*s != NUL) { if ((uint8_t)(*s) == K_SPECIAL && s[1] != NUL && s[2] != NUL) { @@ -722,6 +814,7 @@ void stuffnumReadbuff(int n) /// Stuff a string into the typeahead buffer, such that edit() will insert it /// literally ("literally" true) or interpret is as typed characters. void stuffescaped(const char *arg, bool literally) + FUNC_ATTR_NONNULL_ALL { while (*arg != NUL) { // Stuff a sequence of normal ASCII characters, that's fast. Also @@ -747,160 +840,72 @@ void stuffescaped(const char *arg, bool literally) } } -/// Read a character from the redo buffer. Translates K_SPECIAL and -/// multibyte characters. -/// The redo buffer is left as it is. -/// If init is true, prepare for redo, return FAIL if nothing to redo, OK -/// otherwise. -/// If old_redo is true, use old_redobuff instead of redobuff. -static int read_redo(bool init, bool old_redo) -{ - static buffblock_T *bp; - static uint8_t *p; - int c; - int n; - uint8_t buf[MB_MAXBYTES + 1]; - - if (init) { - bp = old_redo ? old_redobuff.bh_first.b_next : redobuff.bh_first.b_next; - if (bp == NULL) { - return FAIL; - } - p = (uint8_t *)bp->b_str; - return OK; - } - if ((c = *p) == NUL) { - return c; - } - // Reverse the conversion done by add_char_buff() - // For a multi-byte character get all the bytes and return the - // converted character. - if (c != K_SPECIAL || p[1] == KS_SPECIAL) { - n = MB_BYTE2LEN_CHECK(c); - } else { - n = 1; - } - for (int i = 0;; i++) { - if (c == K_SPECIAL) { // special key or escaped K_SPECIAL - c = TO_SPECIAL(p[1], p[2]); - p += 2; - } - if (*++p == NUL && bp->b_next != NULL) { - bp = bp->b_next; - p = (uint8_t *)bp->b_str; - } - buf[i] = (uint8_t)c; - if (i == n - 1) { // last byte of a character - if (n != 1) { - c = utf_ptr2char((char *)buf); - } - break; - } - c = *p; - if (c == NUL) { // cannot happen? - break; - } - } - - return c; -} - -/// Copy the rest of the redo buffer into the stuff buffer (in a slow way). -/// If old_redo is true, use old_redobuff instead of redobuff. -/// The escaped K_SPECIAL is copied without translation. -static void copy_redo(bool old_redo) -{ - int c; - - while ((c = read_redo(false, old_redo)) != NUL) { - add_char_buff(&readbuf2, c); - } -} - -/// Stuff the redo buffer into readbuf2. -/// Insert the redo count into the command. -/// If "old_redo" is true, the last but one command is repeated -/// instead of the last command (inserting text). This is used for -/// CTRL-O <.> in insert mode +/// Dot-repeat "." command: repeats the last change by composing the redo (fields + body, see +/// RedoBuf) into readbuf2. "3." replaces count; a numbered-register redo increments regname +/// ('"1p' then "." pastes '"2'), so "." steps through the delete history. A Visual-mode change +/// re-executes its captured selection keys (embedded in the body, see prep_redo()). /// +/// @param old_redo repeat the last-but-one change (i_CTRL-O ".": the insert +/// session's own prep moved the last change to redobuff.old) /// @return FAIL for failure, OK otherwise int start_redo(int count, bool old_redo) { - // init the pointers; return if nothing to redo - if (read_redo(true, old_redo) == FAIL) { - return FAIL; + RedoBuf *rd = old_redo ? &redobuff.old : &redobuff.cur; + if (rd->keys.size == 0 && rd->spec.regname == 0 && rd->spec.count == 0) { + return FAIL; // nothing to redo } - int c = read_redo(false, old_redo); - - // copy the buffer name, if present - if (c == '"') { - add_buff(&readbuf2, "\"", 1); - c = read_redo(false, old_redo); - - // if a numbered buffer is used, increment the number - if (c >= '1' && c < '9') { - c++; - } - add_char_buff(&readbuf2, c); - - // the expression register should be re-evaluated - if (c == '=') { - add_char_buff(&readbuf2, CAR); - cmd_silent = true; - } - - c = read_redo(false, old_redo); + // The replay's divergences from the captured spec, as explicit tweaks of a local copy: + CmdSpec spec = rd->spec; + if (spec.regname >= '1' && spec.regname < '9') { + spec.regname++; // numbered register: "." steps through the delete history + } + if (count != 0) { + spec.count = count; // the new count replaces the captured one + } + StringBuilder prefix = KV_INITIAL_VALUE; + redo_prefix(&spec, &prefix, true); + add_buff(&readbuf2, prefix.items, (ptrdiff_t)prefix.size); + kv_destroy(prefix); + if (spec.regname == '=') { + cmd_silent = true; } - if (c == 'v') { // redo Visual - Visual.start = curwin->w_cursor; - Visual.active = true; - Visual.select = false; - Visual.reselect = true; - Visual.redo_busy = true; - c = read_redo(false, old_redo); - } - - // try to enter the count (in place of a previous count) - if (count) { - while (ascii_isdigit(c)) { // skip "old" count - c = read_redo(false, old_redo); - } - add_num_buff(&readbuf2, count); - } - - // copy from the redo buffer into the stuff buffer - add_char_buff(&readbuf2, c); - copy_redo(old_redo); + add_buff(&readbuf2, rd->keys.items, (ptrdiff_t)rd->keys.size); return OK; } -/// Repeat the last insert (R, o, O, a, A, i or I command) by stuffing -/// the redo buffer into readbuf2. +/// Repeats the last insert (R, o, O, a, A, i or I command) by stuffing the redo body's inserted +/// text into readbuf2 (counted inserts: "3iZ"). /// /// @return FAIL for failure, OK otherwise int start_redo_ins(void) { - int c; - - if (read_redo(true, false) == FAIL) { + if (redobuff.cur.keys.size == 0) { return FAIL; } start_stuff(); - // skip the count and the command character - while ((c = read_redo(false, false)) != NUL) { - if (vim_strchr("AaIiRrOo", c) != NULL) { - if (c == 'O' || c == 'o') { + // Skip to the insert command; the rest of the keys is the inserted text. + const char *p = redobuff.cur.keys.items; + const char *const end = p + redobuff.cur.keys.size; + while (p < end) { + if ((uint8_t)(*p) == K_SPECIAL && end - p >= 3) { + p += 3; // a special key is never the insert command + continue; + } + if (vim_strchr("AaIiRrOo", (uint8_t)(*p)) != NULL) { + if (*p == 'O' || *p == 'o') { add_buff(&readbuf2, NL_STR, -1); } + p++; break; } + p++; } // copy the typed text from the redo buffer into the stuff buffer - copy_redo(false); + add_buff(&readbuf2, p, end - p); block_redo = true; return OK; } @@ -929,6 +934,7 @@ static void init_typebuf(void) /// @return true when keys cannot be remapped. bool noremap_keys(void) + FUNC_ATTR_PURE { return KeyNoremap & (RM_NONE|RM_SCRIPT); } @@ -950,6 +956,7 @@ bool noremap_keys(void) /// /// @return FAIL for failure, OK otherwise int ins_typebuf(char *str, int noremap, int offset, bool nottyped, bool silent) + FUNC_ATTR_NONNULL_ALL { int val; int nrm; @@ -1070,14 +1077,16 @@ int ins_typebuf(char *str, int noremap, int offset, bool nottyped, bool silent) /// Uses cmd_silent, KeyTyped and KeyNoremap to restore the flags belonging to the char. /// /// @param recorded Pass false if the caller suppressed recording on the initial read. -void requeue_key(int c, int modifiers, bool recorded) +/// @param extra_consumed Extra consumed bytes to un-record beyond the key itself (vgetc()'s +/// ALT-rewrite consumed a `K_SPECIAL KS_MODIFIER` prefix too). +void requeue_key(int c, int modifiers, int extra_consumed, bool recorded) { char buf[MB_MAXBYTES * 3 + 4]; unsigned len = special_to_buf(c, modifiers, true, buf); assert(len < sizeof(buf)); buf[len] = NUL; ins_typebuf(buf, KeyNoremap, 0, !KeyTyped, cmd_silent); - ungetchars((int)len, recorded); + ungetchars((int)len + extra_consumed, recorded); } /// Return true if the typeahead buffer was changed (while waiting for a @@ -1176,7 +1185,7 @@ void del_typebuf(int len, int offset) } } -/// Add a single byte to a recording or 'showcmd'. +/// Append a single byte to a recording or 'showcmd'. /// Return true if a full key has been received, false otherwise. static bool gotchars_add_byte(gotchars_state_T *state, uint8_t byte) FUNC_ATTR_NONNULL_ALL @@ -1212,9 +1221,8 @@ static bool gotchars_add_byte(gotchars_state_T *state, uint8_t byte) state->buflen = 0; } } - // When receiving a multibyte character, store it until we have all - // the bytes, so that it won't be split between two buffer blocks, - // and delete_buff_tail() will work properly. + // When receiving a multibyte character, store it until we have all the bytes, so that + // whole keys reach the recording and ungetchars() removes whole keys. state->pending_mbyte = MB_BYTE2LEN_CHECK(c) - 1; } @@ -1256,12 +1264,14 @@ static void gotchars(const uint8_t *chars, size_t len) } if (reg_recording != 0) { - state.buf[state.buflen] = NUL; - add_buff(&recordbuff, (char *)state.buf, (ptrdiff_t)state.buflen); + kv_concat_len(recordbuff, (char *)state.buf, state.buflen); // remember how many chars were last recorded last_recorded_len += state.buflen; } + // Typed keys consumed during 'operatorfunc' are the op's interactive payload. + atom_typed_add(state.buf, state.buflen); + state.buflen = 0; } @@ -1294,10 +1304,11 @@ static void ungetchars(int len, bool recorded) if (!KeyTyped) { return; // gotchars() only sees typed keys; nothing to undo. } - if (recorded && reg_recording != 0) { - delete_buff_tail(&recordbuff, len); + if (recorded && reg_recording != 0 && recordbuff.size >= (size_t)len) { + recordbuff.size -= (size_t)len; last_recorded_len -= (size_t)len; } + atom_typed_del((size_t)len); size_t trim = MIN((size_t)len, on_key_buf.size); on_key_buf.size -= trim; on_key_ignore_len += (size_t)len - trim; @@ -1311,7 +1322,7 @@ static void ungetchars(int len, bool recorded) /// - When no_u_sync is non-zero. void may_sync_undo(void) { - if ((!(State & (MODE_INSERT | MODE_CMDLINE)) || Ins.arrow_used) + if ((!(State & (MODE_INSERT | MODE_CMDLINE)) || Ins.moved != kInsNone) && curscript < 0) { u_sync(false); } @@ -1361,29 +1372,22 @@ static void save_typebuf(void) alloc_typebuf(); } -static int old_char = -1; ///< character put back by vungetc() -static int old_mod_mask; ///< mod_mask for ungotten character -static int old_mouse_grid; ///< mouse_grid related to old_char -static int old_mouse_row; ///< mouse_row related to old_char -static int old_mouse_col; ///< mouse_col related to old_char -static int old_KeyStuffed; ///< whether old_char was stuffed - -static bool can_get_old_char(void) +static bool can_get_ungot(void) + FUNC_ATTR_PURE { - // If the old character was not stuffed and characters have been added to + // If the ungotten character was not stuffed and characters have been added to // the stuff buffer, need to first get the stuffed characters instead. - return old_char != -1 && (old_KeyStuffed || stuff_empty()); + return ungot.c != -1 && (ungot.stuffed || stuff_empty()); } /// Save all three kinds of typeahead, so that the user must type at a prompt. void save_typeahead(tasave_T *tp) + FUNC_ATTR_NONNULL_ALL { tp->save_typebuf = typebuf; alloc_typebuf(); - tp->typebuf_valid = true; - tp->old_char = old_char; - tp->old_mod_mask = old_mod_mask; - old_char = -1; + tp->ungot = ungot; + ungot.c = -1; tp->save_readbuf1 = readbuf1; readbuf1.bh_first.b_next = NULL; @@ -1394,14 +1398,11 @@ void save_typeahead(tasave_T *tp) /// Restore the typeahead to what it was before calling save_typeahead(). /// The allocated memory is freed, can only be called once! void restore_typeahead(tasave_T *tp) + FUNC_ATTR_NONNULL_ALL { - if (tp->typebuf_valid) { - free_typebuf(); - typebuf = tp->save_typebuf; - } - - old_char = tp->old_char; - old_mod_mask = tp->old_mod_mask; + free_typebuf(); + typebuf = tp->save_typebuf; + ungot = tp->ungot; free_buff(&readbuf1); readbuf1 = tp->save_readbuf1; @@ -1556,6 +1557,7 @@ static void updatescript(int c) /// Merge "modifiers" into "c_arg". int merge_modifiers(int c_arg, int *modifiers) + FUNC_ATTR_NONNULL_ALL { int c = c_arg; @@ -1576,7 +1578,7 @@ int merge_modifiers(int c_arg, int *modifiers) return c; } -/// Add a single byte to 'showcmd' for a partially matched mapping. +/// Append a single byte to 'showcmd' for a partially matched mapping. /// Call add_to_showcmd() if a full key has been received. static void add_byte_to_showcmd(uint8_t byte) { @@ -1651,13 +1653,13 @@ int vgetc(void) // If a character was put back with vungetc, it was already processed. // Return it directly. - if (can_get_old_char()) { - c = old_char; - old_char = -1; - mod_mask = old_mod_mask; - mouse_grid = old_mouse_grid; - mouse_row = old_mouse_row; - mouse_col = old_mouse_col; + if (can_get_ungot()) { + c = ungot.c; + ungot.c = -1; + mod_mask = ungot.mod_mask; + mouse_grid = ungot.mouse_grid; + mouse_row = ungot.mouse_row; + mouse_col = ungot.mouse_col; } else { // number of characters recorded from the last vgetc() call static size_t last_vgetc_recorded_len = 0; @@ -1728,14 +1730,9 @@ int vgetc(void) if (!no_mapping && KeyTyped && mod_mask == MOD_MASK_ALT && !(State & MODE_TERMINAL) && !is_mouse_key(c)) { mod_mask = 0; - char kbuf[MB_MAXBYTES * 3 + 4]; - unsigned klen = special_to_buf(c, 0, true, kbuf); - assert(klen < sizeof(kbuf)); - kbuf[klen] = NUL; // Un-record/un-report the consumed (its K_SPECIAL KS_MODIFIER MOD_MASK_ALT prefix // took 3 more bytes); the rewritten x is consumed (recorded, reported) in its place. - ungetchars((int)klen + 3, true); - ins_typebuf(kbuf, KeyNoremap, 0, !KeyTyped, cmd_silent); + requeue_key(c, 0, 3, true); ins_typebuf(ESC_STR, KeyNoremap, 0, !KeyTyped, cmd_silent); continue; } @@ -1893,8 +1890,8 @@ int plain_vgetc(void) /// Returns NUL if no character is available. int vpeekc(void) { - if (can_get_old_char()) { - return old_char; + if (can_get_ungot()) { + return ungot.c; } return vgetorpeek(false); } @@ -2104,7 +2101,7 @@ void f_getcharmod(typval_T *argvars, typval_T *rettv, EvalFuncData fptr) } typedef enum { - map_result_fail, // failed, break loop + map_result_fail, // failed (recursion limit, OOM), break loop map_result_get, // get a character from typeahead map_result_retry, // try to map again map_result_nomatch, // no matching mapping, get char @@ -2114,6 +2111,7 @@ typedef enum { /// Remove "slen" bytes. /// @return FAIL for error, OK otherwise. static int put_string_in_typebuf(int offset, int slen, uint8_t *string, int new_slen) + FUNC_ATTR_NONNULL_ALL { int extra = new_slen - slen; string[new_slen] = NUL; @@ -2221,6 +2219,7 @@ static int check_simplify_modifier(int max_offset) /// - If decoding of a multi-byte character fails, returns the first byte of /// the encoded character. static int char_iter(const uint8_t **itp, int nomap) + FUNC_ATTR_NONNULL_ALL { const uint8_t *it = *itp; int c = *it; @@ -2270,6 +2269,7 @@ static int char_iter(const uint8_t **itp, int nomap) /// typeahead. /// - On failure (out of memory) return map_result_fail. static int handle_mapping(int *keylenp, const bool *timedout, int *mapdepth) + FUNC_ATTR_NONNULL_ARG(1) { mapblock_T *mp = NULL; mapblock_T *mp2; @@ -2516,6 +2516,9 @@ static int handle_mapping(int *keylenp, const bool *timedout, int *mapdepth) if (keylen > typebuf.tb_maplen && (mp->m_mode & MODE_LANGMAP) == 0) { gotchars(typebuf.tb_buf + typebuf.tb_off + typebuf.tb_maplen, (size_t)(keylen - typebuf.tb_maplen)); + // A typed key sequence resolved this mapping (not a nested expansion: + // those keys come from another mapping): open its composite. + atom_map_start(mp->m_keys, (size_t)mp->m_keylen); } cmd_silent = (typebuf.tb_silent > 0); @@ -2652,12 +2655,14 @@ static int handle_mapping(int *keylenp, const bool *timedout, int *mapdepth) /// Otherwise vgetc() will only get it when the stuff buffer is empty. void vungetc(int c) { - old_char = c; - old_mod_mask = mod_mask; - old_mouse_grid = mouse_grid; - old_mouse_row = mouse_row; - old_mouse_col = mouse_col; - old_KeyStuffed = KeyStuffed; + ungot = (UngotKey){ + .c = c, + .mod_mask = mod_mask, + .mouse_grid = mouse_grid, + .mouse_row = mouse_row, + .mouse_col = mouse_col, + .stuffed = KeyStuffed, + }; } /// When peeking and not getting a character, reg_executing cannot be cleared @@ -2805,6 +2810,7 @@ static int vgetorpeek(bool advance) } if (result == map_result_fail) { + atom_composite_abort(); // failed, use the outer loop c = -1; break; @@ -3502,12 +3508,12 @@ void paste_store(const uint64_t channel_id, const TriState state, const String s const int c = state == kFalse ? K_PASTE_START : K_PASTE_END; if (need_redo) { if (state == kFalse && !(State & MODE_INSERT)) { - ResetRedobuff(); + redo_new((CmdSpec){ 0 }); } - add_char_buff(&redobuff, c); + sb_add_char(&redobuff.cur.keys, c); } if (need_record) { - add_char_buff(&recordbuff, c); + sb_add_char(&recordbuff, c); } return; } @@ -3524,10 +3530,10 @@ void paste_store(const uint64_t channel_id, const TriState state, const String s if (s > start) { if (need_redo) { - add_buff(&redobuff, start, s - start); + kv_concat_len(redobuff.cur.keys, start, (size_t)(s - start)); } if (need_record) { - add_buff(&recordbuff, start, s - start); + kv_concat_len(recordbuff, start, (size_t)(s - start)); } } @@ -3540,10 +3546,10 @@ void paste_store(const uint64_t channel_id, const TriState state, const String s c = NL; } if (need_redo) { - add_byte_buff(&redobuff, c); + sb_add_byte(&redobuff.cur.keys, c); } if (need_record) { - add_byte_buff(&recordbuff, c); + sb_add_byte(&recordbuff, c); } } } diff --git a/src/nvim/input_cmdatom.c b/src/nvim/input_cmdatom.c new file mode 100644 index 0000000000..42e0817339 --- /dev/null +++ b/src/nvim/input_cmdatom.c @@ -0,0 +1,1123 @@ +// input_cmdatom.c: The input engine "policy layer" (input.c. is the "bytes layer"). +// +// Decides _structure_ of user input and captures it as a "CmdAtom": a repeatable unit (edit, +// motion, visual sequence, insert session, mapping), a dot-repeat-style keysequence plus structured +// fields (CmdSpec). +// +// Every "user action" is an atom (emits CmdAtom event), but not every atom is "replayable". +// - Replayable (cascade, dot-repeat) requires the full command grammar. +// - No atoms for: mouse drag/release (TODO(justinmk)?), terminal-mode input, aborted operations, +// command fragments (counts, register prefixes). + +#include +#include +#include + +#include "klib/kvec.h" +#include "nvim/api/private/defs.h" +#include "nvim/api/private/helpers.h" +#include "nvim/ascii_defs.h" +#include "nvim/autocmd.h" +#include "nvim/buffer.h" +#include "nvim/eval/typval_defs.h" +#include "nvim/ex_docmd.h" +#include "nvim/globals.h" +#include "nvim/input.h" +#include "nvim/input_cmdatom.h" +#include "nvim/insert.h" +#include "nvim/keycodes.h" +#include "nvim/log.h" +#include "nvim/macros_defs.h" +#include "nvim/mbyte.h" +#include "nvim/memory.h" +#include "nvim/normal.h" +#include "nvim/normal_defs.h" +#include "nvim/ops.h" +#include "nvim/register.h" +#include "nvim/state_defs.h" +#include "nvim/strings.h" +#include "nvim/vim_defs.h" + +#include "input_cmdatom.c.generated.h" + +CmdAtomVec g_atoms = KV_INITIAL_VALUE; +/// Total atoms ever pushed: so atom_cmd_start() can detect if a cmd already pushed its own atom. +static uint64_t atom_pushes = 0; +/// Suppresses atom pushes. +static bool atom_suppressed = false; +/// Mapping edited the buffer, or its insert-session cascaded: cascades as one unit, incl. motions. +static bool map_edit = false; + +/// Accumulating composite atom: the executing mapping/macro's subatoms, collected while it runs and +/// collapsed at the command end. See `vatom` for Visual composite. +static struct { + CmdAtomVec atoms; + char *lhs; ///< Label: mapping LHS or macro "@x" (NULL: not collecting). + bool queued; ///< A cascadable atom was queued (g_atoms) while collecting. + bool macro; ///< Macro execution: captured as an "@x"-labeled atom. + varnumber_T tick; ///< b:changedtick at start. +} composite; + +/// Staged atom, will be pushed at command end. +static struct { + CmdAtom atom; ///< One is staged when `keys` is non-NULL. + varnumber_T tick; ///< b:changedtick when the atom was staged +} stage; + +/// State of a Visual composite atom. +typedef enum { + // Nothing to replay: + kVatomNone, ///< No pending visual atom. + kVatomVoid, ///< Visual keyseq was tainted/poisoned (by mouse, gv, …), not replayable. + + // Accumulating, replayable: + kVatomTyped, ///< User input (typed, or mapping/macro): emitted/cascaded at end. + kVatomFed, ///< Fed input (":norm! vjd", scheduled feedkeys): preps redo, no emit/cascade. +} VatomState; + +/// Accumulating Visual composite: the full Visual keysequence, including the selection steps. +static struct { + CmdAtomVec atoms; ///< Accumulated atoms during Visual mode. + VatomState state; +} vatom; + +/// Per-command capture scratch. +static struct { + bool redo_pending; ///< The command prepped the change atom (prep_redo*()). + char *cmdline; ///< The ":" payload captured at cmdline accept. NULL: none. + ///< Note: search payloads ("/pat") travel on `cmdarg.searchbuf`. + bool ins_cascaded; ///< Did the command's insert-session already cascade? + bool op_global; ///< Already applied to every cursor (undo, "g CTRL-A"): must not cascade. +} curcmd; + +/// Interactively typed keys of the executing command. Two slices mark keys read by getchar(), which +/// the redo body never sees: 'operatorfunc' input, and the mapping payload ("ds'" reads "'"). +static struct { + kvec_t(uint8_t) keys; + bool opfunc_active; ///< Collecting 'operatorfunc' input. + size_t opfunc_start, opfunc_end; ///< opfunc slice: keys[opfunc_start..opfunc_end) + size_t map_start; ///< mapping slice: keys[map_start..kv_size(keys)) +} typed; + +static const char *const type_names[] = { + [kACommand] = "command", + [kAEx] = "ex", + [kAInsert] = "insert", + [kAInsertSpan] = "insert", // spans display as "insert" (as a composite's `atoms`) + [kAJump] = "jump", + [kAMapping] = "mapping", + [kAMotion] = "motion", + [kAMouse] = "mouse", + [kAOperator] = "operator", + [kAScroll] = "scroll", + [kAVisual] = "visual", +}; + +/// Frees a CmdAtom's allocated members. +void atom_free(CmdAtom *atom) +{ + XFREE_CLEAR(atom->keys); + XFREE_CLEAR(atom->text); + XFREE_CLEAR(atom->lhs); + atoms_free(&atom->atoms); + kv_destroy(atom->atoms); +} + +/// Frees and removes all atoms in `v` (keeps the vector's storage). +void atoms_free(CmdAtomVec *v) +{ + while (kv_size(*v) > 0) { + CmdAtom atom = kv_pop(*v); + atom_free(&atom); + } +} + +#ifdef EXITFREE +void atom_free_all(void) +{ + atoms_free(&g_atoms); + kv_destroy(g_atoms); + atom_stage_drop(); + atom_composite_abort(); + kv_destroy(composite.atoms); + XFREE_CLEAR(curcmd.cmdline); + kv_destroy(typed.keys); + atoms_free(&vatom.atoms); + kv_destroy(vatom.atoms); +} +#endif + +/// Gets a structured spec of a normal-mode command. +CmdSpec atom_cmd_spec(const cmdarg_T *cap) +{ + bool operand = nv_nchar_is_arg(cap->cmdchar); + return (CmdSpec){ + .regname = cap->oap->regname, + .count = cap->count0, + .cmd = cap->cmdchar, + .cmd2 = operand ? NUL : cap->nchar, + .arg = operand ? cap->nchar : NUL, + }; +} + +/// Composes "redo keys" (allocated) from `spec`, as prep_redo() + "." would: for commands +/// that never prep (motions, "u", "zz"). NULL during a cascade. +static char *atom_compose_keys(CmdSpec spec) +{ + StringBuilder sb = KV_INITIAL_VALUE; + redo_prefix(&spec, &sb, false); + redo_chars(&spec, &sb, false); + if (sb.size == 0) { + return NULL; + } + kv_push(sb, NUL); + return sb.items; +} + +/// The pending change as a CmdAtom: the composed keysequence plus the structured fields. +/// Caller owns `keys`. +static CmdAtom atom_from_redo(CmdAtomType type) +{ + String keys = redo_keys(); + return (CmdAtom){ .type = type, .spec = redo_spec(), .keys = keys.data }; +} + +/// Builds a CmdAtom whose `keys` (atom_compose_keys()) and fields both come from `spec`. +static CmdAtom atom_from_spec(CmdAtomType type, CmdSpec spec) +{ + return (CmdAtom){ .type = type, .spec = spec, .keys = atom_compose_keys(spec) }; +} + +/// Builds the atom of a typed cmdline: +/// ":cnext" => CmdAtom{ kAEx, keys=":cnext", text="cnext" } +static CmdAtom atom_from_cmdline(CmdAtomType type, cmdarg_T *ca, const char *line) +{ + StringBuilder sb = KV_INITIAL_VALUE; + if (type != kAEx && ca->count0 != 0) { + kv_printf(sb, "%d", ca->count0); + } + sb_add_char(&sb, ca->cmdchar); + sb_add_lit(&sb, line, -1); + sb_add_char(&sb, NL); + kv_push(sb, NUL); + return (CmdAtom){ + .type = type, + .spec = { .count = ca->count0, .cmd = ca->cmdchar }, + .keys = sb.items, + .text = xstrdup(line), + }; +} + +/// Concatenates the keys of multiple atoms into one (allocated) string. +static String atoms_concat_keys(CmdAtomVec atoms) +{ + StringBuilder keys = KV_INITIAL_VALUE; + for (size_t i = 0; i < kv_size(atoms); i++) { + kv_concat(keys, kv_A(atoms, i).keys); + } + size_t len = kv_size(keys); + kv_push(keys, NUL); + return (String){ .data = keys.items, .size = len }; +} + +/// Renders a (cmd, arg, op) char for CmdAtom: key-notation for special keys, else UTF-8. NUL => "". +static char *atom_key_name(int c) +{ + if (c == NUL) { + return xstrdup(""); + } + if (IS_SPECIAL(c) || c < ' ') { + // Covers /// and the controls (as ""). + return xstrdup(get_special_key_name(c, 0)); + } + if (c == DEL) { + return xstrdup(""); // key_names_table has K_DEL, not the ASCII byte. + } + char buf[MB_MAXBYTES + 1]; + buf[utf_char2bytes(c, buf)] = NUL; + return xstrdup(buf); +} + +/// Gets an atom's (allocated) event-data. +static Dict atom_dict(const CmdAtom *atom) +{ + const CmdSpec *spec = &atom->spec; + char regname[2] = { (char)spec->regname, NUL }; + const char *force = spec->motion_force == Ctrl_V + ? "" : (char[]){ (char)spec->motion_force, NUL }; + // The operator char can be a control char ("g" counter op: CTRL-A). + char *op = atom_key_name(spec->op); + if (spec->op_extra != NUL) { + char *extra = atom_key_name(spec->op_extra); + op = xrealloc(op, strlen(op) + strlen(extra) + 1); + strcat(op, extra); + xfree(extra); + } + char *cmd = atom_key_name(spec->cmd); + if (spec->cmd2 != NUL) { + // Two-char command name ("gJ", "iw", "gn"): compose it. + char *cmd2 = atom_key_name(spec->cmd2); + cmd = xrealloc(cmd, strlen(cmd) + strlen(cmd2) + 1); + strcat(cmd, cmd2); + xfree(cmd2); + } + // Inapplicable fields are OMITTED, not defaulted. + Dict d = ARRAY_DICT_INIT; + char *arg = atom_key_name(spec->arg); + if (arg != NULL && *arg != NUL) { + PUT(d, "arg", CSTR_AS_OBJ(arg)); + } else { + xfree(arg); + } + PUT(d, "changed", BOOLEAN_OBJ(atom->changed)); + if (*cmd != NUL) { + PUT(d, "cmd", CSTR_AS_OBJ(cmd)); + } else { + xfree(cmd); + } + if (spec->count > 0) { + PUT(d, "count", INTEGER_OBJ(spec->count)); + } + // keys/lhs are RAW bytes (typeahead encoding). + PUT(d, "keys", CSTR_TO_OBJ(atom->keys != NULL ? atom->keys : "")); + if (atom->lhs != NULL && *atom->lhs != NUL) { + PUT(d, "lhs", CSTR_TO_OBJ(atom->lhs)); + } + if (*force != NUL) { + PUT(d, "motionforce", CSTR_TO_OBJ(force)); + } + if (*op != NUL) { + PUT(d, "operator", CSTR_AS_OBJ(op)); + } else { + xfree(op); + } + if (spec->regname != 0) { + PUT(d, "reg", CSTR_TO_OBJ(regname)); + } + if (atom->text != NULL && *atom->text != NUL) { + PUT(d, "text", CSTR_TO_OBJ(atom->text)); + } + PUT(d, "type", CSTR_TO_OBJ(type_names[atom->type])); + return d; +} + +/// Schedules a CmdAtom event. +static void atom_emit(const CmdAtom *atom, const char *pending, bool cascade) +{ + if (!has_event(EVENT_CMDATOM)) { + return; + } + Dict data = atom_dict(atom); + PUT(data, "cascade", BOOLEAN_OBJ(cascade)); + if (kv_size(atom->atoms) > 0) { + Array atoms = ARRAY_DICT_INIT; + for (size_t i = 0; i < kv_size(atom->atoms); i++) { + ADD(atoms, DICT_OBJ(atom_dict(&kv_A(atom->atoms, i)))); + } + PUT(data, "atoms", ARRAY_OBJ(atoms)); + } + if (*pending != NUL) { + PUT(data, "pending", CSTR_TO_OBJ(pending)); + } + aucmd_defer(EVENT_CMDATOM, (char *)type_names[atom->type], NULL, AUGROUP_ALL, curbuf, NULL, + &DICT_OBJ(data)); + api_free_dict(data); +} + +/// Emits a CmdAtom event, or collects it as a subatom of a composite. If `cascade` is true, queues +/// a copy for mcursor cascade. +/// +/// Takes ownership of the atom's allocated members. Caller sets `atom.changed`. +void atom_push_raw(bool cascade, CmdAtom atom) +{ + assert(atom.keys != NULL); + atom_pushes++; + if (atom.type == kAVisual && kv_size(atom.atoms) > 0) { + // The completing operator is the only subatom that could have edited. + CmdAtom *last = &kv_A(atom.atoms, kv_size(atom.atoms) - 1); + if (last->type == kAOperator) { + last->changed = atom.changed; + } + } + if (cascade && composite.lhs != NULL) { + composite.queued = true; + } + if (composite.lhs != NULL) { + kv_push(composite.atoms, atom); + } else { + if (atom.type != kAInsertSpan) { + // Spans are cascade-internal; only emit the whole session (kAInsert). + atom_emit(&atom, "", cascade); + } + atom_free(&atom); + } +} + +/// Pushes an atom (emit + maybe cascade), or drops it if replay/Visual/internal-op already +/// in-progress. +static void atom_push(bool cascade, CmdAtom atom) +{ + if (atom_blocked()) { + atom_free(&atom); + return; + } + atom_push_raw(cascade, atom); +} + +/// Stages an atom built before its command executes (do_pending_operator() prep-exempt, Visual +/// ops); will be pushed at command end, once `changed` is known. +static void atom_stage_set(CmdAtom atom) +{ + assert(!atom_staged()); // If this happens, the stage may need to become a stack... + atom_stage_drop(); + if (atom_blocked()) { + // Now, not at flush: drop the atom of an internal operator (atom_suppress()). + atom_free(&atom); + return; + } + assert(atom.keys != NULL); + stage.atom = atom; + stage.tick = buf_get_changedtick(curbuf); +} + +/// True if an atom is staged for the current command. +static bool atom_staged(void) +{ + return stage.atom.keys != NULL; +} + +/// Discards the staged atom. +static void atom_stage_drop(void) +{ + atom_free(&stage.atom); // `keys=NULL` means "nothing staged". +} + +/// Pushes the staged atom (no-op if none). +static void atom_stage_flush(void) +{ + if (!atom_staged()) { + return; + } + stage.atom.changed = buf_get_changedtick(curbuf) != stage.tick; + atom_push(true, stage.atom); // Staged commands are always edits (cascadable). + stage.atom = (CmdAtom){ 0 }; +} + +/// Queues an LHS-replay atom: a mapping that edited invisibly (:normal/:call, "ds'") re-runs +/// per cursor from its LHS + payload keys. Cascade only. +void atom_lhs_replay_queue(void) +{ + StringBuilder keys = KV_INITIAL_VALUE; + kv_concat(keys, composite.lhs); + kv_concat_len(keys, (char *)typed.keys.items + typed.map_start, + kv_size(typed.keys) - typed.map_start); + kv_push(keys, NUL); + kv_push(g_atoms, ((CmdAtom){ .type = kAMapping, .keys = keys.items, .remap = true })); +} + +/// True if the executing mapping queued a subatom: its edit was captured, no LHS-replay needed. +bool atom_composite_queued(void) +{ + return composite.queued; +} + +/// True while a composite is collecting subatoms. +bool atom_composite_active(void) +{ + return composite.lhs != NULL; +} + +/// Starts a composite: accumulate a mapping/macro's subatoms. +static void atom_composite_start(const char *lhs, size_t len) +{ + xfree(composite.lhs); + composite.lhs = xmemdupz(lhs, len); + composite.queued = false; + composite.tick = buf_get_changedtick(curbuf); +} + +/// Collapses the collected subatoms (`CmdAtom.atoms`) and emits the composite atom. +/// +/// :nnoremap gj ik$ +/// "gj" => CmdAtom{ .lhs="gj", .keys="1ik$", kAMapping } +static void atom_composite_end(const char *pending) +{ + composite.macro = false; // "@x" capture ends with its composite. + if (composite.lhs == NULL) { + return; + } + char *lhs = composite.lhs; + composite.lhs = NULL; + CmdAtom atom; + if (kv_size(composite.atoms) == 0) { + // The mapping's commands captured nothing (Ex/Lua commands, no-ops): but it is still a user + // action, so emit it with empty keys (identified by `lhs`). + atom = (CmdAtom){ .type = kAMapping, .keys = xstrdup(""), .lhs = lhs, + .changed = buf_get_changedtick(curbuf) != composite.tick }; + } else if (kv_size(composite.atoms) == 1) { + // Single subatom. "Unwrap" it so e.g. a motion mapping reports kAMotion, not kAMapping. + atom = kv_pop(composite.atoms); + xfree(atom.lhs); + atom.lhs = lhs; + } else { + atom = (CmdAtom){ .type = kAMapping, .keys = atoms_concat_keys(composite.atoms).data, + .lhs = lhs, .changed = buf_get_changedtick(curbuf) != composite.tick }; + atom.atoms = composite.atoms; // Subatoms. + composite.atoms = (CmdAtomVec)KV_INITIAL_VALUE; // Reset. + } + atom_emit(&atom, pending, composite.queued); + atom_free(&atom); +} + +/// Discards the collecting composite (its subatoms): error/interrupt voided it. +void atom_composite_abort(void) +{ + composite.macro = false; + XFREE_CLEAR(composite.lhs); + atoms_free(&composite.atoms); +} + +/// True if the just-executed command is user input. Excludes re-execution of captured +/// material: "@r" (unless composite.macro), ":normal", cascade replays. +bool atom_is_user_cmd(void) +{ + // reg_executing is already reset for a macro's LAST command (its trailing "x" stuffs "dl"). + return ((reg_executing == 0 && !pending_end_reg_executing) || composite.macro) + && ex_normal_busy == 0; +} + +/// Like atom_is_user_cmd(), for sampling before a command consumes its keys. +/// typed "i", mapped "gj" => true; "." (stuffed redo), "@r" => false +static bool atom_is_user_input(void) +{ + return KeyTyped || (atom_is_user_cmd() && typebuf_maplen() > 0); +} + +/// Suppresses atom pushes. For internal operators. +void atom_suppress(bool suppress) +{ + atom_suppressed = suppress; +} + +/// Block atom pushes if: cascade in-progress, internal op is executing, or vatom is accumulating. +static bool atom_blocked(void) +{ + return atom_suppressed || (vatom.state != kVatomNone && Visual.active); +} + +/// True if anything consumes atoms from `curbuf`. For performance: skip capture if no consumers. +static bool atom_buf_has_consumers(void) +{ + return has_event(EVENT_CMDATOM); +} + +/// XXX: Checks consumers for ANY buffer: a mapping/macro may navigate into a buffer w/ cursors... +static bool atom_has_consumers(void) +{ + return has_event(EVENT_CMDATOM); +} + +/// Classifies key/command `cmd` (`arg` is its argument char, for two-char commands like "g;"). +/// +/// @return kKeyXx flags, or 0 for an ordinary key. +unsigned atom_key_class(int cmd, int arg) +{ + switch (cmd) { + case K_EVENT: + case K_IGNORE: + case K_COMMAND: + case K_LUA: + return kKeySynthetic; + case '/': + case '?': + case ':': + case '!': + return kKeyPayload; + case Ctrl_D: + case Ctrl_U: + return kKeyScrollMove | kKeyInsFlush; + case Ctrl_F: + case Ctrl_B: + return kKeyScrollMove; + case Ctrl_E: + case Ctrl_Y: + return kKeyScrollView; + case Ctrl_O: + case Ctrl_I: + return kKeyJump; + case Ctrl_T: + return kKeyJump | kKeyInsFlush; + case 'g': + return (arg == ';' || arg == ',') ? kKeyJump : 0; + case '[': + case ']': + return arg == 'C' ? kKeyJump : 0; // "]C"/"[C": jump to the next/previous cursor + case '*': + case '#': + case '\'': + case '`': + return kKeyJump; // mark motions and "*"/"#": absolute/shared-state targets + case K_UP: + case K_DOWN: + case K_LEFT: + case K_RIGHT: + case K_HOME: + case K_END: + return kKeyMotion | kKeyInsFlush; + case K_S_LEFT: + case K_S_RIGHT: + return kKeyInsFlush; + case K_BS: + case K_DEL: + case Ctrl_H: + case Ctrl_W: + return kKeyInsFlush; + case Ctrl_G: + return kKeyInsFlush; + case K_LEFTMOUSE: + case K_LEFTMOUSE_NM: + case K_MIDDLEMOUSE: + case K_RIGHTMOUSE: + case K_X1MOUSE: + case K_X2MOUSE: + return kKeyMouse; + case K_MOUSEDOWN: // + case K_MOUSEUP: // + case K_MOUSELEFT: + case K_MOUSERIGHT: + return kKeyScrollView; + default: + return 0; + } +} + +/// Captures an accepted ":" cmdline payload. +void atom_cmdline_set(int firstc, const char *line, size_t len) +{ + // Not for nested cmdlines opened by a command's own execution (":normal", macros): they would + // overwrite the user command's payload, e.g. `:exe "normal! :echo 1\r"`. + if (!atom_is_user_cmd() || firstc != ':') { + return; + } + xfree(curcmd.cmdline); + curcmd.cmdline = xmemdupz(line, len); +} + +/// Opens/closes the 'operatorfunc' slice of the typed-key stream. +void atom_opfunc_slice(bool active) +{ + typed.opfunc_active = active; + if (active) { + typed.opfunc_start = kv_size(typed.keys); + } + typed.opfunc_end = kv_size(typed.keys); +} + +/// Collects a typed key (gotchars()) into the stream. +void atom_typed_add(const uint8_t *chars, size_t len) +{ + if (!typed.opfunc_active && !atom_composite_active()) { + return; + } + for (size_t i = 0; i < len; i++) { + kv_push(typed.keys, chars[i]); + } +} + +/// Undoes atom_typed_add() for the last `len` bytes: a key that was read is being re-queued +/// into typeahead and will be collected again (ungetchars()). +void atom_typed_del(size_t len) +{ + if (!typed.opfunc_active && !atom_composite_active()) { + return; + } + kv_size(typed.keys) -= MIN(len, kv_size(typed.keys)); +} + +/// Forgets the redo-atom: new command, or a policy exclusion. +/// Only toplevel commands track it: a nested ":normal!" must not disturb it. +static void atom_redo_reset(void) +{ + if (!atom_is_user_cmd()) { + return; + } + curcmd.redo_pending = false; + curcmd.ins_cascaded = false; + XFREE_CLEAR(curcmd.cmdline); + // The opfunc slice resets per command; the stream itself is truncated only once the + // mapping slice ends too (with its composite). + if (!atom_composite_active()) { + kv_size(typed.keys) = 0; + typed.map_start = 0; + } + typed.opfunc_start = typed.opfunc_end = kv_size(typed.keys); +} + +/// Marks the running command as already applying to every cursor (see `curcmd.op_global`). +/// Called by u_doit() and mc_counter(). +void atom_op_global_set(void) +{ + curcmd.op_global = true; +} + +/// Claims the prepped redo as the command's atom. Only toplevel user commands (a nested redo-prep +/// is not an atom). Declines Ex/Lua operators. +void atom_redo_set(CmdSpec spec) +{ + if (spec.cmd == ':' || spec.cmd == K_COMMAND || spec.cmd == K_LUA) { + atom_redo_reset(); + return; + } + if (atom_is_user_cmd()) { + curcmd.redo_pending = true; + } +} + +/// Starts accumulating a composite for a macro's commands, labeled "@x". +void atom_macro_start(int regname) +{ + if (atom_is_user_input() && atom_has_consumers()) { + composite.macro = true; + if (!atom_composite_active()) { + // The macro's commands collapse into one "@x"-labeled atom. + char lhs[3] = { '@', (char)regname, NUL }; + atom_composite_start(lhs, 2); + } + } +} + +/// Starts accumulating a composite for a mapping resolved from typed keys (vgetorpeek()). +void atom_map_start(const char *lhs, size_t len) +{ + if (!atom_has_consumers() + || reg_executing != 0 || ex_normal_busy != 0 || !(State & MODE_NORMAL) + || Visual.active) { + return; + } + atom_composite_start(lhs, len); + typed.map_start = kv_size(typed.keys); +} + +/// Discards the pending visual atom. Not a lifecycle end: also runs before a session starts. +static void atom_visual_reset(void) +{ + vatom.state = kVatomNone; + atoms_free(&vatom.atoms); +} + +/// True if the pending visual atom is replayable (accumulating, not voided). +bool atom_visual_replayable(void) +{ + return vatom.state == kVatomTyped || vatom.state == kVatomFed; +} + +/// The pending visual atom's accumulated keys (allocated), or NULL data if none is replayable +/// (inactive/void). For the selection dry-run (mc_vsel_refresh()). +String atom_visual_span(void) +{ + if (!atom_visual_replayable()) { + return (String)STRING_INIT; + } + return atoms_concat_keys(vatom.atoms); +} + +/// Captures a typed Visual-mode command into the pending visual atom (vatom): one subatom of +/// the accumulating "viwee"-style keysequence. +static void atom_capture_visual(cmdarg_T *ca, const CmdBaseline *old) +{ + if (!atom_visual_replayable()) { + return; + } + unsigned keycls = atom_key_class(ca->cmdchar, ca->nchar); + if (Visual.select || ca->cmdchar >= 0x100 + || (keycls & (kKeyPayload | kKeyScrollMove)) != 0 + || (ca->cmdchar == 'g' && ca->nchar == 'v')) { + // Not replayable: mouse/special keys, motions with an interactively-typed payload, Select mode, + // "gv" (an absolute region), scrolling that moves the cursor (viewport-dependent extents). + vatom.state = kVatomVoid; + return; + } + if ((keycls & kKeyScrollView) != 0) { + // A viewport scroll (C-E/C-Y) does not change the selection, UNLESS it dragged the cursor along + // (viewport edge, 'scrolloff'), which moved the selection end. + if (!equalpos(old->pos, curwin->w_cursor)) { + vatom.state = kVatomVoid; + } + return; + } + if (ca->cmdchar == 'Q' || ca->cmdchar == 'q' || ca->cmdchar == '"') { + // Skip: recording/replay commands are meta (not part of the edit); a register spec ('"x') is + // re-added by the operator that ends the selection (redo_prefix()). + return; + } + bool operand = nv_nchar_is_arg(ca->cmdchar); + // Omit `regname`, it would prefix '"x' to every command captured after a register spec. + CmdSpec spec = { .count = ca->count0, .cmd = ca->cmdchar, + .cmd2 = operand ? NUL : ca->nchar, .arg = operand ? ca->nchar : NUL }; + char *keys = atom_compose_keys(spec); + if (keys == NULL) { + return; + } + kv_push(vatom.atoms, ((CmdAtom){ .type = kAMotion, .spec = spec, .keys = keys })); +} + +/// Ends the pending visual atom, appends `suffix`, and stages it. Or discards it if selection is +/// unreplayable (void/absent). +/// +/// @param suffix Owned. +/// @param spec The completing operator, or NULL. +/// @param redoable Prep redo so "." re-executes the selection. Unreplayable (void/absent) +/// selection preps "1v" + operator instead (Vim's fixed-size visual-repeat). +/// @return True if the redo was prepped. +static bool atom_visual_end_suffix(char *suffix, const CmdSpec *spec, bool redoable) +{ + if (atom_suppressed) { + // Replay, or internal operator applied as part of another command. + xfree(suffix); + return false; + } + const bool prep = redoable && spec != NULL; + if (suffix == NULL || !atom_visual_replayable()) { + bool prepped = prep && spec->op != NUL && suffix != NULL; + if (prepped) { + prep_redo("1v", 2, false, (CmdSpec){ 0 }); // Equal-size fallback. + redo_append_str(suffix, -1); + } + xfree(suffix); + atom_visual_reset(); + return prepped; + } + String v = atoms_concat_keys(vatom.atoms); + char *vkeys = v.data; + size_t prefix = v.size; + if (prep) { + // Get the redo tail (register/count, op chars) from the suffix. Prevents divergence of prep vs + // atom, and suffixes inexpressible as spec chars ("r") stay replayable. + prep_redo(vkeys, prefix, false, (CmdSpec){ 0 }); + redo_append_str(suffix, -1); + } + if (!atom_is_user_cmd() || vatom.state != kVatomTyped) { + // Not user input (":normal! vjd", fed keys): the redo prep above is the only effect; no emit. + xfree(vkeys); + xfree(suffix); + atom_visual_reset(); + return prep; + } + char *keys = xrealloc(vkeys, prefix + strlen(suffix) + 1); + STRCPY(keys + prefix, suffix); + CmdAtom atom = { + .type = kAVisual, + // The completing operator's fields; per-command counts/registers are + // embedded in `keys` (and decomposed in `atoms`). + .spec = spec != NULL ? *spec : (CmdSpec){ 0 }, + .keys = keys, + }; + if (spec != NULL) { + kv_push(vatom.atoms, ((CmdAtom){ .type = kAOperator, .spec = *spec, .keys = suffix })); + } else { + xfree(suffix); + } + atom.atoms = vatom.atoms; + vatom.atoms = (CmdAtomVec)KV_INITIAL_VALUE; + atom_visual_reset(); + atom_stage_set(atom); + return prep; +} + +/// Ends the pending visual atom with the operator `spec` ("viwee" + "x"). +/// +/// @return True if the redo was prepped. +bool atom_visual_end(CmdSpec spec, bool redoable) +{ + return atom_visual_end_suffix(atom_compose_keys(spec), &spec, redoable); +} + +/// Captures a pending operator's atom before it executes. Prep-exempt commands (yank without cpo-y, +/// "D", folds) build no redo, so atom_cmd_end() cannot derive their atom from redobuff; reconstruct +/// it here (staged). +/// +/// Not for: +/// - OP_CHANGE/OP_INSERT/OP_APPEND +/// - motions with an interactively-typed payload (search, Ex, Lua) +/// +/// @param redo_yank True when a yank builds a redo ("y" in 'cpoptions', not a GUI yank). +void atom_capture_op(oparg_T *oap, cmdarg_T *cap, bool redo_yank) +{ + if (oap->op_type == OP_CHANGE || oap->op_type == OP_INSERT || oap->op_type == OP_APPEND) { + return; + } + bool payload_motion = cap->cmdchar >= 0x100 + || (cap->cmdchar != NUL && strchr("/?:!", cap->cmdchar) != NULL); + if (payload_motion) { + return; + } + const bool redoable = op_redoable(oap->op_type, redo_yank); + bool prep_exempt = !redoable || cap->cmdchar == 'D'; + CmdSpec spec = { + .regname = oap->regname, .count = cap->count0, + .op = get_op_char(oap->op_type), .op_extra = get_extra_op_char(oap->op_type), + }; + if (prep_exempt && (!Visual.active || oap->motion_force)) { + // Only capture _user_ input. + if (atom_buf_has_consumers() && atom_is_user_cmd() && (KeyTyped || atom_composite_active())) { + bool operand = nv_nchar_is_arg(cap->cmdchar); + spec.motion_force = oap->motion_force; + spec.cmd = cap->cmdchar; + spec.cmd2 = operand ? NUL : cap->nchar; + spec.arg = operand ? cap->nchar : NUL; + atom_stage_set(atom_from_spec(kAOperator, spec)); + } + } else if (!Visual.active || oap->motion_force) { + // Prepped: atom_cmd_end() derives the atom from redobuff. + } else if (oap->op_type == OP_REPLACE && cap->nchar <= 0) { + // Visual "r": `spec.arg` cannot represent the sentinel nchar (REPLACE_CR_NCHAR), so + // hand-compose the literal suffix keys. + char suffix[4] = { 'r', Ctrl_V, cap->nchar == REPLACE_CR_NCHAR ? CAR : NL, NUL }; + atom_visual_end_suffix(xstrdup(suffix), &spec, redoable); + } else { + // Visual-mode op: complete the accumulated visual atom with the operator keys. + spec.arg = oap->op_type == OP_REPLACE ? cap->nchar : NUL; + if ((oap->op_type == OP_NR_ADD || oap->op_type == OP_NR_SUB) && cap->arg) { + // g: the "g" variant is distinguished by cap->arg, not the op char: compose it back. + spec.op_extra = spec.op; + spec.op = 'g'; + } + atom_visual_end(spec, redoable); + } +} + +/// Delimits an insert-session, called before its edit(). The session either insert-cascades (spans +/// replay at every cursor) or is captured whole at . +/// +/// No insert-cascade when: +/// - Count is given ("[count]i…"). +/// - Replace mode (R, gR, r, gr): continuation spans re-enter with "i". +/// - Blockwise ("1vI", CTRL-V+"jc"). +/// - Entered from Visual without captured keys: cannot re-execute. +/// +/// @param cmd Entry command char, edit()-style ('i', 'a', 'R', 'v' = gr, …). +/// @param count Count given to the entry command. +/// @param vis How the session was entered from Visual mode. +/// @param vblock The Visual selection was blockwise. +InsSession atom_ins_start(int cmd, long count, VisualIns vis, bool vblock) +{ + InsSession session = { + .typed = atom_is_user_input(), // Sampled before the session. + .vis = vis, + .tick = buf_get_changedtick(curbuf), + }; + if (vis != kVInsNone) { + if (vis == kVInsKeys && vatom.state != kVatomTyped) { + // The selection came from fed keys (":norm", scheduled feedkeys). + session.typed = false; + } + // The selection is consumed: already in the redo body. Also clears selection display. + atom_visual_reset(); + } + return session; +} + +/// Ends the insert-session delimited by atom_ins_start(), capturing it as one atom. Replaying the +/// whole session (not spans), applies the entry cursor placement ("A", "o", "cw") and autocommands. +/// +/// @param busy True when edit() returned early (i_CTRL-O): session incomplete. +void atom_ins_end(const InsSession *session, bool busy) +{ + bool visual = session->vis != kVInsNone; + if (!session->typed || busy || restart_edit != 0 || !atom_buf_has_consumers() + || (visual && session->vis != kVInsKeys)) { + return; + } + atom_ins_push(session, true); +} + +/// Pushes the ended insert-session as one atom. Skips a session not ending in , except +/// self-terminating "r"/"grx". +static void atom_ins_push(const InsSession *session, bool cascade) +{ + CmdAtom atom = atom_from_redo(session->vis != kVInsNone ? kAVisual : kAInsert); + size_t size = atom.keys != NULL ? strlen(atom.keys) : 0; + bool replace = atom.spec.cmd == 'r' || (atom.spec.cmd == 'g' && atom.spec.cmd2 == 'r'); + if (size == 0 || (!replace && (uint8_t)atom.keys[size - 1] != ESC)) { + atom_free(&atom); + return; + } + atom.text = get_last_insert_save(); + atom.changed = buf_get_changedtick(curbuf) != session->tick; + atom_push_raw(cascade, atom); +} + +/// Samples the pre-command state at normal_execute() entry; atom_cmd_end() diffs against it to +/// classify the command (motion, Visual-mode transition, edit). +void atom_cmd_start(CmdBaseline *old) +{ + old->pos = curwin->w_cursor; + old->buf = curbuf; + old->tick = buf_get_changedtick(curbuf); + old->visual = Visual; + old->keytyped = KeyTyped; + old->pushes = atom_pushes; + // Sampled: "q=" toggled DURING a command must not apply to it retroactively. + old->follow = false; + old->consumers = atom_buf_has_consumers(); + // Diffed at command end: detects a register-write (yank). + old->reg_ts = old->consumers ? reg_max_ts(true) : 0; + old->staged = atom_staged(); + curcmd.op_global = false; + atom_redo_reset(); +} + +/// Captures the typed command's atom: in Visual mode into `vatom`, else one atom per command. +/// +/// Skipped for a command that stuffed keys ("x" stuffs "dl": its resolution is the atom), or that +/// already captured its own atom (do_pending_operator(), insert spans). +static void atom_capture_cmd(cmdarg_T *ca, const CmdBaseline *old, bool toplevel) +{ + // Not atom_blocked(): Visual capture must run while the vatom accumulates. + if (atom_suppressed) { + return; + } + // Non-user input (":normal", scripted macro) pushes no atoms, but still accumulates the vatom, + // so its operator can prep a redo: ":normal! vjd" is dot-repeatable. + const bool user = atom_is_user_cmd(); + const unsigned keycls = atom_key_class(ca->cmdchar, ca->nchar); + // Synthetic commands (timers, RPC, plugin callbacks) are not user-input: one that changes + // nothing is invisible; one that changes the buffer/selection voids the pending visual atom. + // + // XXX: This "state diff" is ad hoc: a synthetic change to unobserved state (e.g. only w_curswant) + // counts as no-op. Extend this (or atom_key_class()) when such a case is reported... + bool synthetic = (keycls & kKeySynthetic) != 0; + bool unchanged = curbuf == old->buf + && equalpos(old->pos, curwin->w_cursor) + && buf_get_changedtick(curbuf) == old->tick + && Visual.active == old->visual.active + && (!Visual.active + || (equalpos(old->visual.start, Visual.start) + && old->visual.mode == Visual.mode)); + if (synthetic && unchanged) { + return; + } + bool ins_cascaded = user && curcmd.ins_cascaded; + // Command from a mapping's RHS (typed keys have KeyTyped set). + bool mapped = user && !old->keytyped && !synthetic; + if (mapped + // NOT if cursor-global op: cascading (e.g. vim-repeat "nmap u") would undo at every cursor. + && !curcmd.op_global + && (( + // NOT if it ended in another buffer: a navigation mapping ("nnoremap l") + // entering a buffer with cursors must not cascade. + curbuf == old->buf + && buf_get_changedtick(curbuf) != old->tick) || ins_cascaded)) { + map_edit = true; + } + if (Visual.active) { + if (!old->visual.active) { + atom_visual_reset(); + // Decided once, at session start. + vatom.state = (old->keytyped || atom_composite_active()) ? kVatomTyped : kVatomFed; + } + atom_capture_visual(ca, old); + } else if (old->visual.active) { + if (user && old->follow && atom_visual_replayable() && kv_size(vatom.atoms) > 0) { + // Follow-motion ("q="): a selection abandoned without an operator (, "v" toggle) still + // moved the primary cursor to the selection end: replay it at every cursor. + char suffix[2] = { ESC, NUL }; + atom_visual_end_suffix(xstrdup(suffix), NULL, false); + } else { + atom_visual_reset(); + } + } else if (user && old->consumers && atom_pushes == old->pushes && !atom_staged() + && ca->oap->op_type == OP_NOP + && stuff_empty() && !ins_cascaded + && (old->keytyped || atom_composite_active())) { + // KeyTyped survives stuffing but not macro playback; mapping/macro-fed commands are covered by + // atom_composite_active(). + bool special_motion = (keycls & kKeyMotion) != 0; + bool scroll_cmd = (keycls & (kKeyScrollMove | kKeyScrollView)) != 0; + bool mouse_cmd = (keycls & kKeyMouse) != 0; + bool jump_cmd = (keycls & kKeyJump) != 0; + // Replayable? Register prefix ('"x') is captured as part of the command it prefixes; "@x"/"Q" + // are translations, their resolution is the atom stream. + bool capturable = (ca->cmdchar > 0 && ca->cmdchar < 0x100 + && ca->cmdchar != '"' && ca->cmdchar != '@' && ca->cmdchar != 'Q' + && !scroll_cmd) + || special_motion; + bool changed = buf_get_changedtick(curbuf) != old->tick; + bool motion = curbuf == old->buf + && !equalpos(old->pos, curwin->w_cursor) + && !changed + && !finish_op && !jump_cmd + && ((ca->cmdchar > 0 && ca->cmdchar < 0x100 + && strchr("/?:!Qq", ca->cmdchar) == NULL) + || special_motion); + // Mapping-internal motions are part of its recipe: queue them, the clock edge decides. + bool follow = mapped && motion; + if (curcmd.redo_pending && !mouse_cmd) { + // Not for mouse commands (middle-click paste): pasting at every cursor would use + // viewport-dependent positions. + CmdAtom atom = atom_from_redo(kAOperator); + size_t plen = typed.opfunc_end - typed.opfunc_start; + if (atom.keys != NULL && plen > 0) { + // The opfunc's payload is absent from the captured redo: append it. + size_t klen = strlen(atom.keys); + atom.keys = xrealloc(atom.keys, klen + plen + 1); + memcpy(atom.keys + klen, typed.keys.items + typed.opfunc_start, plen); + atom.keys[klen + plen] = NUL; + } + // Cascade only on an OBSERVABLE effect: an edit or register write. A redoable operator that + // did neither is a no-op (vim-surround "ysa[" whose surround char was ). + bool effect = changed || reg_max_ts(true) > old->reg_ts; + if (atom.keys != NULL && *atom.keys != NUL) { + atom.changed = changed; + atom_push(effect, atom); + } else { + atom_free(&atom); + } + } else if (ca->searchbuf != NULL && (ca->cmdchar == '/' || ca->cmdchar == '?')) { + // Payload typed in the cmdline ("/pat"). Emit-only. + CmdAtom atom = atom_from_cmdline(kAMotion, ca, ca->searchbuf); + atom.changed = changed; + atom_push(false, atom); + } else if (curcmd.cmdline != NULL && ca->cmdchar == ':') { + // Same for ":cnext". + CmdAtom atom = atom_from_cmdline(kAEx, ca, curcmd.cmdline); + atom.changed = changed; + atom_push(false, atom); + } else if (capturable) { + // Non-redoable command (u, zz, q=): never cascaded as an edit. + CmdAtom atom = atom_from_spec(motion ? kAMotion : jump_cmd ? kAJump : kACommand, + atom_cmd_spec(ca)); + atom.changed = changed; + atom_push(follow, atom); + } else if ((scroll_cmd || mouse_cmd) && !atom_composite_active()) { + // Emit-only (viewport-dependent), and never a subatom. Composite keys must stay replayable. + CmdSpec spec = atom_cmd_spec(ca); + if (IS_SPECIAL(ca->cmdchar)) { + // Wheel/mouse count is never typed: do_mousescroll() wrote its internal step there. + spec.count = 0; + } + CmdAtom atom = atom_from_spec(scroll_cmd ? kAScroll : kAMouse, spec); + atom.changed = changed; + atom_push(false, atom); + } + } +} + +/// Completes a cmd at normal_execute() exit: captures its atom, pushes the staged one, ends the +/// composite. +void atom_cmd_end(cmdarg_T *ca, const CmdBaseline *old, bool toplevel) +{ + atom_capture_cmd(ca, old, toplevel); + if (!old->staged) { + // Flush only what this cmd staged. In case of nested :norm (e.g. 'indentexpr' during "gq"). + atom_stage_flush(); + } + + // The clock edge. Only at toplevel: cascading from a nested normal_execute() would recurse. + // Deferred while a mapping executes (its keys are still in typebuf), so its commands collapse as + // one unit; likewise for a macro's LAST command, which may stuff a translation ("x" => "dl"). + if (toplevel && typebuf_typed() && stuff_empty()) { + map_edit = false; + atom_composite_end(ca->oap->op_type != OP_NOP ? "operator" : Visual.active ? "visual" : ""); + } +} diff --git a/src/nvim/input_cmdatom.h b/src/nvim/input_cmdatom.h new file mode 100644 index 0000000000..dbc080a1ad --- /dev/null +++ b/src/nvim/input_cmdatom.h @@ -0,0 +1,30 @@ +#pragma once + +#include +#include + +#include "nvim/buffer_defs.h" // buf_T +#include "nvim/eval/typval_defs.h" // varnumber_T +#include "nvim/input_cmdatom_defs.h" // IWYU pragma: export +#include "nvim/normal_defs.h" // VisualState, cmdarg_T +#include "nvim/pos_defs.h" +#include "nvim/register_defs.h" // Timestamp + +/// Pending atom(s). Multiple atoms may queue; they cascade as a batch (mc_clock_edge). +extern CmdAtomVec g_atoms; + +/// Pre-command state sampled at normal_execute() entry; atom_cmd_end() diffs it to classify. +typedef struct { + pos_T pos; ///< Cursor position. + const buf_T *buf; ///< Current buffer. + varnumber_T tick; ///< b:changedtick + VisualState visual; ///< Visual-mode state (active/start/mode are diffed). + bool keytyped; ///< KeyTyped + uint64_t pushes; ///< `atom_pushes` (total atoms ever pushed). + bool follow; ///< mc_following() ("q=") + bool consumers; ///< Capture is skipped if there are no consumers (for performance). + Timestamp reg_ts; ///< Max register timestamp (to detect a per-cursor register write). + bool staged; ///< atom_staged() +} CmdBaseline; + +#include "input_cmdatom.h.generated.h" diff --git a/src/nvim/input_cmdatom_defs.h b/src/nvim/input_cmdatom_defs.h new file mode 100644 index 0000000000..3a2f2b0c90 --- /dev/null +++ b/src/nvim/input_cmdatom_defs.h @@ -0,0 +1,81 @@ +#pragma once + +#include + +#include "klib/kvec.h" +#include "nvim/eval/typval_defs.h" +#include "nvim/input_defs.h" + +// Concepts (see :help dev-cmdatom): +// - atom, composite (atom with subatoms) +// - insert-session +// - INSERTION +// - span +// - replay, cascade, insert-cascade + +typedef enum CmdAtomType { + kACommand, ///< Not an (operator) edit, nor a cursor-move (u zz l …). Never cascades, + ///< except as part of a mapping's composite. + kAEx, ///< Ex command (":cnext"): the typed cmdline is the payload. + kAInsert, ///< Insert session: entry command + text + . + kAInsertSpan, ///< Span (chunk) of an ongoing insert-session, cascaded mid-session. + kAJump, ///< Cursor movement by absolute/shared navigation (jumplist, marks, `*`): + ///< not followable (its target would collapse cursors onto one position). + kAMapping, ///< Subcommands of a mapping/macro, collapsed into one atom (`lhs`). + kAMotion, ///< Motion (cascades in "q=" follow-motion mode). + kAMouse, ///< Mouse action: emit-only (not replayable). + kAOperator, ///< Operator+motion, or a self-contained edit command. + kAScroll, ///< Scroll (CTRL-Y/D/…, wheel): emit-only, like kAMouse. + kAVisual, ///< Visual-mode sequence ("viwee" + operator). +} CmdAtomType; + +/// How an insert-session was entered from Visual mode. +typedef enum { + kVInsNone, ///< Not entered from Visual mode. + kVInsKeys, ///< Redo opens with the selection's captured keys: replayable. + kVInsOther, ///< Redo without the captured keys (Ex/Lua-motion selection, forced + ///< motion, or the "1v" fixed-size fallback for a void selection). +} VisualIns; + +/// The insert-session delimited by atom_ins_start()/atom_ins_end(). +typedef struct { + bool typed; ///< Session is user input (typed, or via mapping/macro). + VisualIns vis; ///< Session was entered from Visual mode. + varnumber_T tick; ///< b:changedtick at start (the session atom's `changed` baseline). +} InsSession; + +typedef struct CmdAtom CmdAtom; +typedef kvec_t(CmdAtom) CmdAtomVec; + +/// One repeatable operation. `keys` is the replay payload; `spec` is the structured form. +struct CmdAtom { + CmdSpec spec; ///< Structured fields. + CmdAtomVec atoms; ///< Composite (multi-command mapping, Visual sequence): its subatoms, + ///< in order; their keys concatenate to `keys`. Empty for non-composite. + char *keys; ///< Resolved keysequence (typeahead encoding). + char *text; ///< Payload: insert-session text, or Ex or search cmdline. + char *lhs; ///< Mapping LHS or macro register ("gj", "@q") that produced this atom, or NULL. + ///< Label/hint, not replayed. + CmdAtomType type; + bool changed; ///< The command changed the buffer. + bool remap; ///< Replay `keys` w/ remap. For replay of a payload mapping (vim-surround "ds'"), + ///< which edits invisibly (:norm/Ex) and must rerun LHS instead of resolved keys. +}; + +/// Key classes (atom_key_class()). +/// Flags, bc same char can mean different things per mode (CTRL-T: tag-jump vs i_CTRL-T indent). +enum { + kKeySynthetic = 1 << 0, ///< Not a user keystroke (K_EVENT, K_IGNORE, K_COMMAND, K_LUA). + kKeyPayload = 1 << 1, ///< Interactively-typed payload (/, ?, :, !). + kKeyScrollMove = 1 << 2, ///< Scroll may move cursor (C-D/…): viewport-dependent, unreplayable. + kKeyScrollView = 1 << 3, ///< Viewport-only scroll (C-Y,wheel): cursor stays, unless 'scrolloff'. + kKeyJump = 1 << 4, ///< Moves to absolute pos from primary cursor's shared nav state + ///< (jumplist C-O/I, CTRL-T, "g;"): not followable. + kKeyMotion = 1 << 5, ///< Replayable special-key motion (arrows, , …). + kKeyInsFlush = 1 << 6, ///< Insert-mode cmd a literal preview cannot represent: + ///< - deletions/indent-shifts (, CTRL-W, …) may edit text + ///< outside the tracked region by per-cursor amounts; + ///< - cursor-moves (start_arrow()) move the insertion point itself. + kKeyMouse = 1 << 7, ///< Mouse button press (, …). Drag/release/move are the + ///< press's continuation: no class, invisible to capture. +}; diff --git a/src/nvim/input_defs.h b/src/nvim/input_defs.h index fdfd0a9942..cd03e81316 100644 --- a/src/nvim/input_defs.h +++ b/src/nvim/input_defs.h @@ -5,6 +5,7 @@ #include #include "nvim/api/private/defs.h" +#include "nvim/types_defs.h" /// structure used to store one block of the stuff/redo/recording buffers typedef struct buffblock { @@ -13,19 +14,51 @@ typedef struct buffblock { char b_str[1]; ///< contents (actually longer) } buffblock_T; -/// header used for the stuff buffer and the redo buffer +/// Consumable byte queue (linked list of buffblock_T) of keys in typeahead encoding (K_SPECIAL +/// escaped). Appends (add_buff()) fill the spare space of the last block, allocating a new block +/// when full; reads consume from the front (read_readbuf()). +/// +/// Note: Append-only key accumulation (RedoBuf, macro recording) uses StringBuilder instead. typedef struct { - buffblock_T bh_first; ///< first (dummy) block of list + buffblock_T bh_first; ///< empty sentinel: bh_first.b_next holds the first content buffblock_T *bh_curr; ///< buffblock for appending - size_t bh_index; ///< index for reading + size_t bh_index; ///< read position in the first block's b_str size_t bh_space; ///< space in bh_curr for appending bool bh_create_newblock; ///< create a new block? } buffheader_T; +#define BUFFHEADER_INIT { { NULL, 0, { NUL } }, NULL, 0, 0, false } + +/// Structured decomposition of a normal-mode command, used two ways: +/// - Capture (prep_redo()): the command appends its own bytes to the redo body; only `regname` +/// and `count` are functional (the `["x][count]` prefix), the rest is CmdAtom metadata. +/// - Reconstruction (atom_from_spec()): a command that never preps ("u", motions) has no body, so +/// atom_compose_keys() composes the whole keysequence from the spec. typedef struct { - buffheader_T sr_redobuff; - buffheader_T sr_old_redobuff; -} save_redo_T; + long count; ///< Effective count (0 = none) + int regname; ///< Register (`"x` prefix; 0 = none) + int op; ///< Operator char ('d'; 0 = none) + int op_extra; ///< Second operator char ("g~" => '~'; 0 = none) + int motion_force; ///< Forced motion type ('v'/'V'/CTRL-V; 0 = none) + int cmd; ///< Command/motion char ('J', 'p', 'f', K_LEFT, …; 0 = none) + int cmd2; ///< Second char of a two-char command name ("gJ" => 'J'; 0 = none) + int arg; ///< Operand ("fx" => 'x', "ma" => 'a'; 0 = none) +} CmdSpec; + +/// The last change: structured fields plus the command body, filled as the command executes +/// (redo_append_xx()). "." (start_redo()) composes the `["x][count]` prefix around the body. +typedef struct { + CmdSpec spec; ///< Structured command fields. + StringBuilder keys; ///< Cmd body. Perf: StringBuilder (not buffheader_T) => fewer allocs/copies. +} RedoBuf; + +/// RedoBuf (dot-repeat) state: the pending change atom and the previous one. +/// Also the "save" shape for preserving it across user code (save_redobuff()). +typedef struct { + RedoBuf cur; ///< Pending change: "." replays it (start_redo()). + RedoBuf old; ///< Last-but-one change: an insert session's own prep moved the previous change + ///< here (redo_new()); "i_CTRL-O ." replays it. +} RedoState; /// Used for the typeahead buffer: typebuf. typedef struct { @@ -40,15 +73,23 @@ typedef struct { int tb_change_cnt; ///< nr of time tb_buf was changed; never zero } typebuf_T; +/// Char put back by vungetc() (`c == -1`: none), with the read state restored when it is re-read: +/// a later input event must not leak its mod_mask/mouse coords into the ungot key. +typedef struct { + int c; ///< -1: none + int mod_mask; + int mouse_grid; + int mouse_row; + int mouse_col; + bool stuffed; ///< `KeyStuffed` when ungotten. +} UngotKey; + /// Struct to hold the saved typeahead for save_typeahead(). typedef struct { typebuf_T save_typebuf; - bool typebuf_valid; ///< true when save_typebuf valid - int old_char; - int old_mod_mask; + UngotKey ungot; buffheader_T save_readbuf1; buffheader_T save_readbuf2; - String save_inputbuf; } tasave_T; /// Values for "noremap" argument of ins_typebuf() diff --git a/src/nvim/insert.c b/src/nvim/insert.c index 754a880df6..9009367a59 100644 --- a/src/nvim/insert.c +++ b/src/nvim/insert.c @@ -218,16 +218,17 @@ static void insert_enter(InsertState *s) } if (s->cmdchar != NUL && restart_edit == 0) { - ResetRedobuff(); - AppendNumberToRedobuff(s->count); if (s->cmdchar == 'V' || s->cmdchar == 'v') { // "gR" or "gr" command - AppendCharToRedobuff('g'); - AppendCharToRedobuff((s->cmdchar == 'v') ? 'r' : 'R'); + redo_new((CmdSpec){ .count = s->count, .cmd = 'g', .cmd2 = (s->cmdchar == 'v') ? 'r' : 'R' }); + redo_append_char('g'); + redo_append_char((s->cmdchar == 'v') ? 'r' : 'R'); } else { - AppendCharToRedobuff(s->cmdchar); + redo_new((CmdSpec){ .count = s->count, .cmd = s->cmdchar, + .cmd2 = (s->cmdchar == 'g') ? 'I' : NUL }); + redo_append_char(s->cmdchar); if (s->cmdchar == 'g') { // "gI" command - AppendCharToRedobuff('I'); + redo_append_char('I'); } else if (s->cmdchar == 'r') { // "r" command s->count = 1; // insert only one } @@ -279,7 +280,7 @@ static void insert_enter(InsertState *s) if (restart_edit != 0 && stuff_empty()) { // After a paste we consider text typed to be part of the insert for // the pasted text. You can backspace over the pasted text too. - Ins.arrow_used = where_paste_started.lnum == 0; + Ins.moved = where_paste_started.lnum == 0 ? kInsJump : kInsNone; restart_edit = 0; // If the cursor was after the end-of-line before the CTRL-O and it is @@ -304,7 +305,7 @@ static void insert_enter(InsertState *s) } ins_at_eol = false; } else { - Ins.arrow_used = false; + Ins.moved = kInsNone; } // we are in insert mode now, don't need to start it anymore @@ -337,10 +338,10 @@ static void insert_enter(InsertState *s) // Get the current length of the redo buffer, those characters have to be // skipped if we want to get to the inserted characters. - String inserted = get_inserted(); - Ins.new_insert_skip = (int)inserted.size; - if (inserted.data != NULL) { - xfree(inserted.data); + String redo = redo_keys(); + Ins.new_insert_skip = (int)redo.size; + if (redo.data != NULL) { + xfree(redo.data); } old_indent = 0; @@ -389,7 +390,7 @@ static int insert_check(VimState *state) Ins.revins_legal = 0; } - if (Ins.arrow_used) { // don't repeat insert when arrow key used + if (Ins.moved != kInsNone) { // don't repeat insert when arrow key used s->count = 0; } @@ -411,7 +412,7 @@ static int insert_check(VimState *state) } // set curwin->w_curswant for next K_DOWN or K_UP - if (!Ins.arrow_used) { + if (Ins.moved == kInsNone) { curwin->w_set_curswant = true; } @@ -726,8 +727,6 @@ static int insert_execute(VimState *state, int key) static int insert_handle_key(InsertState *s) { // The big switch to handle a character in insert mode. - // TODO(tarruda): This could look better if a lookup table is used. - // (similar to normal mode `nv_cmds[]`) switch (s->c) { case ESC: // End input mode if (echeck_abbr(ESC + ABBR_OFF)) { @@ -1227,7 +1226,7 @@ normalchar: ins_char(s->c); } } - AppendToRedobuffLit(str, -1); + redo_append_lit(str, -1); } xfree(str); s->c = NUL; @@ -1324,7 +1323,7 @@ static void insert_handle_key_post(InsertState *s) } // If the cursor was moved we didn't just insert a space - if (Ins.arrow_used) { + if (Ins.moved != kInsNone) { s->inserted_space = false; } @@ -1395,11 +1394,6 @@ bool edit(int cmdchar, bool startln, int count) return s->c == Ctrl_O; } -bool ins_need_undo_get(void) -{ - return Ins.need_undo; -} - /// Redraw for Insert mode. /// This is postponed until getting the next character to make '$' in the 'cpo' /// option work correctly. @@ -1505,7 +1499,7 @@ static void ins_ctrl_v(void) edit_putchar('^', true); did_putchar = true; } - AppendToRedobuff(CTRL_V_STR); + redo_append_str(S_LEN(CTRL_V_STR)); add_to_showcmd_c(Ctrl_V); @@ -1596,7 +1590,7 @@ char *prompt_text(void) return buf_prompt_text(curbuf); } -// Prepare for prompt mode: Make sure the last line has the prompt text. +// Prepare for prompt mode (buftype=prompt): Make sure the last line has the prompt text. // Move the cursor to this line. static void init_prompt(int cmdchar_todo) { @@ -1674,7 +1668,7 @@ static void set_insstart(linenr_T lnum, colnr_T col) Ins.start_orig = Ins.start; Ins.start_textlen = Ins.start.col; Ins.start_blank_vcol = MAXCOL; - Ins.arrow_used = false; + Ins.moved = kInsNone; } // Undo the previous edit_putchar(). @@ -1922,7 +1916,7 @@ static void insert_special(int c, int allow_modmask, int ctrlv) } p[len - 1] = NUL; ins_str(p, (size_t)(len - 1)); - AppendToRedobuffLit(p, -1); + redo_append_lit(p, -1); ctrlv = false; } } @@ -2112,7 +2106,7 @@ void insertchar(int c, int flags, int second_indent) i = 0; } if (buf[i] != NUL) { - AppendToRedobuffLit(buf + i, -1); + redo_append_lit(buf + i, -1); } } else { int cc; @@ -2123,13 +2117,13 @@ void insertchar(int c, int flags, int second_indent) utf_char2bytes(c, buf); buf[cc] = NUL; ins_char_bytes(buf, (size_t)cc); - AppendCharToRedobuff(c); + redo_append_char(c); } else { ins_char(c); if (flags & INSCHAR_CTRLV) { redo_literal(c); } else { - AppendCharToRedobuff(c); + redo_append_char(c); } } } @@ -2144,47 +2138,62 @@ static void redo_literal(int c) // three digits. if (ascii_isdigit(c)) { vim_snprintf(buf, sizeof(buf), "%03d", c); - AppendToRedobuff(buf); + redo_append_str(buf, -1); } else { - AppendCharToRedobuff(c); + redo_append_char(c); } } -/// start_arrow() is called when an arrow key is used in insert mode. +/// Called when the cursor moves in insert-mode without editing text (arrow keys, , mouse). /// For undo/redo it resembles hitting the key. /// -/// @param end_insert_pos can be NULL -void start_arrow(pos_T *end_insert_pos) +/// @param end_insert_pos Can be NULL +/// @param end_change End undoable change +/// @param move_key Cursor-move key, captured in the CmdAtom (replayed at mc-cascade). +/// NUL for non-captured jumps (mouse, , ); then the next +/// edit restarts a new insert-session (stop_arrow()). +void start_arrow(pos_T *end_insert_pos, bool end_change, int move_key) { - start_arrow_common(end_insert_pos, true); -} - -/// Like start_arrow() but with end_change argument. -/// Will prepare for redo of CTRL-G U if "end_change" is false. -/// -/// @param end_insert_pos can be NULL -/// @param end_change end undoable change -static void start_arrow_with_change(pos_T *end_insert_pos, bool end_change) -{ - start_arrow_common(end_insert_pos, end_change); - if (!end_change) { - AppendCharToRedobuff(Ctrl_G); - AppendCharToRedobuff('U'); + if (Ins.moved == kInsNone && end_change) { // something has been inserted + stop_insert(end_insert_pos, false, false); // this stops the current insert } -} - -/// @param end_insert_pos can be NULL -/// @param end_change end undoable change -static void start_arrow_common(pos_T *end_insert_pos, bool end_change) -{ - if (!Ins.arrow_used && end_change) { // something has been inserted - AppendToRedobuff(ESC_STR); - stop_insert(end_insert_pos, false, false); - Ins.arrow_used = true; // This means we stopped the current insert. + if (Ins.moved != kInsJump) { + if (move_key == NUL) { + redo_append_str(S_LEN(ESC_STR)); + Ins.moved = kInsJump; + } else { + if (!IS_SPECIAL(move_key)) { + // CTRL-G j/k: the only captured cursor-moves typed as plain chars. + redo_append_char(Ctrl_G); + } + redo_append_char(move_key); + if (end_change) { + Ins.moved = kInsArrow; + } + } } check_spell_redraw(); } +/// Like start_arrow(); if `end_change` is false (CTRL-G U pending), also captures the CTRL-G U +/// keys in the atom, ahead of "move_key": replay re-executes the whole "U". +/// +/// (Capturing them here, before start_arrow(), cannot corrupt stop_insert()'s last_insert +/// harvest: they append only when "end_change" is false, and stop_insert() runs only when it +/// is true.) +/// +/// @param end_insert_pos Can be NULL +/// @param end_change End undoable change +/// @param move_key @see start_arrow() +static void start_arrow_with_change(pos_T *end_insert_pos, bool end_change, int move_key) +{ + if (!end_change && Ins.moved != kInsJump) { + redo_append_char(Ctrl_G); + redo_append_char('U'); + } + start_arrow(end_insert_pos, end_change, move_key); +} + // If we skipped highlighting word at cursor, do it now. // It may be skipped again, thus reset spell_redraw_lnum first. static void check_spell_redraw(void) @@ -2198,11 +2207,12 @@ static void check_spell_redraw(void) } // stop_arrow() is called before a change is made in insert mode. -// If an arrow key has been used, start a new insertion. +// +// After a cursor-move (`Ins.moved`), the change starts a new INSERTION (see `Ins.start`). // Returns FAIL if undo is impossible, shouldn't insert then. int stop_arrow(void) { - if (Ins.arrow_used) { + if (Ins.moved != kInsNone) { Ins.start = curwin->w_cursor; // new insertion starts here if (Ins.start.col > Ins.start_orig.col && !Ins.need_undo) { // Don't update the original insert position when moved to the @@ -2211,8 +2221,9 @@ int stop_arrow(void) } Ins.start_textlen = linetabsize_str(get_cursor_line_ptr()); + const bool jumped = Ins.moved == kInsJump; if (u_save_cursor() == OK) { - Ins.arrow_used = false; + Ins.moved = kInsNone; Ins.need_undo = false; } Ins.ai_col = 0; @@ -2220,9 +2231,19 @@ int stop_arrow(void) orig_line_count = curbuf->b_ml.ml_line_count; vr_lines_changed = 1; } - ResetRedobuff(); - AppendToRedobuff("1i"); // Pretend we start an insertion. - Ins.new_insert_skip = 2; + if (jumped) { + // Non-captured cursor-move (mouse, , …): restart the capture as a "1i" insertion. + // The count is a spec field (not body bytes), so "[count]." replaces it ("3i…"). + redo_new((CmdSpec){ .count = 1, .cmd = 'i' }); + redo_append_char('i'); + Ins.new_insert_skip = 2; + } else { + // Cursor-move was captured (start_arrow()): the atom mc-cascade will replay it. + // Only `last_insert` (the ". register, i_CTRL-A) restarts here, like Vim. + String redo = redo_keys(); + Ins.new_insert_skip = (int)redo.size; + xfree(redo.data); + } } else if (Ins.need_undo) { if (u_save_cursor() == OK) { // A command or event may have moved the cursor before the next @@ -2241,7 +2262,7 @@ int stop_arrow(void) // Always open fold at the cursor line when inserting something. foldOpenCursor(); - return Ins.arrow_used || Ins.need_undo ? FAIL : OK; + return Ins.moved != kInsNone || Ins.need_undo ? FAIL : OK; } /// Do a few things to stop inserting. @@ -2258,17 +2279,17 @@ static void stop_insert(pos_T *end_insert_pos, int esc, int nomove) // Save the inserted text for later redo with ^@ and CTRL-A. // Don't do it when "restart_edit" was set and nothing was inserted, // otherwise CTRL-O w and then will clear "last_insert". - String inserted = get_inserted(); - int added = inserted.data == NULL ? 0 : (int)inserted.size - Ins.new_insert_skip; + String redo = redo_keys(); + int added = redo.data == NULL ? 0 : (int)redo.size - Ins.new_insert_skip; if (Ins.did_restart_edit == 0 || added > 0) { xfree(last_insert.data); - last_insert = inserted; // structure copy + last_insert = redo; // structure copy last_insert_skip = added < 0 ? 0 : Ins.new_insert_skip; } else { - xfree(inserted.data); + xfree(redo.data); } - if (!Ins.arrow_used && end_insert_pos != NULL) { + if (Ins.moved == kInsNone && end_insert_pos != NULL) { int cc; // Auto-format now. It may seem strange to do this when stopping an // insertion (or moving the cursor), but it's required when appending @@ -2313,9 +2334,9 @@ static void stop_insert(pos_T *end_insert_pos, int esc, int nomove) // Do this when ESC was used or moving the cursor up/down. // Check for the old position still being valid, just in case the text // got changed unexpectedly. - if (!nomove && Ins.did_ai && (esc || (vim_strchr(p_cpo, kCpoIndent) == NULL - && curwin->w_cursor.lnum != - end_insert_pos->lnum)) + if (!nomove && Ins.did_ai + && (esc || (vim_strchr(p_cpo, kCpoIndent) == NULL + && curwin->w_cursor.lnum != end_insert_pos->lnum)) && end_insert_pos->lnum <= curbuf->b_ml.ml_line_count) { pos_T tpos = curwin->w_cursor; colnr_T prev_col = end_insert_pos->col; @@ -2735,7 +2756,7 @@ static bool echeck_abbr(int c) { // Don't check for abbreviation in paste mode, when disabled and just // after moving around with cursor keys. - if (p_paste || no_abbr || Ins.arrow_used) { + if (p_paste || no_abbr || Ins.moved != kInsNone) { return false; } @@ -2956,15 +2977,15 @@ static void ins_reg(void) if (literally == Ctrl_O || literally == Ctrl_P) { // Append the command to the redo buffer. - AppendCharToRedobuff(Ctrl_R); - AppendCharToRedobuff(literally); - AppendCharToRedobuff(regname); + redo_append_char(Ctrl_R); + redo_append_char(literally); + redo_append_char(regname); do_put(regname, NULL, BACKWARD, 1, (literally == Ctrl_P ? PUT_FIXINDENT : 0) | PUT_CURSEND); } else if (reg->y_size > 1 && is_literal_register(regname)) { - AppendCharToRedobuff(Ctrl_R); - AppendCharToRedobuff(regname); + redo_append_char(Ctrl_R); + redo_append_char(regname); do_put(regname, NULL, BACKWARD, 1, PUT_CURSEND); } else if (insert_reg(regname, NULL, !!literally) == FAIL) { vim_beep(kOptBoFlagRegister); @@ -3089,10 +3110,10 @@ static bool ins_esc(int *count, int cmdchar, bool nomove) RedrawingDisabled--; disabled_redraw = false; } - if (!Ins.arrow_used) { + if (Ins.moved == kInsNone) { // Don't append the ESC for "r" and "grx". if (cmdchar != 'r' && cmdchar != 'v') { - AppendToRedobuff(ESC_STR); + redo_append_str(S_LEN(ESC_STR)); } // Repeating insert may take a long time. Check for @@ -3121,6 +3142,10 @@ static bool ins_esc(int *count, int cmdchar, bool nomove) } stop_insert(&curwin->w_cursor, true, nomove); undisplay_dollar(); + } else if (Ins.moved == kInsArrow && cmdchar != 'r' && cmdchar != 'v') { + // The session ended just after a cursor-move: close the atom, so the whole session (including + // cursor-move) replays. + redo_append_str(S_LEN(ESC_STR)); } if (cmdchar != 'r' && cmdchar != 'v') { @@ -3261,7 +3286,7 @@ static void ins_insert(int replaceState) State = replaceState | (State & MODE_LANGMAP); } may_trigger_modechanged(); - AppendCharToRedobuff(K_INS); + redo_append_char(K_INS); showmode(); ui_cursor_shape(); // may show different cursor shape } @@ -3294,7 +3319,7 @@ static void ins_shift(int c, int lastc) if (stop_arrow() == FAIL) { return; } - AppendCharToRedobuff(c); + redo_append_char(c); // 0^D and ^^D: remove all indent. if (c == Ctrl_D && (lastc == '0' || lastc == '^') @@ -3349,7 +3374,7 @@ static void ins_del(void) Ins.did_si = false; Ins.can_si = false; Ins.can_si_back = false; - AppendCharToRedobuff(K_DEL); + redo_append_char(K_DEL); } /// Handle Backspace, delete-word and delete-line in Insert mode. @@ -3376,10 +3401,10 @@ static bool ins_bs(int c, int mode, int *inserted_space_p) || (!Ins.revins_on && ((curwin->w_cursor.lnum == 1 && curwin->w_cursor.col == 0) || (!can_bs(BS_START) - && ((Ins.arrow_used && !bt_prompt(curbuf)) + && ((Ins.moved != kInsNone && !bt_prompt(curbuf)) || (curwin->w_cursor.lnum == Ins.start_orig.lnum && curwin->w_cursor.col <= Ins.start_orig.col))) - || (!can_bs(BS_INDENT) && !Ins.arrow_used && Ins.ai_col > 0 + || (!can_bs(BS_INDENT) && Ins.moved == kInsNone && Ins.ai_col > 0 && curwin->w_cursor.col <= Ins.ai_col) || (!can_bs(BS_EOL) && curwin->w_cursor.col == 0)))) { vim_beep(kOptBoFlagBackspace); @@ -3518,7 +3543,7 @@ static bool ins_bs(int c, int mode, int *inserted_space_p) && curwin->w_cursor.col > 0 && (*(get_cursor_pos_ptr() - 1) == TAB || (*(get_cursor_pos_ptr() - 1) == ' ' - && (!*inserted_space_p || Ins.arrow_used)))))) { + && (!*inserted_space_p || Ins.moved != kInsNone)))))) { *inserted_space_p = false; bool const use_ts = !curwin->w_p_list || curwin->w_p_lcs_chars.tab1; @@ -3669,7 +3694,7 @@ static bool ins_bs(int c, int mode, int *inserted_space_p) // It's a little strange to put backspaces into the redo // buffer, but it makes auto-indent a lot easier to deal // with. - AppendCharToRedobuff(c); + redo_append_char(c); // If deleted before the insertion point, adjust it if (curwin->w_cursor.lnum == Ins.start_orig.lnum @@ -3707,10 +3732,7 @@ static void ins_left(void) undisplay_dollar(); pos_T tpos = curwin->w_cursor; if (oneleft() == OK) { - start_arrow_with_change(&tpos, end_change); - if (!end_change) { - AppendCharToRedobuff(K_LEFT); - } + start_arrow_with_change(&tpos, end_change, K_LEFT); // If exit reversed string, position is fixed if (Ins.revins_scol != -1 && (int)curwin->w_cursor.col >= Ins.revins_scol) { Ins.revins_legal++; @@ -3719,7 +3741,7 @@ static void ins_left(void) } else if (vim_strchr(p_ww, '[') != NULL && curwin->w_cursor.lnum > 1) { // if 'whichwrap' set for cursor in insert mode may go to previous line. // always break undo when moving upwards/downwards, else undo may break - start_arrow(&tpos); + start_arrow(&tpos, true, K_LEFT); curwin->w_cursor.lnum--; coladvance(curwin, MAXCOL); curwin->w_set_curswant = true; // so we stay at the end @@ -3742,7 +3764,7 @@ static void ins_home(int c) curwin->w_cursor.col = 0; curwin->w_cursor.coladd = 0; curwin->w_curswant = 0; - start_arrow(&tpos); + start_arrow(&tpos, true, c == K_C_HOME ? NUL : K_HOME); } static void ins_end(int c) @@ -3758,7 +3780,7 @@ static void ins_end(int c) coladvance(curwin, MAXCOL); curwin->w_curswant = MAXCOL; - start_arrow(&tpos); + start_arrow(&tpos, true, c == K_C_END ? NUL : K_END); } static void ins_s_left(void) @@ -3769,10 +3791,7 @@ static void ins_s_left(void) } undisplay_dollar(); if (curwin->w_cursor.lnum > 1 || curwin->w_cursor.col > 0) { - start_arrow_with_change(&curwin->w_cursor, end_change); - if (!end_change) { - AppendCharToRedobuff(K_S_LEFT); - } + start_arrow_with_change(&curwin->w_cursor, end_change, K_S_LEFT); bck_word(1, false, false); curwin->w_set_curswant = true; } else { @@ -3790,10 +3809,7 @@ static void ins_right(void) } undisplay_dollar(); if (gchar_cursor() != NUL || virtual_active(curwin)) { - start_arrow_with_change(&curwin->w_cursor, end_change); - if (!end_change) { - AppendCharToRedobuff(K_RIGHT); - } + start_arrow_with_change(&curwin->w_cursor, end_change, K_RIGHT); curwin->w_set_curswant = true; if (virtual_active(curwin)) { oneright(); @@ -3809,7 +3825,7 @@ static void ins_right(void) && curwin->w_cursor.lnum < curbuf->b_ml.ml_line_count) { // if 'whichwrap' set for cursor in insert mode, may move the // cursor to the next line - start_arrow(&curwin->w_cursor); + start_arrow(&curwin->w_cursor, true, K_RIGHT); curwin->w_set_curswant = true; curwin->w_cursor.lnum++; curwin->w_cursor.col = 0; @@ -3828,10 +3844,7 @@ static void ins_s_right(void) undisplay_dollar(); if (curwin->w_cursor.lnum < curbuf->b_ml.ml_line_count || gchar_cursor() != NUL) { - start_arrow_with_change(&curwin->w_cursor, end_change); - if (!end_change) { - AppendCharToRedobuff(K_S_RIGHT); - } + start_arrow_with_change(&curwin->w_cursor, end_change, K_S_RIGHT); fwd_word(1, false, 0); curwin->w_set_curswant = true; } else { @@ -3856,7 +3869,7 @@ static void ins_up(bool startcol) || old_topfill != curwin->w_topfill) { redraw_later(curwin, UPD_VALID); } - start_arrow(&tpos); + start_arrow(&tpos, true, startcol ? 'k' : K_UP); Ins.can_cindent = true; } else { vim_beep(kOptBoFlagCursor); @@ -3870,7 +3883,7 @@ static void ins_pageup(void) if (mod_mask & MOD_MASK_CTRL) { // : tab page back if (first_tabpage->tp_next != NULL) { - start_arrow(&curwin->w_cursor); + start_arrow(&curwin->w_cursor, true, NUL); goto_tabpage(-1); } return; @@ -3878,7 +3891,7 @@ static void ins_pageup(void) pos_T tpos = curwin->w_cursor; if (pagescroll(BACKWARD, 1, false) == OK) { - start_arrow(&tpos); + start_arrow(&tpos, true, NUL); Ins.can_cindent = true; } else { vim_beep(kOptBoFlagCursor); @@ -3901,7 +3914,7 @@ static void ins_down(bool startcol) || old_topfill != curwin->w_topfill) { redraw_later(curwin, UPD_VALID); } - start_arrow(&tpos); + start_arrow(&tpos, true, startcol ? 'j' : K_DOWN); Ins.can_cindent = true; } else { vim_beep(kOptBoFlagCursor); @@ -3915,7 +3928,7 @@ static void ins_pagedown(void) if (mod_mask & MOD_MASK_CTRL) { // : tab page forward if (first_tabpage->tp_next != NULL) { - start_arrow(&curwin->w_cursor); + start_arrow(&curwin->w_cursor, true, NUL); goto_tabpage(0); } return; @@ -3923,7 +3936,7 @@ static void ins_pagedown(void) pos_T tpos = curwin->w_cursor; if (pagescroll(FORWARD, 1, false) == OK) { - start_arrow(&tpos); + start_arrow(&tpos, true, NUL); Ins.can_cindent = true; } else { vim_beep(kOptBoFlagCursor); @@ -3974,7 +3987,7 @@ static bool ins_tab(void) Ins.did_si = false; Ins.can_si = false; Ins.can_si_back = false; - AppendToRedobuff("\t"); + redo_append_str(S_LEN("\t")); if (p_sta && ind) { // insert tab in indent, use 'shiftwidth' temp = get_sw_value(curbuf); @@ -4189,7 +4202,7 @@ bool ins_eol(int c) curwin->w_cursor.col += get_cursor_pos_len(); } - AppendToRedobuff(NL_STR); + redo_append_str(S_LEN(NL_STR)); bool i = open_line(FORWARD, has_format_option(kFoRetComs) ? OPENLINE_DO_COM : 0, old_indent, NULL); @@ -4260,7 +4273,7 @@ static int ins_digraph(void) edit_unputchar(); } if (cc != ESC) { - AppendToRedobuff(CTRL_V_STR); + redo_append_str(S_LEN(CTRL_V_STR)); c = digraph_get(c, cc, true); clear_showcmd(); return c; @@ -4323,7 +4336,7 @@ static int ins_ctrl_ey(int tc) // wasn't set. Digits, 'o' and 'x' are special after a // CTRL-V, don't use it for these. if (c < 256 && !isalnum(c)) { - AppendToRedobuff(CTRL_V_STR); + redo_append_str(S_LEN(CTRL_V_STR)); } OptInt tw_save = curbuf->b_p_tw; curbuf->b_p_tw = -1; diff --git a/src/nvim/insert_defs.h b/src/nvim/insert_defs.h index d04b732bb9..2a35342b1c 100644 --- a/src/nvim/insert_defs.h +++ b/src/nvim/insert_defs.h @@ -5,23 +5,28 @@ #include "nvim/pos_defs.h" #include "nvim/types_defs.h" +/// Change-delimiting cursor-move in insert-mode (start_arrow()): undo, Ins.start and last_insert +/// (the ". register) restart at next edit (stop_arrow()); typed keys sync undo (may_sync_undo()). +typedef enum { + kInsNone = 0, ///< No cursor-move since last edit: contiguous insert-session. + kInsArrow, ///< Cursor-move captured in the atom. + kInsJump, ///< Non-replayable jump (mouse, , …): atom terminated (). +} InsArrow; + /// Insert-mode session state: the in-progress insert session, as one global "group" (Ins), so the -/// insert session can be saved/restored as a whole around nested edit() mini-sessions (e.g. -/// a future multicursor live-mirror). +/// insert session can be saved/restored as a whole around nested edit() sessions. typedef struct { - pos_T start; ///< Where the latest insert/append mode started - pos_T start_orig; ///< Where the latest insert/append mode started. In contrast to - ///< "start", this won't be reset by certain keys and is needed for - ///< op_insert(), to detect correctly where inserting by the user - ///< started. + pos_T start; ///< Start of current INSERTION: from here to cursor is the unit that + ///< undo, '[ and ". treat as one. Reanchored after cursor-move, C-G u. + pos_T start_orig; ///< Where insert by the user started (for op_insert): follows `start` + ///< until insertion starts right of it; before it pulls back. colnr_T start_textlen; ///< length of line when insert started colnr_T start_blank_vcol; ///< vcol for first inserted blank - bool arrow_used; ///< Normally false, set to true after hitting a cursor key in insert - ///< mode. Used by vgetorpeek() to decide when to call u_sync(). + InsArrow moved; ///< Cursor moved since the last edit; see InsArrow. bool stop_insert_mode; ///< for ":stopinsert" bool can_cindent; ///< may do cindenting on this line bool need_undo; ///< call u_save() before inserting a char. Set when edit() is - ///< called; after that arrow_used is used. + ///< called; after that `moved` is used. bool did_ai; ///< Makes auto-indent work right on lines where only a or ///< is typed: set when an auto-indent is done, reset when any ///< other editing is done on the line. If an or is diff --git a/src/nvim/insexpand.c b/src/nvim/insexpand.c index e324c54198..0a27ccb457 100644 --- a/src/nvim/insexpand.c +++ b/src/nvim/insexpand.c @@ -2781,7 +2781,7 @@ static bool ins_compl_stop(const int c, const int prev_mode, bool retval) } // only format when something was inserted - if (!Ins.arrow_used && !ins_need_undo_get() && c != Ctrl_E) { + if (Ins.moved == kInsNone && !Ins.need_undo && c != Ctrl_E) { insertchar(NUL, 0, -1); } @@ -3012,10 +3012,10 @@ static void ins_compl_fixRedoBufForLeader(char *ptr_arg) } // Add backspace characters for each remaining character in original text for (p += len; *p != NUL; MB_PTR_ADV(p)) { - AppendCharToRedobuff(K_BS); + redo_append_char(K_BS); } } - AppendToRedobuffLit(ptr + len, -1); + redo_append_lit(ptr + len, -1); } /// Loops through the list of windows, loaded-buffers or non-loaded-buffers @@ -6494,7 +6494,7 @@ static void spell_back_to_badword(void) pos_T tpos = curwin->w_cursor; spell_bad_len = spell_move_to(curwin, BACKWARD, SMT_ALL, true, NULL); if (curwin->w_cursor.col != tpos.col) { - start_arrow(&tpos); + start_arrow(&tpos, true, NUL); } } diff --git a/src/nvim/memline.c b/src/nvim/memline.c index 3c6a15fc40..f7f272f8dd 100644 --- a/src/nvim/memline.c +++ b/src/nvim/memline.c @@ -4225,6 +4225,12 @@ int incl(pos_T *lp) return r; } +/// Decrements position `lp` by one character (multibyte-aware), crossing line boundaries as +/// necessary. Resets `lp.coladd`. Uses curbuf. +/// +/// @return 1 when crossing to the previous line. +/// -1 when at the start of the file. +/// 0 otherwise. int dec(pos_T *lp) { lp->coladd = 0; diff --git a/src/nvim/memory.c b/src/nvim/memory.c index 36a1e2ec4b..0b1b884b11 100644 --- a/src/nvim/memory.c +++ b/src/nvim/memory.c @@ -25,6 +25,7 @@ #include "nvim/globals.h" #include "nvim/highlight.h" #include "nvim/highlight_group.h" +#include "nvim/input_cmdatom.h" #include "nvim/insexpand.h" #include "nvim/lua/executor.h" #include "nvim/main.h" @@ -33,6 +34,7 @@ #include "nvim/memfile.h" #include "nvim/memory.h" #include "nvim/message.h" +#include "nvim/normal.h" #include "nvim/option_vars.h" #include "nvim/sign.h" #include "nvim/state_defs.h" @@ -964,8 +966,7 @@ void free_all_mem(void) // Clear registers. clear_registers(); - ResetRedobuff(); - ResetRedobuff(); + redo_free_all(); // highlight info free_highlight(); @@ -983,6 +984,7 @@ void free_all_mem(void) channel_free_all_mem(); eval_clear(); api_extmark_free_all_mem(); + atom_free_all(); map_destroy(int, &buffer_handles); map_destroy(int, &window_handles); diff --git a/src/nvim/menu.c b/src/nvim/menu.c index ff78de021e..bc0e284992 100644 --- a/src/nvim/menu.c +++ b/src/nvim/menu.c @@ -1538,10 +1538,8 @@ void execute_menu(const exarg_T *eap, vimmenu_T *menu, int mode_idx) save_state_T save_state; ex_normal_busy++; - if (save_current_state(&save_state)) { - exec_normal_cmd(menu->strings[idx], menu->noremap[idx], - menu->silent[idx]); - } + save_current_state(&save_state); + exec_normal_cmd(menu->strings[idx], menu->noremap[idx], menu->silent[idx]); restore_current_state(&save_state); ex_normal_busy--; } else { diff --git a/src/nvim/message.c b/src/nvim/message.c index 3b508dce4f..bfb16fa273 100644 --- a/src/nvim/message.c +++ b/src/nvim/message.c @@ -1552,7 +1552,7 @@ void wait_return(int redraw) } else if (vim_strchr("\r\n ", c) == NULL && c != Ctrl_C && c != 'q') { // Put the character back in the typeahead buffer. Don't use the // stuff buffer, because lmaps wouldn't work. - requeue_key(vgetc_char, vgetc_mod_mask, + requeue_key(vgetc_char, vgetc_mod_mask, 0, // Recording was suppressed around safe_vgetc() above. false); do_redraw = true; // need a redraw even though there is typeahead diff --git a/src/nvim/mouse.c b/src/nvim/mouse.c index 22059067a3..d33d1fe1c1 100644 --- a/src/nvim/mouse.c +++ b/src/nvim/mouse.c @@ -22,6 +22,7 @@ #include "nvim/grid.h" #include "nvim/grid_defs.h" #include "nvim/input.h" +#include "nvim/input_cmdatom.h" #include "nvim/insert.h" #include "nvim/keycodes.h" #include "nvim/macros_defs.h" @@ -518,9 +519,9 @@ bool do_mouse(oparg_T *oap, int c, int dir, int count, bool fixindent) (fixindent ? PUT_FIXINDENT : 0) | PUT_CURSEND); // Repeat it with CTRL-R CTRL-O r or CTRL-R CTRL-P r - AppendCharToRedobuff(Ctrl_R); - AppendCharToRedobuff(fixindent ? Ctrl_P : Ctrl_O); - AppendCharToRedobuff(regname == 0 ? '"' : regname); + redo_append_char(Ctrl_R); + redo_append_char(fixindent ? Ctrl_P : Ctrl_O); + redo_append_char(regname == 0 ? '"' : regname); } } return false; @@ -865,7 +866,8 @@ bool do_mouse(oparg_T *oap, int c, int dir, int count, bool fixindent) c1 = (dir == FORWARD) ? 'p' : 'P'; c2 = NUL; } - prep_redo(regname, count, NUL, c1, NUL, c2, NUL); + prep_redo(NULL, 0, false, + (CmdSpec){ .regname = regname, .count = count, .cmd = c1, .cmd2 = c2 }); // Remember where the paste started, so in edit() Ins.start can be set to this position if (restart_edit != 0) { @@ -1023,7 +1025,7 @@ void ins_mouse(int c) curbuf->b_prompt_insert = 'A'; } } - start_arrow(curwin == old_curwin ? &tpos : NULL); + start_arrow(curwin == old_curwin ? &tpos : NULL, true, NUL); if (curwin != new_curwin && win_valid(new_curwin)) { curwin = new_curwin; curbuf = curwin->w_buffer; @@ -1137,7 +1139,7 @@ void ins_mousescroll(int dir) curbuf = curwin->w_buffer; if (!equalpos(curwin->w_cursor, orig_cursor)) { - start_arrow(&orig_cursor); + start_arrow(&orig_cursor, true, NUL); set_can_cindent(true); } } diff --git a/src/nvim/msgpack_rpc/channel.c b/src/nvim/msgpack_rpc/channel.c index 36e39e1b4a..3e5214cb51 100644 --- a/src/nvim/msgpack_rpc/channel.c +++ b/src/nvim/msgpack_rpc/channel.c @@ -548,6 +548,7 @@ void rpc_free(Channel *channel) /// Closes a channel after receiving fatal error, and logs a message. static void chan_close_on_err(Channel *channel, char *msg, int loglevel) { + logmsg(loglevel, "RPC: ", NULL, -1, true, "%s", msg); for (size_t i = 0; i < kv_size(channel->rpc.call_stack); i++) { ChannelCallFrame *frame = kv_A(channel->rpc.call_stack, i); if (frame->returned) { diff --git a/src/nvim/normal.c b/src/nvim/normal.c index 19367a061f..aa3ab20b31 100644 --- a/src/nvim/normal.c +++ b/src/nvim/normal.c @@ -14,7 +14,10 @@ #include #include +#include "nvim/api/buffer.h" +#include "nvim/api/extmark.h" #include "nvim/api/private/helpers.h" +#include "nvim/api/vim.h" #include "nvim/ascii_defs.h" #include "nvim/autocmd.h" #include "nvim/autocmd_defs.h" @@ -22,6 +25,7 @@ #include "nvim/buffer_defs.h" #include "nvim/change.h" #include "nvim/charset.h" +#include "nvim/clipboard.h" #include "nvim/cmdhist.h" #include "nvim/cursor.h" #include "nvim/decoration.h" @@ -29,13 +33,16 @@ #include "nvim/digraph.h" #include "nvim/drawscreen.h" #include "nvim/errors.h" +#include "nvim/eval.h" #include "nvim/eval/buffer.h" +#include "nvim/eval/typval.h" #include "nvim/eval/vars.h" #include "nvim/ex_cmds.h" #include "nvim/ex_cmds2.h" #include "nvim/ex_docmd.h" #include "nvim/ex_eval.h" #include "nvim/ex_getln.h" +#include "nvim/extmark.h" #include "nvim/file_search.h" #include "nvim/fileio.h" #include "nvim/fold.h" @@ -45,15 +52,19 @@ #include "nvim/help.h" #include "nvim/highlight.h" #include "nvim/highlight_defs.h" +#include "nvim/highlight_group.h" #include "nvim/indent_c.h" #include "nvim/input.h" +#include "nvim/input_cmdatom.h" #include "nvim/insert.h" +#include "nvim/insexpand.h" #include "nvim/keycodes.h" #include "nvim/lua/executor.h" #include "nvim/macros_defs.h" #include "nvim/mapping.h" #include "nvim/mark.h" #include "nvim/mark_defs.h" +#include "nvim/marktree.h" #include "nvim/math.h" #include "nvim/mbyte.h" #include "nvim/mbyte_defs.h" @@ -100,10 +111,14 @@ typedef struct { bool ctrl_w; bool need_flushbuf; bool set_prevcount; - bool previous_got_int; // `got_int` was true - bool toplevel; // top-level normal mode - oparg_T oa; // operator arguments - cmdarg_T ca; // command arguments + bool previous_got_int; ///< `got_int` was true. + bool toplevel; ///< This is a (poorly-named) _behavior_ opt-in, not a state indicator. + ///< It enables "full interactive-command treatment": + ///< - count prep, v:count publication. + ///< - scrollbind/cursorbind syncing after the command. + ///< - callers pair it with readbuf1_empty() to exclude stuffed keys. + oparg_T oa; ///< Operator arguments. + cmdarg_T ca; ///< Command arguments. int mapped_len; int old_mapped_len; int idx; @@ -147,6 +162,8 @@ typedef void (*nv_func_T)(cmdarg_T *cap); #define NV_RL 0x80 // 'rightleft' modifies command #define NV_KEEPREG 0x100 // don't clear regname #define NV_NCW 0x200 // not allowed in command-line window +#define NV_NCH_ARG 0x400 // second char is a typed operand (mark/register name), + // not part of the command name (see NV_LANG for f/t/r) // Generally speaking, every Normal mode command should either clear any // pending operator (with *clearop*()), or set the motion type variable @@ -201,12 +218,12 @@ static const struct nv_cmd { { Ctrl__, nv_error, 0, 0 }, { ' ', nv_right, 0, 0 }, { '!', nv_operator, 0, 0 }, - { '"', nv_regname, NV_NCH_NOP|NV_KEEPREG, 0 }, + { '"', nv_regname, NV_NCH_NOP|NV_NCH_ARG|NV_KEEPREG, 0 }, { '#', nv_ident, 0, 0 }, { '$', nv_dollar, 0, 0 }, { '%', nv_percent, 0, 0 }, { '&', nv_optrans, 0, 0 }, - { '\'', nv_gomark, NV_NCH_ALW, true }, + { '\'', nv_gomark, NV_NCH_ALW|NV_NCH_ARG, true }, { '(', nv_brace, 0, BACKWARD }, { ')', nv_brace, 0, FORWARD }, { '*', nv_ident, 0, 0 }, @@ -231,7 +248,7 @@ static const struct nv_cmd { { '=', nv_operator, 0, 0 }, { '>', nv_operator, NV_RL, 0 }, { '?', nv_search, 0, false }, - { '@', nv_at, NV_NCH_NOP, false }, + { '@', nv_at, NV_NCH_NOP|NV_NCH_ARG, false }, { 'A', nv_edit, 0, 0 }, { 'B', nv_bck_word, 0, 1 }, { 'C', nv_abbrev, NV_KEEPREG, 0 }, @@ -262,7 +279,7 @@ static const struct nv_cmd { { ']', nv_brackets, NV_NCH_ALW, FORWARD }, { '^', nv_beginline, 0, BL_WHITE | BL_FIX }, { '_', nv_lineop, 0, 0 }, - { '`', nv_gomark, NV_NCH_ALW, false }, + { '`', nv_gomark, NV_NCH_ALW|NV_NCH_ARG, false }, { 'a', nv_edit, NV_NCH, 0 }, { 'b', nv_bck_word, 0, 0 }, { 'c', nv_operator, 0, 0 }, @@ -275,11 +292,11 @@ static const struct nv_cmd { { 'j', nv_down, 0, false }, { 'k', nv_up, 0, false }, { 'l', nv_right, NV_RL, 0 }, - { 'm', nv_mark, NV_NCH_NOP, 0 }, + { 'm', nv_mark, NV_NCH_NOP|NV_NCH_ARG, 0 }, { 'n', nv_next, 0, 0 }, { 'o', nv_open, 0, 0 }, { 'p', nv_put, 0, 0 }, - { 'q', nv_q, NV_NCH, 0 }, + { 'q', nv_q, NV_NCH|NV_NCH_ARG, 0 }, { 'r', nv_replace, NV_NCH_NOP|NV_LANG, 0 }, { 's', nv_subst, NV_KEEPREG, 0 }, { 't', nv_csearch, NV_NCH_ALW|NV_LANG, FORWARD }, @@ -406,6 +423,14 @@ void init_normal_cmds(void) nv_max_linear = i - 1; } +/// True if a command's second char (cmdarg_T.nchar) is a typed operand ("fx", "ma") rather than +/// the second char of its name ("gJ", "iw"). +bool nv_nchar_is_arg(int cmdchar) +{ + int idx = find_command(cmdchar); + return idx >= 0 && (nv_cmds[idx].cmd_flags & (NV_LANG|NV_NCH_ARG)) != 0; +} + /// Search for a command in the commands table. /// /// @return -1 for invalid command. @@ -500,9 +525,7 @@ bool op_pending(void) && current_oap->regname == NUL); } -/// Normal state entry point: the main loop. Called on startup, never returns. -/// -/// This used to be called main_loop() on main.c +/// Normal state entry point: the main loop. Never returns. void normal_enter(void) { NormalState state; @@ -1018,7 +1041,6 @@ normal_end: } checkpcmark(); // check if we moved since setting pcmark - xfree(s->ca.searchbuf); mb_check_adjust_col(curwin); // #6203 @@ -1063,6 +1085,18 @@ normal_end: static int normal_execute(VimState *state, int key) { + // Like most things in Vim, `toplevel` is a lie: exec_normal() happily sets it to true (inherited + // from Vim). So we track `depth` instead. + // - depth=0: No command executing (idle, between commands, sitting in vgetc()). + // - depth=1: Outermost command-frame. + // - depth=2: First nested frame (e.g. the "dd" inside ":normal! dd"). + // - depth=n: And so on... + static int depth = 0; + depth++; + + CmdBaseline atom_old; + atom_cmd_start(&atom_old); + NormalState *s = (NormalState *)state; s->command_finished = false; s->ctrl_w = false; // got CTRL-W command @@ -1092,7 +1126,7 @@ static int normal_execute(VimState *state, int key) // When "restart_edit" is set fake a "d"elete command, Insert mode will restart automatically. // Insert the typed character in the typeahead buffer, so that it can // be mapped in Insert mode. Required for ":lmap" to work. - requeue_key(vgetc_char, vgetc_mod_mask, true); + requeue_key(vgetc_char, vgetc_mod_mask, 0, true); if (restart_edit != 0) { s->c = 'd'; @@ -1133,12 +1167,11 @@ static int normal_execute(VimState *state, int key) // Always remember the count. It will be set to zero (on the next call, // above) when there is no pending operator. - // When called from main(), save the count for use by the "count" built-in - // variable. + // When called from toplevel, save the count for use by v:count. s->ca.opcount = s->ca.count0; s->ca.count1 = (s->ca.count0 == 0 ? 1 : s->ca.count0); - // Only set v:count when called from main() and not a stuffed command. + // Only set v:count when called from toplevel and not a stuffed command. // Do set it for redo. if (s->toplevel && readbuf1_empty()) { set_vcount(s->ca.count0, s->ca.count1, s->set_prevcount); @@ -1236,6 +1269,9 @@ static int normal_execute(VimState *state, int key) finish: normal_finish_command(s); + atom_cmd_end(&s->ca, &atom_old, depth == 1); + xfree(s->ca.searchbuf); + depth--; return 1; } @@ -1699,51 +1735,11 @@ size_t find_ident_at_pos(win_T *wp, linenr_T lnum, colnr_T startcol, char **text /// Prepare for redo of a normal command. static void prep_redo_cmd(cmdarg_T *cap) { - prep_redo(cap->oap->regname, cap->count0, - NUL, cap->cmdchar, NUL, NUL, NUL); - if (cap->nchar_len > 0) { - AppendToRedobuff(cap->nchar_composing); - } else { - AppendCharToRedobuff(cap->nchar); - } -} - -/// Prepare for redo of any command. -/// Note that only the last argument can be a multi-byte char. -void prep_redo(int regname, int num, int cmd1, int cmd2, int cmd3, int cmd4, int cmd5) -{ - prep_redo_num2(regname, num, cmd1, cmd2, 0, cmd3, cmd4, cmd5); -} - -/// Prepare for redo of any command with extra count after "cmd2". -void prep_redo_num2(int regname, int num1, int cmd1, int cmd2, int num2, int cmd3, int cmd4, - int cmd5) -{ - ResetRedobuff(); - if (regname != 0) { // yank from specified buffer - AppendCharToRedobuff('"'); - AppendCharToRedobuff(regname); - } - if (num1 != 0) { - AppendNumberToRedobuff(num1); - } - if (cmd1 != NUL) { - AppendCharToRedobuff(cmd1); - } - if (cmd2 != NUL) { - AppendCharToRedobuff(cmd2); - } - if (num2 != 0) { - AppendNumberToRedobuff(num2); - } - if (cmd3 != NUL) { - AppendCharToRedobuff(cmd3); - } - if (cmd4 != NUL) { - AppendCharToRedobuff(cmd4); - } - if (cmd5 != NUL) { - AppendCharToRedobuff(cmd5); + // Composing chars: the operand's byte form is the composed string, not the single char. + bool composing = cap->nchar_len > 0; + prep_redo(NULL, 0, composing, atom_cmd_spec(cap)); + if (composing) { + redo_append_str(cap->nchar_composing, -1); } } @@ -1759,7 +1755,7 @@ static bool checkclearop(oparg_T *oap) return true; } -/// Check for operator or Visual active. Clear active operator. +/// Checks for operator or Visual active, and clears active operator. /// /// Beep and return true if an operator or Visual was active. static bool checkclearopq(oparg_T *oap) @@ -1925,7 +1921,9 @@ bool add_to_showcmd(int c) 0 }; - if (!p_sc || msg_silent != 0 || ex_normal_busy) { + // Not for stuffed (replay): "." executes as atomic unit; displaying the keys would churn + // 'showcmd' mid-replay, redrawing transient states (e.g. selection of a replayed visual op). + if (!p_sc || msg_silent != 0 || ex_normal_busy || KeyStuffed) { return false; } @@ -2019,7 +2017,8 @@ void showcmd_update_clear_state(void) showcmd_is_clear = (showcmd_buf[0] == NUL); } -static void display_showcmd(void) +/// Displays 'showcmd' info, and a ("2×") hint if multicursor is active. +void display_showcmd(void) { showcmd_update_clear_state(); @@ -2066,8 +2065,10 @@ static void display_showcmd(void) int len = 0; if (!showcmd_is_clear) { - len = grid_line_puts(sc_col, showcmd_buf, -1, HL_ATTR(HLF_MSG)); + len += grid_line_puts(sc_col + len, showcmd_buf, -1, HL_ATTR(HLF_MSG)); } + // Clamp so the padding arithmetic below stays in bounds. + len = MIN(len, (int)SHOWCMD_COLS); // clear the rest of an old message by outputting up to SHOWCMD_COLS spaces grid_line_puts(sc_col + len, (char *)" " + len, -1, HL_ATTR(HLF_MSG)); @@ -3111,12 +3112,16 @@ static void nv_regreplay(cmdarg_T *cap) return; } + if (reg_recorded != 0) { + // The macro's commands are captured as one "@x"-labeled atom (see + // atom_macro_start()). + atom_macro_start(reg_recorded); + } while (cap->count1-- && !got_int) { if (do_execreg(reg_recorded, false, false, false) == false) { clearopbeep(cap->oap); break; } - line_breakcheck(); } } @@ -4579,15 +4584,19 @@ static void nv_replace(cmdarg_T *cap) // Give 'r' to edit(), to get the redo command right. invoke_edit(cap, true, 'r', false); } else { - prep_redo(cap->oap->regname, cap->count1, NUL, 'r', NUL, had_ctrl_v, 0); + prep_redo(NULL, 0, true, (CmdSpec){ .regname = cap->oap->regname, .count = cap->count1, + .cmd = 'r', .arg = cap->nchar }); + if (had_ctrl_v != NUL) { + redo_append_char(had_ctrl_v); + } curbuf->b_op_start = curwin->w_cursor; const int old_State = State; if (cap->nchar_len > 0) { - AppendToRedobuff(cap->nchar_composing); + redo_append_str(cap->nchar_composing, -1); } else { - AppendCharToRedobuff(cap->nchar); + redo_append_char(cap->nchar); } // This is slow, but it handles replacing a single-byte with a @@ -5637,11 +5646,17 @@ static void nv_g_cmd(cmdarg_T *cap) nv_gd(oap, cap->nchar, cap->count0); break; + // g: jump to mouse-clicked tag, like "CTRL-]". + case K_LEFTMOUSE: + if (do_mouse(oap, cap->nchar, BACKWARD, cap->count1, 0)) { + stuffcharReadbuff(Ctrl_RSB); + } + break; + // g<*Mouse> : case K_MIDDLEMOUSE: case K_MIDDLEDRAG: case K_MIDDLERELEASE: - case K_LEFTMOUSE: case K_LEFTDRAG: case K_LEFTRELEASE: case K_MOUSEMOVE: @@ -5754,7 +5769,7 @@ static void nv_dot(cmdarg_T *cap) // If "restart_edit" is true, the last but one command is repeated // instead of the last command (inserting text). This is used for // CTRL-O <.> in insert mode. - if (start_redo(cap->count0, restart_edit != 0 && !Ins.arrow_used) == false) { + if (start_redo(cap->count0, restart_edit != 0 && Ins.moved == kInsNone) == false) { clearopbeep(cap->oap); } } @@ -5833,7 +5848,7 @@ static void nv_operator(cmdarg_T *cap) return; } - if (op_type == cap->oap->op_type) { // double operator works on lines + if (op_type == cap->oap->op_type) { // double operator ("dd") works on lines nv_lineop(cap); } else if (!checkclearop(cap->oap)) { cap->oap->start = curwin->w_cursor; @@ -6274,13 +6289,17 @@ static void invoke_edit(cmdarg_T *cap, int repl, int cmd, int startln) if (cap->cmdchar != 'O' && cap->cmdchar != 'o') { curbuf->b_last_changedtick_i = buf_get_changedtick(curbuf); } - if (edit(cmd, startln, cap->count1)) { + InsSession session = atom_ins_start(cmd, cap->count1, kVInsNone, false); + bool busy = edit(cmd, startln, cap->count1); + if (busy) { cap->retval |= CA_COMMAND_BUSY; } if (restart_edit == 0) { restart_edit = restart_edit_save; } + + atom_ins_end(&session, busy); } /// "a" or "i" while an operator is pending or in Visual mode: object motion. @@ -6417,6 +6436,7 @@ static void nv_at(cmdarg_T *cap) return; } } + atom_macro_start(cap->nchar); while (cap->count1-- && !got_int) { if (do_execreg(cap->nchar, false, false, false) == false) { clearopbeep(cap->oap); @@ -6458,8 +6478,7 @@ static void nv_join(cmdarg_T *cap) cap->count0 = curbuf->b_ml.ml_line_count - curwin->w_cursor.lnum + 1; } - prep_redo(cap->oap->regname, cap->count0, - NUL, cap->cmdchar, NUL, NUL, cap->nchar); + prep_redo_cmd(cap); do_join((size_t)cap->count0, cap->nchar == NUL, true, true, true); } @@ -6514,7 +6533,15 @@ static void nv_put_opt(cmdarg_T *cap, bool fix_indent) || ((cap->cmdchar == 'g' || cap->cmdchar == 'z') && cap->nchar == 'P')) ? BACKWARD : FORWARD; } - prep_redo_cmd(cap); + bool vatom_prepped = false; + if (Visual.active) { + // Visual-mode put: complete the visual atom ("viw" + "p"). + vatom_prepped = atom_visual_end((CmdSpec){ .regname = cap->oap->regname, .count = cap->count0, + .cmd = cap->cmdchar, .cmd2 = cap->nchar }, true); + } + if (!vatom_prepped) { + prep_redo_cmd(cap); + } if (cap->cmdchar == 'g') { flags |= PUT_CURSEND; } else if (cap->cmdchar == 'z') { @@ -6552,8 +6579,10 @@ static void nv_put_opt(cmdarg_T *cap, bool fix_indent) cap->nchar = NUL; cap->oap->regname = keep_registers ? '_' : NUL; msg_silent++; + atom_suppress(true); // internal op: the put atom already cascades nv_operator(cap); do_pending_operator(cap, 0, false); + atom_suppress(false); empty = (curbuf->b_ml.ml_flags & ML_EMPTY); msg_silent--; diff --git a/src/nvim/normal_defs.h b/src/nvim/normal_defs.h index b2d4801f53..dd390ad960 100644 --- a/src/nvim/normal_defs.h +++ b/src/nvim/normal_defs.h @@ -66,15 +66,12 @@ enum { CA_NO_ADJ_OP_END = 2, ///< don't adjust operator end }; -/// A Visual selection's mode and extent (line/column span, not absolute positions), so it can be -/// re-applied starting at the cursor: for "gv" reselect (`Visual.resel`) and Visual-operator redo -/// (`redo_VIsual`). +/// A Visual selection's mode and extent (line/column span, not absolute positions), so an +/// equal-sized region can be re-applied starting at the cursor: {count}v reselect. typedef struct { int mode; ///< 'v', 'V', or Ctrl-V linenr_T line_count; ///< number of lines colnr_T vcol; ///< number of cols or end column (MAXCOL: to end of line) - int count; ///< count for the Visual operator - int arg; ///< extra argument } VisualExtent; /// Visual/Select mode state, as one global "group" (Visual). Previously these were bare EXTERN @@ -88,8 +85,7 @@ typedef struct { int restart_select; ///< Restart Select mode when next cmd finished. int reselect; ///< Restart the selection after a Select-mode mapping or menu. int mode; ///< Type of Visual mode: 'v', 'V', Ctrl-V. - bool redo_busy; ///< True when redoing Visual. - VisualExtent resel; ///< Previous Visual area, for reselection ("gv"); seeds operator-redo. + VisualExtent resel; ///< Previous Visual area's extent, for {count}v reselect. } VisualState; /// Replacement for nchar used by nv_replace(). diff --git a/src/nvim/ops.c b/src/nvim/ops.c index b58ba21bbd..4e9e197462 100644 --- a/src/nvim/ops.c +++ b/src/nvim/ops.c @@ -43,6 +43,7 @@ #include "nvim/indent.h" #include "nvim/indent_c.h" #include "nvim/input.h" +#include "nvim/input_cmdatom.h" #include "nvim/input_defs.h" #include "nvim/insert.h" #include "nvim/keycodes.h" @@ -108,7 +109,7 @@ static const char opchars[][3] = { { 'r', NUL, OPF_CHANGE }, // OP_REPLACE { 'I', NUL, OPF_CHANGE }, // OP_INSERT { 'A', NUL, OPF_CHANGE }, // OP_APPEND - { 'z', 'f', 0 }, // OP_FOLD + { 'z', 'f', 0 }, // OP_FOLD { 'z', 'o', OPF_LINES }, // OP_FOLDOPEN { 'z', 'O', OPF_LINES }, // OP_FOLDOPENREC { 'z', 'c', OPF_LINES }, // OP_FOLDCLOSE @@ -170,6 +171,13 @@ int op_is_change(int op) return opchars[op][2] & OPF_CHANGE; } +/// Whether operator `op` builds a redo: text-changing operators do, ":" and the fold operators do +/// not. Yank with the 'y' flag in 'cpoptions'. +bool op_redoable(int op, bool redo_yank) +{ + return op == OP_YANK ? redo_yank : op_is_change(op); +} + /// Get first operator command character. /// /// @return 'g' or 'z' if there is another command character. @@ -3189,7 +3197,7 @@ static void op_function(const oparg_T *oap) /// Calculate start/end virtual columns for operating in block mode. /// /// @param initial when true: adjust position for 'selectmode' -static void get_op_vcol(oparg_T *oap, colnr_T redo_VIsual_vcol, bool initial) +static void get_op_vcol(oparg_T *oap, bool initial) { colnr_T start; colnr_T end; @@ -3205,18 +3213,16 @@ static void get_op_vcol(oparg_T *oap, colnr_T redo_VIsual_vcol, bool initial) mark_mb_adjustpos(curwin->w_buffer, &oap->end); getvvcol(curwin, &(oap->start), &oap->start_vcol, NULL, &oap->end_vcol, 0); - if (!Visual.redo_busy) { - getvvcol(curwin, &(oap->end), &start, NULL, &end, 0); + getvvcol(curwin, &(oap->end), &start, NULL, &end, 0); - oap->start_vcol = MIN(oap->start_vcol, start); - if (end > oap->end_vcol) { - if (initial && *p_sel == 'e' - && start >= 1 - && start - 1 >= oap->end_vcol) { - oap->end_vcol = start - 1; - } else { - oap->end_vcol = end; - } + oap->start_vcol = MIN(oap->start_vcol, start); + if (end > oap->end_vcol) { + if (initial && *p_sel == 'e' + && start >= 1 + && start - 1 >= oap->end_vcol) { + oap->end_vcol = start - 1; + } else { + oap->end_vcol = end; } } @@ -3229,8 +3235,6 @@ static void get_op_vcol(oparg_T *oap, colnr_T redo_VIsual_vcol, bool initial) getvvcol(curwin, &curwin->w_cursor, NULL, NULL, &end, 0); oap->end_vcol = MAX(oap->end_vcol, end); } - } else if (Visual.redo_busy) { - oap->end_vcol = oap->start_vcol + redo_VIsual_vcol - 1; } // Correct oap->end.col and oap->start.col to be the @@ -3252,6 +3256,24 @@ static bool is_ex_cmdchar(cmdarg_T *cap) return cap->cmdchar == ':' || cap->cmdchar == K_COMMAND; } +/// How an Insert-entering operator (OP_CHANGE/OP_INSERT/OP_APPEND) was entered from Visual mode: +/// decides the session's redo/publish handling (atom_ins_start()). +static VisualIns op_ins_visual(oparg_T *oap, cmdarg_T *cap) +{ + if (!oap->is_VIsual) { + return kVInsNone; + } + if (is_ex_cmdchar(cap) || cap->cmdchar == K_LUA || oap->motion_force != NUL + || (cap->cmdchar == 'g' + && (cap->nchar == 'n' || cap->nchar == 'N' || cap->nchar == 'v'))) { + // The selection came from a self-selecting motion (gn/gN/gv, an omap running ":normal", a Lua + // motion) or a forced-motion operator: the redo replays the motion's own keys instead. + return kVInsOther; + } + // Unreplayable (void/absent) selection: the redo falls back to an equal-size reselect ("1v"). + return atom_visual_replayable() ? kVInsKeys : kVInsOther; +} + /// Handle an operator after Visual mode or when the movement is finished. /// "gui_yank" is true when yanking text for the clipboard. void do_pending_operator(cmdarg_T *cap, int old_col, bool gui_yank) @@ -3259,9 +3281,6 @@ void do_pending_operator(cmdarg_T *cap, int old_col, bool gui_yank) oparg_T *oap = cap->oap; int lbr_saved = curwin->w_p_lbr; - // The visual area is remembered for redo - static VisualExtent redo_VIsual = { NUL, 0, 0, 0, 0 }; - pos_T old_cursor = curwin->w_cursor; // If an operation is pending, handle it... @@ -3301,75 +3320,46 @@ void do_pending_operator(cmdarg_T *cap, int old_col, bool gui_yank) Visual.reselect = false; } - // Only redo yank when 'y' flag is in 'cpoptions'. - // Never redo "zf" (define fold). - if ((redo_yank || oap->op_type != OP_YANK) + atom_capture_op(oap, cap, redo_yank); + if (op_redoable(oap->op_type, redo_yank) && ((!Visual.active || oap->motion_force) // Also redo Operator-pending Visual mode mappings. - || ((is_ex_cmdchar(cap) || cap->cmdchar == K_LUA) - && oap->op_type != OP_COLON)) - && cap->cmdchar != 'D' - && oap->op_type != OP_FOLD - && oap->op_type != OP_FOLDOPEN - && oap->op_type != OP_FOLDOPENREC - && oap->op_type != OP_FOLDCLOSE - && oap->op_type != OP_FOLDCLOSEREC - && oap->op_type != OP_FOLDDEL - && oap->op_type != OP_FOLDDELREC) { - prep_redo(oap->regname, cap->count0, - get_op_char(oap->op_type), get_extra_op_char(oap->op_type), - oap->motion_force, cap->cmdchar, cap->nchar); + || is_ex_cmdchar(cap) || cap->cmdchar == K_LUA) + && cap->cmdchar != 'D') { + prep_redo(NULL, 0, false, (CmdSpec){ + .regname = oap->regname, .count = cap->count0, + .op = get_op_char(oap->op_type), .op_extra = get_extra_op_char(oap->op_type), + .motion_force = oap->motion_force, .cmd = cap->cmdchar, .cmd2 = cap->nchar, + }); if (cap->cmdchar == '/' || cap->cmdchar == '?') { // was a search // If 'cpoptions' does not contain 'r', insert the search // pattern to really repeat the same command. if (vim_strchr(p_cpo, kCpoRedo) == NULL) { - AppendToRedobuffLit(cap->searchbuf, -1); + redo_append_lit(cap->searchbuf, -1); } - AppendToRedobuff(NL_STR); + redo_append_str(S_LEN(NL_STR)); } else if (is_ex_cmdchar(cap)) { // do_cmdline() has stored the first typed line in // "repeat_cmdline". When several lines are typed repeating // won't be possible. if (repeat_cmdline == NULL) { - ResetRedobuff(); + redo_new((CmdSpec){ 0 }); } else { if (cap->cmdchar == ':') { - AppendToRedobuffLit(repeat_cmdline, -1); + redo_append_lit(repeat_cmdline, -1); } else { - AppendToRedobuffSpec(repeat_cmdline); + redo_append_spec(repeat_cmdline); } - AppendToRedobuff(NL_STR); + redo_append_str(S_LEN(NL_STR)); XFREE_CLEAR(repeat_cmdline); } } else if (cap->cmdchar == K_LUA) { - AppendNumberToRedobuff(repeat_luaref); - AppendToRedobuff(NL_STR); + redo_append_num(repeat_luaref); + redo_append_str(S_LEN(NL_STR)); } } - if (Visual.redo_busy) { - // Redo of an operation on a Visual area. Use the same size from - // redo_VIsual.line_count and redo_VIsual.vcol. - oap->start = curwin->w_cursor; - curwin->w_cursor.lnum += redo_VIsual.line_count - 1; - curwin->w_cursor.lnum = MIN(curwin->w_cursor.lnum, curbuf->b_ml.ml_line_count); - Visual.mode = redo_VIsual.mode; - if (redo_VIsual.vcol == MAXCOL || Visual.mode == 'v') { - if (Visual.mode == 'v') { - if (redo_VIsual.line_count <= 1) { - validate_virtcol(curwin); - curwin->w_curswant = curwin->w_virtcol + redo_VIsual.vcol - 1; - } else { - curwin->w_curswant = redo_VIsual.vcol; - } - } else { - curwin->w_curswant = MAXCOL; - } - coladvance(curwin, curwin->w_curswant); - } - cap->count0 = redo_VIsual.count; - cap->count1 = (cap->count0 == 0 ? 1 : cap->count0); - } else if (Visual.active) { + if (Visual.active) { if (!gui_yank) { // Save the current Visual area for '< and '> marks, and "gv" curbuf->b_visual.vi_start = Visual.start; @@ -3451,12 +3441,11 @@ void do_pending_operator(cmdarg_T *cap, int old_col, bool gui_yank) // Set "virtual_op" before resetting Visual.active. virtual_op = virtual_active(curwin); - if (Visual.active || Visual.redo_busy) { - get_op_vcol(oap, redo_VIsual.vcol, true); + if (Visual.active) { + get_op_vcol(oap, true); - if (!Visual.redo_busy && !gui_yank) { - // Prepare to reselect and redo Visual: this is based on the - // size of the Visual text + if (!gui_yank) { + // Remember Visual text size, for {count}v reselect (:h visual-repeat). Visual.resel.mode = Visual.mode; if (curwin->w_curswant == MAXCOL) { Visual.resel.vcol = MAXCOL; @@ -3476,47 +3465,29 @@ void do_pending_operator(cmdarg_T *cap, int old_col, bool gui_yank) Visual.resel.line_count = oap->line_count; } - // can't redo yank (unless 'y' is in 'cpoptions') and ":" - if ((redo_yank || oap->op_type != OP_YANK) - && oap->op_type != OP_COLON - && oap->op_type != OP_FOLD - && oap->op_type != OP_FOLDOPEN - && oap->op_type != OP_FOLDOPENREC - && oap->op_type != OP_FOLDCLOSE - && oap->op_type != OP_FOLDCLOSEREC - && oap->op_type != OP_FOLDDEL - && oap->op_type != OP_FOLDDELREC - && oap->motion_force == NUL) { - // Prepare for redoing. Only use the nchar field for "r", - // otherwise it might be the second char of the operator. + if (op_redoable(oap->op_type, redo_yank) && oap->motion_force == NUL) { + // Prepare for redoing. "gn"/"gN"/"gv" motions select their own region: the + // redo replays the motion itself ("dgn" re-searches, "dgv" reselects). if (cap->cmdchar == 'g' && (cap->nchar == 'n' - || cap->nchar == 'N')) { - prep_redo(oap->regname, cap->count0, - get_op_char(oap->op_type), get_extra_op_char(oap->op_type), - oap->motion_force, cap->cmdchar, cap->nchar); - } else if (!is_ex_cmdchar(cap) && cap->cmdchar != K_LUA) { - int opchar = get_op_char(oap->op_type); - int extra_opchar = get_extra_op_char(oap->op_type); - int nchar = oap->op_type == OP_REPLACE ? cap->nchar : NUL; - - // reverse what nv_replace() did - if (nchar == REPLACE_CR_NCHAR) { - nchar = CAR; - } else if (nchar == REPLACE_NL_NCHAR) { - nchar = NL; - } - - if (opchar == 'g' && extra_opchar == '@') { - // also repeat the count for 'operatorfunc' - prep_redo_num2(oap->regname, 0, NUL, 'v', cap->count0, opchar, extra_opchar, nchar); - } else { - prep_redo(oap->regname, 0, NUL, 'v', opchar, extra_opchar, nchar); - } - } - if (!Visual.redo_busy) { - redo_VIsual = Visual.resel; - redo_VIsual.count = cap->count0; - redo_VIsual.arg = cap->arg; + || cap->nchar == 'N' + || cap->nchar == 'v')) { + prep_redo(NULL, 0, false, (CmdSpec){ + .regname = oap->regname, .count = cap->count0, + .op = get_op_char(oap->op_type), .op_extra = get_extra_op_char(oap->op_type), + .motion_force = oap->motion_force, .cmd = cap->cmdchar, .cmd2 = cap->nchar, + }); + } else if ((oap->op_type == OP_CHANGE || oap->op_type == OP_INSERT + || oap->op_type == OP_APPEND) + && !is_ex_cmdchar(cap) && cap->cmdchar != K_LUA) { + // Visual-entered Insert: redo body opens with the selection's captured keys; appends the + // op+text+. Unreplayable (void) selection falls back to "1v" (fixed-size reselect). + // (Ex/Lua-motion selections were already prepped above, as the motion's keys.) + String v = atom_visual_span(); + prep_redo(v.data != NULL ? v.data : "1v", v.data != NULL ? v.size : 2, false, (CmdSpec){ + .regname = oap->regname, + .op = get_op_char(oap->op_type), .op_extra = get_extra_op_char(oap->op_type), + }); + xfree(v.data); } } @@ -3546,8 +3517,6 @@ void do_pending_operator(cmdarg_T *cap, int old_col, bool gui_yank) } } - Visual.redo_busy = false; - // Switch Visual off now, so screen updating does // not show inverted text when the screen is redrawn. // With OP_YANK and sometimes with OP_COLON and OP_FILTER there is @@ -3651,7 +3620,7 @@ void do_pending_operator(cmdarg_T *cap, int old_col, bool gui_yank) Visual.reselect = false; // don't reselect now if (empty_region_error) { vim_beep(kOptBoFlagOperator); - CancelRedo(); + redo_cancel(); } else { op_delete(oap); // save cursor line for undo if it wasn't saved yet @@ -3667,7 +3636,7 @@ void do_pending_operator(cmdarg_T *cap, int old_col, bool gui_yank) if (empty_region_error) { if (!gui_yank) { vim_beep(kOptBoFlagOperator); - CancelRedo(); + redo_cancel(); } } else { restore_lbr(lbr_saved); @@ -3684,7 +3653,7 @@ void do_pending_operator(cmdarg_T *cap, int old_col, bool gui_yank) Visual.reselect = false; // don't reselect now if (empty_region_error) { vim_beep(kOptBoFlagOperator); - CancelRedo(); + redo_cancel(); } else { // This is a new edit command, not a restart. Need to // remember it to make i_CTRL-O work with mappings for @@ -3702,18 +3671,22 @@ void do_pending_operator(cmdarg_T *cap, int old_col, bool gui_yank) // trigger TextChangedI curbuf->b_last_changedtick_i = buf_get_changedtick(curbuf); - if (op_change(oap)) { // will call edit() + InsSession session = atom_ins_start('c', cap->count0, op_ins_visual(oap, cap), + oap->is_VIsual && oap->motion_type == kMTBlockWise); + bool busy = op_change(oap); // will call edit() + if (busy) { cap->retval |= CA_COMMAND_BUSY; } if (restart_edit == 0) { restart_edit = restart_edit_save; } + atom_ins_end(&session, busy); } break; case OP_FILTER: if (vim_strchr(p_cpo, kCpoFilter) != NULL) { - AppendToRedobuff("!\r"); // Use any last used !cmd. + redo_append_str(S_LEN("!\r")); // Use any last used !cmd. } else { bangredo = true; // do_bang() will put cmd in redo buffer. } @@ -3745,7 +3718,7 @@ void do_pending_operator(cmdarg_T *cap, int old_col, bool gui_yank) case OP_ROT13: if (empty_region_error) { vim_beep(kOptBoFlagOperator); - CancelRedo(); + redo_cancel(); } else { op_tilde(oap); } @@ -3768,26 +3741,21 @@ void do_pending_operator(cmdarg_T *cap, int old_col, bool gui_yank) op_format(oap, true); // use internal function break; - case OP_FUNCTION: { - VisualExtent save_redo_VIsual = redo_VIsual; - + case OP_FUNCTION: // Restore linebreak, so that when the user edits it looks as before. restore_lbr(lbr_saved); + atom_opfunc_slice(true); // Keys the opfunc reads interactively (getchar) are its payload. // call 'operatorfunc' op_function(oap); - - // Restore the info for redoing Visual mode, the function may - // invoke another operator and unintentionally change it. - redo_VIsual = save_redo_VIsual; + atom_opfunc_slice(false); break; - } case OP_INSERT: case OP_APPEND: Visual.reselect = false; // don't reselect now if (empty_region_error) { vim_beep(kOptBoFlagOperator); - CancelRedo(); + redo_cancel(); } else { // This is a new edit command, not a restart. Need to // remember it to make i_CTRL-O work with mappings for @@ -3801,7 +3769,10 @@ void do_pending_operator(cmdarg_T *cap, int old_col, bool gui_yank) // trigger TextChangedI curbuf->b_last_changedtick_i = buf_get_changedtick(curbuf); + InsSession session = atom_ins_start('I', cap->count1, op_ins_visual(oap, cap), + oap->motion_type == kMTBlockWise); op_insert(oap, cap->count1); + atom_ins_end(&session, false); // Reset linebreak, so that formatting works correctly. reset_lbr(); @@ -3822,7 +3793,7 @@ void do_pending_operator(cmdarg_T *cap, int old_col, bool gui_yank) Visual.reselect = false; // don't reselect now if (empty_region_error) { vim_beep(kOptBoFlagOperator); - CancelRedo(); + redo_cancel(); } else { // Restore linebreak, so that when the user edits it looks as before. restore_lbr(lbr_saved); @@ -3860,11 +3831,11 @@ void do_pending_operator(cmdarg_T *cap, int old_col, bool gui_yank) case OP_NR_SUB: if (empty_region_error) { vim_beep(kOptBoFlagOperator); - CancelRedo(); + redo_cancel(); } else { Visual.active = true; restore_lbr(lbr_saved); - op_addsub(oap, (linenr_T)cap->count1, redo_VIsual.arg); + op_addsub(oap, (linenr_T)cap->count1, cap->arg); Visual.active = false; } check_cursor_col(curwin); diff --git a/src/nvim/ops.h b/src/nvim/ops.h index faef90efed..7c67dc03ad 100644 --- a/src/nvim/ops.h +++ b/src/nvim/ops.h @@ -1,5 +1,6 @@ #pragma once +#include // for LUA_NOREF #include #include diff --git a/src/nvim/os/shell.c b/src/nvim/os/shell.c index 7683d6cbbc..02c6a84cdf 100644 --- a/src/nvim/os/shell.c +++ b/src/nvim/os/shell.c @@ -49,7 +49,6 @@ #include "nvim/ui.h" #include "nvim/vim_defs.h" -#define NS_1_SECOND 1000000000U // 1 second, in nanoseconds #define OUT_DATA_THRESHOLD 1024 * 10U // 10KB, "a few screenfuls" of data. #define SHELL_SPECIAL "\t \"&'$;<>()\\|\n" @@ -1049,10 +1048,10 @@ static bool out_data_decide_throttle(size_t size) started = os_hrtime(); } else { uint64_t since = os_hrtime() - started; - if (since < (visit * (NS_1_SECOND / 10))) { + if (since < (visit * (NS_PER_SEC / 10))) { return true; } - if (since > (3 * NS_1_SECOND)) { + if (since > (3 * NS_PER_SEC)) { received = visit = 0; return false; } diff --git a/src/nvim/os/time.c b/src/nvim/os/time.c index 2e6bf3e56d..e9dc7cba1b 100644 --- a/src/nvim/os/time.c +++ b/src/nvim/os/time.c @@ -48,7 +48,7 @@ int64_t os_realtime(void) ELOG("uv_clock_gettime failed: %d %s", error_number, uv_err_name(error_number)); return 0; } - return ts.tv_sec * 1000000000L + ts.tv_nsec; + return ts.tv_sec * NS_PER_SEC + ts.tv_nsec; } /// Gets a millisecond-resolution, monotonically-increasing time relative to an diff --git a/src/nvim/os/time_defs.h b/src/nvim/os/time_defs.h index 9b71a6764d..0e128b20ac 100644 --- a/src/nvim/os/time_defs.h +++ b/src/nvim/os/time_defs.h @@ -2,3 +2,5 @@ #include typedef uint64_t Timestamp; + +#define NS_PER_SEC 1000000000U ///< Nanoseconds per second. diff --git a/src/nvim/profile.c b/src/nvim/profile.c index 05294cd5a2..a6e309c598 100644 --- a/src/nvim/profile.c +++ b/src/nvim/profile.c @@ -73,7 +73,7 @@ const char *profile_msg(proftime_T tm) FUNC_ATTR_WARN_UNUSED_RESULT { static char buf[50]; snprintf(buf, sizeof(buf), "%10.6lf", - (double)profile_signed(tm) / 1000000000.0); + (double)profile_signed(tm) / NS_PER_SEC); return buf; } diff --git a/src/nvim/register.c b/src/nvim/register.c index fb5fe5de49..ac6a766987 100644 --- a/src/nvim/register.c +++ b/src/nvim/register.c @@ -171,7 +171,7 @@ int get_default_register_name(void) return name; } -/// Iterate over registers `regs`. +/// Iterate over registers `regs` (only NUM_SAVED_REGISTERS, not clipboard "*"/"+"). /// /// @param[in] iter Iterator. Pass NULL to start iteration. /// @param[in] regs Registers list to be iterated. @@ -206,7 +206,7 @@ const void *op_reg_iter(const void *const iter, const yankreg_T *const regs, cha return NULL; } -/// Iterate over global registers. +/// Iterate over global registers (only NUM_SAVED_REGISTERS, not clipboard "*"/"+"). /// /// @see op_register_iter const void *op_global_reg_iter(const void *const iter, char *const name, yankreg_T *const reg, @@ -216,6 +216,31 @@ const void *op_global_reg_iter(const void *const iter, char *const name, yankreg return op_reg_iter(iter, y_regs, name, reg, is_unnamed); } +/// Gets the most-recent timestamp (nanoseconds) of all registers, or 0 if all are empty. Every +/// register-write sets its timestamp, so this detects if any register changed (e.g. for caching). +/// +/// @param clipboard Include clipboard registers ("*"/"+"). +Timestamp reg_max_ts(bool clipboard) +{ + Timestamp max_ts = 0; + const void *iter = NULL; + do { + char name = NUL; + yankreg_T reg; + bool is_unnamed = false; + iter = op_global_reg_iter(iter, &name, ®, &is_unnamed); + if (name == NUL) { + break; + } + max_ts = MAX(max_ts, reg.timestamp); + } while (iter != NULL); + if (clipboard) { + max_ts = MAX(max_ts, get_y_register(op_reg_index('*'))->timestamp); + max_ts = MAX(max_ts, get_y_register(op_reg_index('+'))->timestamp); + } + return max_ts; +} + /// Get a number of non-empty registers size_t op_reg_amount(void) FUNC_ATTR_WARN_UNUSED_RESULT @@ -424,7 +449,7 @@ static int stuff_yank(int regname, char *p) reg->y_size = 1; reg->y_type = kMTCharWise; } - reg->timestamp = os_time(); + reg->timestamp = (Timestamp)os_realtime(); return OK; } @@ -786,8 +811,8 @@ int insert_reg(int regname, yankreg_T *reg, bool literally_arg) curwin->w_cursor = curpos; } - AppendCharToRedobuff(Ctrl_R); - AppendCharToRedobuff(regname); + redo_append_char(Ctrl_R); + redo_append_char(regname); do_put(regname, NULL, dir, 1, PUT_CURSEND); } else { stuffescaped(reg->y_array[i].data, literally); @@ -1036,7 +1061,7 @@ void op_yank_reg(oparg_T *oap, bool message, yankreg_T *reg, bool append) reg->y_width = 0; reg->y_array = xcalloc(yanklines, sizeof(String)); reg->additional_data = NULL; - reg->timestamp = os_time(); + reg->timestamp = (Timestamp)os_realtime(); size_t y_idx = 0; // index in y_array[] linenr_T lnum = oap->start.lnum; // current line number @@ -2649,7 +2674,7 @@ static void str_to_reg(yankreg_T *y_ptr, MotionType yank_type, const char *str, y_ptr->y_type = yank_type; y_ptr->y_size = lnum; XFREE_CLEAR(y_ptr->additional_data); - y_ptr->timestamp = os_time(); + y_ptr->timestamp = (Timestamp)os_realtime(); if (yank_type == kMTBlockWise) { y_ptr->y_width = (blocklen == -1 ? (colnr_T)maxlen - 1 : blocklen); } else { diff --git a/src/nvim/shada.c b/src/nvim/shada.c index 69cd28dc95..e191180dae 100644 --- a/src/nvim/shada.c +++ b/src/nvim/shada.c @@ -910,17 +910,18 @@ static void shada_read(FileDescriptor *const sd_reader, const int flags) const bool get_old_files = (flags & (kShaDaGetOldfiles | kShaDaForceit) && (force || tv_list_len(oldfiles_list) == 0)); const bool want_marks = flags & kShaDaWantMarks; + const bool no_opt = flags & kShaDaNoOpt; const unsigned srni_flags = (unsigned)( (flags & kShaDaWantInfo ? (kSDReadUndisableableData | kSDReadRegisters | kSDReadGlobalMarks - | (p_hi ? kSDReadHistory : 0) - | (find_shada_parameter('!') != NULL + | (p_hi && !(flags & kShaDaNoHistory) ? kSDReadHistory : 0) + | (no_opt || find_shada_parameter('!') != NULL ? kSDReadVariables : 0) - | (find_shada_parameter('%') != NULL + | ((no_opt || find_shada_parameter('%') != NULL) && ARGCOUNT == 0 ? kSDReadBufferList : 0)) @@ -1042,32 +1043,36 @@ static void shada_read(FileDescriptor *const sd_reader, const int flags) hms_insert(hms + cur_entry.data.history_item.histtype, cur_entry, true); // Do not free shada entry: its allocated memory was saved above. break; - case kSDItemRegister: + case kSDItemRegister: { if (cur_entry.data.reg.type != kMTCharWise && cur_entry.data.reg.type != kMTLineWise && cur_entry.data.reg.type != kMTBlockWise) { shada_free_shada_entry(&cur_entry); break; } + // reg are ns; shada stores seconds; Context/multicursor wants high-precision comparison. + const Timestamp reg_ts = (flags & kShaDaNanos) + ? cur_entry.timestamp : cur_entry.timestamp * NS_PER_SEC; if (!force) { const yankreg_T *const reg = op_reg_get(cur_entry.data.reg.name); - if (reg == NULL || reg->timestamp >= cur_entry.timestamp) { + if (reg == NULL || reg->timestamp >= reg_ts) { shada_free_shada_entry(&cur_entry); break; } } if (!op_reg_set(cur_entry.data.reg.name, (yankreg_T) { - .y_array = cur_entry.data.reg.contents, - .y_size = cur_entry.data.reg.contents_size, - .y_type = cur_entry.data.reg.type, - .y_width = (colnr_T)cur_entry.data.reg.width, - .timestamp = cur_entry.timestamp, - .additional_data = cur_entry.additional_data, - }, cur_entry.data.reg.is_unnamed)) { + .y_array = cur_entry.data.reg.contents, + .y_size = cur_entry.data.reg.contents_size, + .y_type = cur_entry.data.reg.type, + .y_width = (colnr_T)cur_entry.data.reg.width, + .timestamp = reg_ts, + .additional_data = cur_entry.additional_data, + }, cur_entry.data.reg.is_unnamed)) { shada_free_shada_entry(&cur_entry); } // Do not free shada entry: its allocated memory was saved above. break; + } case kSDItemVariable: var_set_global(cur_entry.data.global_var.name, cur_entry.data.global_var.value); @@ -1803,7 +1808,7 @@ static inline ShaDaWriteResult shada_read_when_writing(FileDescriptor *const sd_ } if (wms->registers[idx].type == kSDItemMissing) { const yankreg_T *const reg = op_reg_get(entry.data.reg.name); - if (reg != NULL && reg_empty(reg) && reg->timestamp >= entry.timestamp) { + if (reg != NULL && reg_empty(reg) && reg->timestamp / NS_PER_SEC >= entry.timestamp) { shada_free_shada_entry(&entry); break; } @@ -2108,7 +2113,9 @@ static inline void add_search_pattern(ShadaEntry *const ret_pse, /// /// @param[in] wms The WriteMergerState used when writing. /// @param[in] max_reg_lines The maximum number of register lines. -static inline void shada_initialize_registers(WriteMergerState *const wms, int max_reg_lines) +/// @param scale_ts Downscale the timestamp to seconds (shada legacy). +static inline void shada_initialize_registers(WriteMergerState *const wms, int max_reg_lines, + bool scale_ts) FUNC_ATTR_NONNULL_ALL FUNC_ATTR_ALWAYS_INLINE { const void *reg_iter = NULL; @@ -2127,7 +2134,7 @@ static inline void shada_initialize_registers(WriteMergerState *const wms, int m wms->registers[op_reg_index(name)] = (ShadaEntry) { .can_free_entry = false, .type = kSDItemRegister, - .timestamp = reg.timestamp, + .timestamp = scale_ts ? reg.timestamp / NS_PER_SEC : reg.timestamp, .data = { .reg = { .contents = reg.y_array, @@ -2483,7 +2490,7 @@ static ShaDaWriteResult shada_write(FileDescriptor *const sd_writer, // Initialize registers if (dump_registers) { - shada_initialize_registers(wms, max_reg_lines); + shada_initialize_registers(wms, max_reg_lines, true); } // Initialize buffers @@ -3609,14 +3616,17 @@ static inline size_t shada_init_jumps(ShadaEntry *jumps, Set(ptr_t) *const remov } /// Gets registers as a msgpack-encoded string, in shada format. -String shada_encode_regs(void) +/// +/// @param scale_ts Downscale the timestamp to seconds (shada legacy). +/// @param since Only registers modified => this time (0: all). Nanoseconds, unless `scale_ts`. +String shada_encode_regs(bool scale_ts, Timestamp since) FUNC_ATTR_NONNULL_ALL { WriteMergerState *const wms = xcalloc(1, sizeof(*wms)); - shada_initialize_registers(wms, -1); + shada_initialize_registers(wms, -1, scale_ts); PackerBuffer packer = packer_string_buffer(); for (size_t i = 0; i < ARRAY_SIZE(wms->registers); i++) { - if (wms->registers[i].type == kSDItemRegister) { + if (wms->registers[i].type == kSDItemRegister && wms->registers[i].timestamp >= since) { if (kSDWriteFailed == shada_pack_pfreed_entry(&packer, wms->registers[i], 0)) { abort(); diff --git a/src/nvim/shada.h b/src/nvim/shada.h index 7fbdceabc5..6c1f721308 100644 --- a/src/nvim/shada.h +++ b/src/nvim/shada.h @@ -1,6 +1,7 @@ #pragma once #include "nvim/api/private/defs.h" // IWYU pragma: keep +#include "nvim/os/time_defs.h" // IWYU pragma: keep /// Flags for shada_read_file and children typedef enum { @@ -9,6 +10,9 @@ typedef enum { kShaDaForceit = 4, ///< Overwrite info already read kShaDaGetOldfiles = 8, ///< Load v:oldfiles. kShaDaMissingError = 16, ///< Error out when os_open returns -ENOENT. + kShaDaNanos = 32, ///< Timestamps are ns (Context), instead of seconds (shada legacy). + kShaDaNoHistory = 64, ///< Skip cmdline/search history merge (perf: O(history) per read). + kShaDaNoOpt = 128, ///< Ignore 'shada': only these flags decide what is read. } ShaDaReadFileFlags; #include "shada.h.generated.h" diff --git a/src/nvim/spellsuggest.c b/src/nvim/spellsuggest.c index 47ded7a86e..a87dc95592 100644 --- a/src/nvim/spellsuggest.c +++ b/src/nvim/spellsuggest.c @@ -620,11 +620,10 @@ void spell_suggest(int count) strcat(p, sug.su_badptr + stp->st_orglen); // For redo we use a change-word command. - ResetRedobuff(); - AppendToRedobuff("ciw"); - AppendToRedobuffLit(p + c, - stp->st_wordlen + sug.su_badlen - stp->st_orglen); - AppendCharToRedobuff(ESC); + redo_new((CmdSpec){ 0 }); + redo_append_str(S_LEN("ciw")); + redo_append_lit(p + c, stp->st_wordlen + sug.su_badlen - stp->st_orglen); + redo_append_char(ESC); // "p" may be freed here ml_replace(curwin->w_cursor.lnum, p, false); diff --git a/src/nvim/terminal.c b/src/nvim/terminal.c index 84a21521a9..2e343fdea8 100644 --- a/src/nvim/terminal.c +++ b/src/nvim/terminal.c @@ -2316,7 +2316,7 @@ end: return false; } - requeue_key(vgetc_char, vgetc_mod_mask, true); + requeue_key(vgetc_char, vgetc_mod_mask, 0, true); return true; } diff --git a/src/nvim/undo.c b/src/nvim/undo.c index 5c7c2a0364..2db2afd1cb 100644 --- a/src/nvim/undo.c +++ b/src/nvim/undo.c @@ -107,6 +107,7 @@ #include "nvim/globals.h" #include "nvim/highlight_defs.h" #include "nvim/input.h" +#include "nvim/input_cmdatom.h" #include "nvim/insert.h" #include "nvim/macros_defs.h" #include "nvim/mark.h" @@ -1873,6 +1874,7 @@ static void u_doit(int startcount, bool quiet, bool do_buf_event) if (!undo_allowed(curbuf)) { return; } + atom_op_global_set(); // multicursor: undo/redo must not cascade (global, not per-cursor). u_newcount = 0; u_oldcount = 0; diff --git a/test/functional/editor/atom_testutil.lua b/test/functional/editor/atom_testutil.lua new file mode 100644 index 0000000000..6a1d6de279 --- /dev/null +++ b/test/functional/editor/atom_testutil.lua @@ -0,0 +1,94 @@ +-- Helpers shared by the atom-capture specs (mcursor_spec.lua, cmdatom_spec.lua). + +local n = require('test.functional.testnvim')() + +local m = {} + +--- Get buffer lines as a table. +function m.get_lines() + return n.api.nvim_buf_get_lines(0, 0, -1, true) +end + +--- vim.keycode(): |key-notation| => the raw bytes of the CmdAtom event's keys/lhs. +function m.k(s) + return n.api.nvim_replace_termcodes(s, true, true, true) +end + +--- Starts collecting CmdAtom event-data. +function m.atoms_start() + n.exec_lua([[ + _G.atoms = {} + vim.api.nvim_create_autocmd('CmdAtom', { + callback = function(ev) + table.insert(_G.atoms, ev.data) + end, + }) + ]]) +end + +--- Gets the collected CmdAtom event-data (the events are deferred: drain +--- the event loop first). +function m.atoms() + n.poke_eventloop() + return n.exec_lua('return _G.atoms') +end + +--- Gets the last collected CmdAtom event. +function m.atom_last() + local evs = m.atoms() + return evs[#evs] +end + +--- Projects only the named fields of `ev` (a nil field stays absent, so `eq` still asserts +--- omission when the expected table lacks it). +function m.pick(ev, ...) + local r = {} + for _, f in ipairs({ ... }) do + r[f] = ev[f] + end + return r +end + +--- Gets the last `count` collected CmdAtom events: bare `keys` strings by default, or +--- projections of the named `fields`. +function m.atoms_tail(count, ...) + local evs = m.atoms() + local fields = select('#', ...) > 0 and { ... } or nil + local tail = {} + for i = #evs - count + 1, #evs do + table.insert(tail, fields and m.pick(evs[i], unpack(fields)) or evs[i].keys) + end + return tail +end + +--- Minimal vim-surround "ys": an mapping sets 'operatorfunc' and returns "g@"; the opfunc +--- reads the wrap char with getchar() and wraps the motion region (yank, modify register, paste +--- back). +m.minisurround_vim = [[ + function! MiniSurroundSetup() abort + set operatorfunc=MiniSurround + return 'g@' + endfunction + function! MiniSurround(type) abort + let char = nr2char(getchar()) + let save = getreg('"') + silent exe "norm! v`[o`]y" + call setreg('"', char .. getreg('"') .. char, 'v') + silent exe "norm! gvp`[" + call setreg('"', save) + endfunction + nnoremap ys MiniSurroundSetup() +]] + +--- Minimal vim-surround "ds": a ":call" mapping whose edit runs through :normal inside a +--- function, with a getchar() payload naming the surround to delete. +m.delsurround_vim = [[ + function! DelSurround() abort + call getchar() + " cursor is on the "("; delete it and its matching ")". + normal! mz%x`zx + endfunction + nnoremap ds :call DelSurround() +]] + +return m diff --git a/test/functional/editor/cmdatom_spec.lua b/test/functional/editor/cmdatom_spec.lua new file mode 100644 index 0000000000..0823d2c783 --- /dev/null +++ b/test/functional/editor/cmdatom_spec.lua @@ -0,0 +1,866 @@ +-- Tests for atom capture: the CmdAtom event, and dot-repeat of whole insert sessions. + +local t = require('test.testutil') +local n = require('test.functional.testnvim')() +local Screen = require('test.functional.ui.screen') +local t_atom = require('test.functional.editor.atom_testutil') + +local describe, it, before_each = t.describe, t.it, t.before_each +local retry = t.retry +local clear = n.clear +local command = n.command +local exec = n.exec +local feed = n.feed +local fn = n.fn +local eq = t.eq +local api = n.api +local get_lines = t_atom.get_lines +local k = t_atom.k +local atoms_start = t_atom.atoms_start +local atoms = t_atom.atoms +local atoms_tail = t_atom.atoms_tail +local atom_last = t_atom.atom_last +local pick = t_atom.pick + +describe('dot-repeat', function() + before_each(clear) + + it('replays insert-mode cursor-moves (the whole session)', function() + fn.setline(1, { 'one', 'two' }) + feed('iabc') + eq({ 'acbone', 'two' }, get_lines()) + -- The ". register and undo still restart at the cursor-move, like Vim. + eq('c', fn.getreg('.')) + feed('u') + eq({ 'abone', 'two' }, get_lines()) + feed('') + -- "." re-executes the whole session, cursor-move included. + feed('j0.') + eq({ 'acbone', 'acbtwo' }, get_lines()) + -- An absolute jump () still restarts the capture: "." replays + -- only the post-jump insert. + feed('ggAxyz') + eq({ 'zacbonexy', 'acbtwo' }, get_lines()) + eq('z', fn.getreg('.')) + feed('j0.') + eq({ 'zacbonexy', 'zacbtwo' }, get_lines()) + -- Vim's repeat-only-the-tail behavior is a mapping away (documented in + -- vim_diff.txt): i_CTRL-O re-entry restarts the capture. + command('inoremap ') + feed('ggiabc') + eq({ 'acbzacbonexy', 'zacbtwo' }, get_lines()) + feed('j0.') + eq({ 'acbzacbonexy', 'czacbtwo' }, get_lines()) + end) +end) + +describe('CmdAtom', function() + before_each(clear) + + it('a counted mapped motion carries its count in the atom', function() + command('nnoremap j gj') + fn.setline(1, { 'a1', 'b2', 'c3', 'd4', 'e5' }) + feed('gg') + atoms_start() + feed('3j') + eq(4, fn.line('.')) + local ev = atom_last() + eq( + { type = 'motion', lhs = 'j', keys = '3gj', count = 3 }, + pick(ev, 'type', 'lhs', 'keys', 'count') + ) + -- The "," repeat recipe: replaying the KEYS verbatim repeats the count. + feed('gg') + n.exec_lua(([[vim.api.nvim_feedkeys(%q, 'nx', false)]]):format(ev.keys)) + eq(4, fn.line('.')) + -- Multi-command mapping: the pre-typed count lands in the FIRST folded + -- command; the folded atom itself has no single count. + command('nnoremap xw') + fn.setline(1, { 'abcdef ghi', 'jkl' }) + feed('gg0') + feed('3') + ev = atom_last() + eq({ type = 'mapping' }, pick(ev, 'type', 'count')) + eq({ keys = '3dl', count = 3 }, pick(ev.atoms[1], 'keys', 'count')) + end) + + it('a Lua-callback mapping (the "]q" default) emits a mapping atom', function() + -- Same shape as the "]q" default mapping: a Lua callback with no + -- replayable keys. Still a user action: it publishes with an empty + -- replay payload. + n.exec_lua([[ + vim.keymap.set('n', ']q', function() + vim.cmd({ cmd = 'cnext', count = vim.v.count1 }) + end, { desc = ':cnext' }) + ]]) + fn.setline(1, { 'aaa', 'bbb', 'ccc' }) + local bufnr = api.nvim_get_current_buf() + fn.setqflist({ { bufnr = bufnr, lnum = 1 }, { bufnr = bufnr, lnum = 3 } }) + command('cfirst') + atoms_start() + feed(']q') + eq(3, fn.line('.')) -- the mapping did run (:cnext) + eq( + { type = 'mapping', lhs = ']q', keys = '', changed = false }, + pick(atom_last(), 'type', 'lhs', 'keys', 'changed') + ) + -- An empty-keys mapping that DOES edit still reports it: `changed` is + -- the only informative payload of a /Lua-callback edit. + n.exec_lua([[ + vim.keymap.set('n', ',e', function() + vim.api.nvim_buf_set_lines(0, 0, 0, false, { 'NEW' }) + end) + ]]) + feed(',e') + eq({ keys = '', changed = true }, pick(atom_last(), 'keys', 'changed')) + end) + + it('motions, search, Ex emit without an edit', function() + -- Emission is not tied to editing: every user action publishes, so + -- plugins can observe all activity. + fn.setline(1, { 'alpha beta', 'gamma delta' }) + feed('gg0') + atoms_start() + feed('w') + feed('3l') + feed('/gamma') + feed(':nohlsearch') + eq({ + { type = 'motion', keys = 'w' }, + { type = 'motion', keys = '3l' }, + { type = 'motion', keys = k('/gamma') }, + { type = 'ex', keys = k(':nohlsearch') }, + }, atoms_tail(4, 'type', 'keys')) + -- ":" embeds its count as the range prefill, never as composed digits; + -- the count field carries it. + feed('gg') + feed('2:') + eq(2, fn.line('.')) + eq( + { type = 'ex', keys = k(':.,.+1'), count = 2, cmd = ':' }, + pick(atom_last(), 'type', 'keys', 'count', 'cmd') + ) + eq({ 'alpha beta', 'gamma delta' }, get_lines()) -- nothing was edited + -- Scrolls and mouse presses also emit (type "scroll"/"mouse"): emit-only, + -- never cascaded. + feed('') + feed('3') + api.nvim_input_mouse('wheel', 'up', '', 0, 0, 0) + api.nvim_input_mouse('left', 'press', '', 0, 1, 2) + eq({ + { type = 'scroll', keys = k(''), cascade = false }, + { type = 'scroll', keys = k('3'), cascade = false }, + { type = 'scroll', keys = k(''), cascade = false }, + { type = 'mouse', keys = k(''), cascade = false }, + }, atoms_tail(4, 'type', 'keys', 'cascade')) + eq(2, fn.line('.')) -- the click moved the cursor + -- /match is the atom type, never path-expanded. + n.exec_lua([[ + vim.api.nvim_create_autocmd('CmdAtom', { + callback = function(ev) + _G.last_match = ev.match + end, + }) + ]]) + feed('0w') + n.poke_eventloop() + eq('motion', n.exec_lua('return _G.last_match')) + end) + + it('captures counts and payload chars', function() + fn.setline(1, { 'abcd,ef' }) + feed('gg0') + atoms_start() + feed('yw') + -- A yank emits but does not edit. + eq({ operator = 'y', changed = false }, pick(atom_last(), 'operator', 'changed')) + feed('3x') + eq({ 'd,ef' }, get_lines()) + feed('vf,d') + eq({ 'ef' }, get_lines()) + eq({ '3dl', 'vf,d' }, atoms_tail(2)) + eq(3, atoms()[#atoms() - 1].count) + -- Structured decomposition: operator/motion/operand as fields, no byte-parsing. + local op = atoms()[#atoms() - 1] -- "3dl" + eq( + { operator = 'd', cmd = 'l', changed = true }, + pick(op, 'operator', 'cmd', 'arg', 'motionforce', 'changed') + ) + -- A visual atom carries the completing operator's fields, and decomposes + -- into its commands ("v", "f," and the operator). + local vis = atom_last() -- "vf,d" + eq({ type = 'visual', operator = 'd' }, pick(vis, 'type', 'operator')) + eq( + { + { keys = 'v', cmd = 'v', changed = false }, + { keys = 'f,', cmd = 'f', arg = ',', changed = false }, + { keys = 'd', changed = true }, -- the completing operator did the edit + }, + vim.tbl_map(function(c) + return pick(c, 'keys', 'cmd', 'arg', 'changed') + end, vis.atoms) + ) + eq('d', vis.atoms[3].operator) + -- Operators with an interactively-typed search payload: the atom is the + -- redobuff, which includes the payload. + fn.setline(1, { 'aa END bb' }) + feed('gg0') + feed('d/END') + eq({ 'END bb' }, get_lines()) + eq({ k('d/END') }, atoms_tail(1)) + eq( + { operator = 'd', cmd = '/', changed = true }, + pick(atom_last(), 'operator', 'cmd', 'changed') + ) + -- An operand (mark or register name, target char) is its own field; the second char of a + -- two-char command NAME ("gJ") composes into `cmd`. + fn.setline(1, { 'one', 'two' }) + feed('gg0magJ') + eq({ 'onetwo' }, get_lines()) -- "gJ": join without inserting a space + eq({ cmd = 'm', arg = 'a' }, pick(atoms()[#atoms() - 1], 'cmd', 'arg')) + eq({ cmd = 'gJ' }, pick(atom_last(), 'cmd', 'arg')) + local nrec = #atoms() + feed('qax') -- the recording register is an operand, not part of the name + feed('q') + eq({ cmd = 'q', arg = 'a' }, pick(atoms()[nrec + 1], 'cmd', 'arg')) + -- Forced motion type ("dvj") is a field. + fn.setline(1, { 'one', 'two' }) + feed('gg0dvj') + eq( + { operator = 'd', motionforce = 'v', cmd = 'j' }, + pick(atom_last(), 'operator', 'motionforce', 'cmd') + ) + end) + + it('fires for typed input, never for programmatic sources', function() + atoms_start() + -- Drain deferred CmdAtom events, then return + clear the collected list. + local function take() + n.poke_eventloop() + return n.exec_lua([[local a = _G.atoms; _G.atoms = {}; return a]]) + end + -- Fresh buffer + cursor via the API (no keys, so no motion atoms). + local function fresh() + api.nvim_buf_set_lines(0, 0, -1, true, { 'aaaaaaaa', 'bbbbbbbb' }) + api.nvim_win_set_cursor(0, { 1, 0 }) + take() + end + + -- Typed input emits: real keys (nvim_input, via feed())... + fresh() + feed('x') + eq(1, #take()) + -- ...and nvim_feedkeys() with the "t" (typed) flag. + n.exec_lua([[vim.api.nvim_feedkeys('x', 't', false)]]) + eq(1, #take()) + + -- A typed macro folds into exactly ONE atom, labeled "@q". + fresh() + feed('qqxq') -- recording is typed input + take() + feed('@q') + local evs = take() + eq(1, #evs) + eq('@q', evs[1].lhs) + -- Same for "Q" (replay the last recorded register). + feed('Q') + evs = take() + eq(1, #evs) + eq({ lhs = '@q', keys = 'dl' }, pick(evs[1], 'lhs', 'keys')) + + -- Programmatic / replayed input must NOT leak any atom. + fresh() + api.nvim_buf_set_lines(0, 0, 1, true, { 'ZZZZ' }) -- API buffer edit + eq(0, #take()) + api.nvim_buf_set_text(0, 0, 0, 0, 1, { 'Q' }) + eq(0, #take()) + command('normal! x') -- :normal! + eq(0, #take()) + command('normal x') -- :normal (with mappings) + eq(0, #take()) + n.exec_lua([[vim.cmd('normal! x')]]) + eq(0, #take()) + n.exec_lua([[vim.api.nvim_feedkeys('x', '', false)]]) -- feedkeys without "t" + eq(0, #take()) + command('normal! @q') -- macro played programmatically, not typed + eq(0, #take()) + n.exec_lua([[vim.api.nvim_feedkeys('@q', '', false)]]) + eq(0, #take()) + command('normal! yy') -- prep-exempt operator (a yank builds no redo) + eq(0, #take()) + n.exec_lua([[vim.api.nvim_feedkeys('viwd', '', false)]]) -- Visual sequence + eq(0, #take()) + + -- A timer/scheduled API edit must NOT leak. + n.exec_lua([[ + _G.done = false + vim.defer_fn(function() + vim.api.nvim_buf_set_lines(0, 0, 1, true, { 'TTTT' }) + _G.done = true + end, 5) + ]]) + n.exec_lua('vim.wait(200, function() return _G.done end)') + eq(0, #take()) + + -- Sanity: typed input still emits after all the programmatic noise. + fresh() + feed('x') + eq(1, #take()) + end) + + it('visual atom keys re-execute on replay; unreplayable ops fall back to equal-size', function() + -- The core use-case for a plugin: observe CmdAtom, capture a Visual-mode + -- operation's resolved `keys`, and replay them verbatim to re-execute it. + fn.setline(1, { 'foo bar', 'longword bar' }) + feed('gg0') + atoms_start() + feed('viwd') -- select the inner word and delete it + eq({ ' bar', 'longword bar' }, get_lines()) + local ev = atom_last() + eq('visual', ev.type) + -- Replay the captured keys at line 2. The keysequence RE-EXECUTES (not an + -- equal-size reselect): "iw" selects THAT line's word (the longer one), + -- so the delete adapts to the new context. + feed('j0') + n.exec_lua(([[vim.api.nvim_feedkeys(%q, 'nx', false)]]):format(ev.keys)) + eq({ ' bar', ' bar' }, get_lines()) + -- Builtin |.| replays the same keysequence: own-sized re-execution, not + -- Vim's equal-size reselect (|visual-repeat|). + api.nvim_buf_set_lines(0, 0, -1, true, { 'foo bar', 'longword bar' }) + feed('gg0viwd') + eq({ ' bar', 'longword bar' }, get_lines()) + feed('j0.') + eq({ ' bar', ' bar' }, get_lines()) + -- A viewport scroll that drags the cursor along (edge/'scrolloff') grows + -- the selection by a viewport-dependent amount: void, no atom. + local lines = {} + for i = 1, 30 do + lines[i] = 'l' .. i + end + fn.setline(1, lines) + feed('gg') + local before = #atoms() + feed('V') + n.poke_eventloop() + eq(2, fn.line('.')) -- the scroll dragged the cursor: selection is lines 1-2 + feed('d') + eq(before, #atoms()) -- not replayable: no atom published for the edit + eq('l3', fn.getline(1)) -- the edit itself deleted both selected lines + -- "." on the unreplayable operation falls back to an equal-size reselect + -- ("1v" + operator): it deletes the same number of lines at the cursor. + feed('.') + eq('l5', fn.getline(1)) + -- A fed (":normal!") Visual put preps the selection keysequence, like any fed visual + -- operator (":normal! vjd"): "." re-executes "Vjp", not a bare "p". + api.nvim_buf_set_lines(0, 0, -1, true, { 'aa', 'bb', 'cc', 'dd', 'ee' }) + feed('ggyy') + command('normal! Vjp') + eq({ 'aa', 'cc', 'dd', 'ee' }, get_lines()) + feed('j.') + eq({ 'aa', 'aa', 'bb', 'ee' }, get_lines()) + -- {Visual}r replaces with a literal (REPLACE_CR_NCHAR): inexpressible as + -- spec chars, so the literal keys compose the redo tail, which "." replays. + api.nvim_buf_set_lines(0, 0, -1, true, { 'abcd', 'efgh' }) + feed('gg0vlr') + eq({ '\r\rcd', 'efgh' }, get_lines()) + feed('j0.') + eq({ '\r\rcd', '\r\rgh' }, get_lines()) + end) + + it('a mapping can repeat the last visual atom', function() + -- User-defined Visual dot-repeat: capture a visual atom's resolved + -- `keys`, replay them verbatim from a mapping to RE-EXECUTE the + -- operation (not an equal-size reselect like builtin |.|). + n.exec_lua([[ + vim.api.nvim_create_autocmd('CmdAtom', { + pattern = 'visual', + callback = function(ev) + _G.last_visual = ev.data.keys + end, + }) + vim.keymap.set('n', ',', function() + -- Scheduled: runs after any pending CmdAtom event (fresh + -- `last_visual`), and emits no CmdAtom itself. See |CmdAtom|. + vim.schedule(function() + if _G.last_visual then + vim.api.nvim_feedkeys(_G.last_visual, 'n', false) + end + end) + end) + ]]) + --- Count of captured visual atoms. + local function nvisual() + local count = 0 + for _, a in ipairs(atoms()) do + if a.type == 'visual' then + count = count + 1 + end + end + return count + end + fn.setline(1, { 'foo bar', 'longword bar' }) + feed('gg0') + atoms_start() + feed('viwd') + eq({ ' bar', 'longword bar' }, get_lines()) + eq('visual', atom_last().type) + -- "iw" re-executes: it selects THAT line's (longer) word. + feed('j0,') + retry(nil, 1000, function() + eq({ ' bar', ' bar' }, get_lines()) + end) + -- The scheduled replay is programmatic input: it emits no visual atom itself. + eq(1, nvisual()) + -- A Visual change (insert session): the atom embeds the selection, the + -- operator, the inserted text, and ; the repeat re-executes it all. + api.nvim_buf_set_lines(0, 0, -1, true, { 'foo bar', 'longword bar' }) + feed('gg0viwcX') + eq({ 'X bar', 'longword bar' }, get_lines()) + feed('j0,') + retry(nil, 1000, function() + eq({ 'X bar', 'X bar' }, get_lines()) + end) + eq(2, nvisual()) + end) + + it('a mapping can restore equal-size visual dot-repeat', function() + -- Keep in sync with the example in runtime/doc/repeat.txt. + n.exec_lua([[ + local vop ---@type string? + vim.api.nvim_create_autocmd('CmdAtom', { + callback = function(ev) + if ev.data.type == 'visual' then + local children = ev.data.atoms + vop = children and children[#children].keys or nil + elseif ev.data.changed then + vop = nil -- the last change is no longer the Visual one + end + end, + }) + vim.keymap.set('n', '.', function() + vim.schedule(function() + vim.api.nvim_feedkeys(vop and ('1v' .. vop) or '.', 'n', false) + end) + end) + ]]) + fn.setline(1, { 'foo bar', 'longword bar' }) + feed('gg0viwd') + eq({ ' bar', 'longword bar' }, get_lines()) + -- Equal-size repeat: a 3-char region at the cursor, NOT that line's word. + feed('j0.') + retry(nil, 1000, function() + eq({ ' bar', 'gword bar' }, get_lines()) + end) + -- A non-visual change falls back to the builtin |.|. + feed('gg0x') + eq({ 'bar', 'gword bar' }, get_lines()) + feed('j0.') + retry(nil, 1000, function() + eq({ 'bar', 'word bar' }, get_lines()) + end) + end) + + it('one event per operation, for each kind of atom', function() + n.clear({ args = { '--clean' }, args_rm = { '--cmd' } }) + --- Feeds `keys`, asserts exactly ONE new event, with the given keys. + local function atom(keys, expected) + local before = #atoms() + feed(keys) + local evs = atoms() + eq({ before + 1, k(expected) }, { #evs, evs[#evs].keys }) + end + local lines = {} + for i = 1, 20 do + lines[i] = 'alpha beta gamma delta epsilon zeta' + end + fn.setline(1, lines) + feed('gg0') + atoms_start() + -- Operators: the atom is the redobuff (count/register included). + -- "x" is normalized ("translated") to the elemental command "dl". + atom('x', 'dl') + atom('3x', '3dl') + atom('dw', 'dw') + atom('"z2dw', '"z2dw') + atom('yy', 'yy') + atom('p', 'p') + atom('J', '2J') + atom('3J', '3J') + atom('r?', '1r?') + atom('~', '~') + atom('guiw', 'guiw') + atom('gUiw', 'gUiw') + atom('>>', '>>') + atom('dfa', 'dfa') + feed('ddk') + atom('P', 'P') + -- Insert sessions: one whole-session atom (entry + text + ). + atom('iXY', '1iXY') + atom('A!', '1A!') + atom('oNEW', '1oNEW') + atom('3iZ', '3iZ') + atom('cwWORD', 'cwWORD') + -- Visual: the full typed keysequence. + atom('viwd', 'viwd') + atom('Vd', 'Vd') + atom('jd', 'jd') + -- Motions. + atom('w', 'w') + atom('3w', '3w') + atom('fb', 'fb') + atom('G', 'G') + atom('$', '$') + feed('gg0') + atom(']]', ']]') + -- Jumps: absolute/shared-state navigation, their own kind. + atom('ma', 'ma') + eq('command', atom_last().type) -- "m" sets state; it does not jump + atom('`a', '`a') + eq('jump', atom_last().type) + atom('', '') + eq('jump', atom_last().type) + -- Non-redoable commands: still emitted, as type "command". + atom('zz', 'zz') + eq('command', atom_last().type) + atom('u', 'u') + atom('', '') + -- "." emits its resolution (like "x" => "dl"). + feed('gg0') + atom('x', 'dl') + atom('.', 'dl') + atom('3.', '3dl') -- "3.": the new count replaces the captured one + -- Payload commands: the interactively-typed cmdline completes the + -- keysequence (not a bare "/" or ":" prefix). + atom('/beta', '/beta') + eq({ type = 'motion', text = 'beta' }, pick(atom_last(), 'type', 'text')) + atom('?alpha', '?alpha') + atom('2/beta', '2/beta') + -- Ex commands: their own atom kind, the cmdline is the "text" payload. + atom(':set tw=42', ':settw=42') + eq({ type = 'ex', text = 'set tw=42' }, pick(atom_last(), 'type', 'text')) + -- A nested cmdline opened by the command's own execution (":normal") + -- does not hijack the payload. + atom(':exe "normal! :echo 1\\r"', ':exe"normal!:echo1r"') + -- A mapping is ALWAYS an atom, even when its commands capture no + -- replayable keys ("]q" = :cnext): the event has empty keys. + fn.setqflist({ { text = 'one' }, { text = 'two' } }) + feed(']q') + eq( + { type = 'mapping', lhs = ']q', keys = '' }, + pick(atom_last(), 'type', 'lhs', 'keys', 'pending') + ) + -- A mapping that ends mid-operation says what it awaits. + command('nnoremap ,D d') + command('nnoremap ,V v') + feed(',D') + eq({ lhs = ',D', pending = 'operator' }, pick(atom_last(), 'lhs', 'pending')) + atom('w', 'dw') -- the supplied motion completes the operation + feed(',V') + eq({ lhs = ',V', pending = 'visual' }, pick(atom_last(), 'lhs', 'pending')) + feed('') + -- An ABORTED mapping emits nothing: an error discards its remaining + -- keys, and the composite with them. + command('nnoremap ,E :NoSuchCmdx') + local count = #atoms() + feed(',E') -- E492 mid-mapping: the trailing "x" never runs + eq(count, #atoms()) + atom('x', 'dl') -- the next command is not folded into the dead composite + eq(nil, atom_last().lhs) -- not from a mapping: omitted + command('nmap ,A ,B') + command('nmap ,B ,A') + feed(',A') -- E223: recursive mapping + atom('x', 'dl') + eq(nil, atom_last().lhs) -- not from a mapping: omitted + -- Macro playback ("@q") emits its commands' atoms (capture-on-replay); + -- "@q" itself is a translation, never an atom. + local total = #atoms() + feed('qqxq') + feed('@q') + eq(total + 4, #atoms()) + eq({ 'qq', 'dl', 'q', 'dl' }, atoms_tail(4)) + -- The macro's atoms fold into one "@x"-labeled composite, like a mapping. + eq('@q', atom_last().lhs) + -- A typed scroll emits its own (emit-only) atom kind. + feed('') + eq(total + 5, #atoms()) + eq({ type = 'scroll', keys = k('') }, pick(atom_last(), 'type', 'keys')) + -- An 'indentexpr' that runs ":normal" opens a nested command-frame mid-operator: "gq" still + -- pushes its own atom, after the edit. + exec([[ + func Indent() + exe "normal! \" + return 0 + endfunc + setlocal indentexpr=Indent() textwidth=20 + ]]) + atom('Vgq', 'Vgq') + eq(true, atom_last().changed) + end) + + it('captures non-edit operators (zfap) and fold/view commands', function() + fn.setline(1, { 'aa', 'aa', '' }) + feed('gg0') + atoms_start() + feed('zfap') + eq({ 'zfap' }, atoms_tail(1)) + eq('operator', atom_last().type) + eq(1, fn.foldclosed(1)) + feed('za') + eq({ 'za' }, atoms_tail(1)) + -- Neither an operator nor a motion: its own kind. + eq('command', atom_last().type) + eq(-1, fn.foldclosed(1)) + end) + + it('a single-command mapping keeps its own type and structure', function() + command('nnoremap ,d dw') + fn.setline(1, { 'one two aa bb cc dd' }) + feed('gg0') + atoms_start() + feed(',d') + eq({ 'two aa bb cc dd' }, get_lines()) + -- The event's structured fields mirror what is encoded in "keys"; a + -- mapping labels its atom with the typed LHS (a single-command + -- mapping: one event, keeping the command's own type and structure). + local evs = atoms() + eq(1, #evs) + -- Inapplicable fields (count/reg/arg/motionforce/text/pending/atoms here) are omitted. + eq({ + type = 'operator', + keys = 'dw', + operator = 'd', + cmd = 'w', + changed = true, + cascade = true, -- the edit is cascadable + lhs = ',d', + }, evs[#evs]) + -- Count and register are captured; one event per occurrence. + feed('"z2dw') + feed('"z2dw') + evs = atoms() + eq({ keys = '"z2dw', count = 2, reg = 'z' }, pick(evs[#evs], 'keys', 'count', 'reg')) + eq(evs[#evs - 1], evs[#evs]) + end) + + it('a mapping with edits and motions folds into one atom', function() + -- Split the line at the cursor, ending at the EOL of the first half. + command('nnoremap gj ik$') + fn.setline(1, { 'aaa bbb' }) + feed('gg04l') + atoms_start() + feed('gj') + eq({ 'aaa ', 'bbb' }, get_lines()) + -- The mapping IS the atom: its commands (the insert session, k, $) + -- accumulate and fold into exactly ONE event, labeled with the typed + -- LHS; the resolved keys remain the (replayable) payload. The + -- mapping is never re-resolved. + local evs = atoms() + eq(1, #evs) + eq( + { type = 'mapping', lhs = 'gj', keys = k('1ik$'), changed = true }, + pick(evs[1], 'type', 'lhs', 'keys', 'changed') + ) + -- The folded commands stay exposed, each with its own structure. + eq( + { + { type = 'insert', keys = k('1i') }, + { type = 'motion', keys = 'k' }, + { type = 'motion', keys = '$' }, + }, + vim.tbl_map(function(c) + return pick(c, 'type', 'keys') + end, evs[1].atoms) + ) + eq({ 'k', false }, { evs[1].atoms[2].cmd, evs[1].atoms[2].changed }) + -- A scroll inside a mapping is NOT a subatom: the composite's keys must + -- stay replayable, so the scroll is elided. + fn.setline(1, { 'l1', 'l2', 'l3', 'l4', 'l5', 'l6' }) + feed('3G') + command('nnoremap gk j$') + feed('gk') + eq(4, fn.line('.')) + eq({ type = 'mapping', lhs = 'gk', keys = 'j$' }, pick(atom_last(), 'type', 'lhs', 'keys')) + -- A recursive mapping (:nmap gJ gj) does not nest: the inner mapping's commands flatten + -- into ONE composite labeled with the typed LHS, with the same resolved keys. + command('nmap gJ gj') + api.nvim_buf_set_lines(0, 0, -1, true, { 'aaa bbb' }) + feed('gg04l') + local before = #atoms() + feed('gJ') + eq({ 'aaa ', 'bbb' }, get_lines()) + evs = atoms() + eq(before + 1, #evs) + eq( + { type = 'mapping', lhs = 'gJ', keys = k('1ik$') }, + pick(evs[#evs], 'type', 'lhs', 'keys') + ) + end) + + it('an insert session atom captures its text', function() + fn.setline(1, { 'aaa' }) + feed('gg0') + atoms_start() + -- No event for the bare "i" command, and none per keystroke: ONE + -- whole-session event at . + feed('i') + eq(0, #atoms()) + feed('X') + eq(0, #atoms()) + feed('Y') + eq(0, #atoms()) + feed('') + local evs = atoms() + eq(1, #evs) + -- `count` mirrors what the keys encode: insert keys always embed the count + -- ("1i…"), so it is 1 even untyped. "dw" omits its count. + eq( + { type = 'insert', count = 1, text = 'XY', keys = k('1iXY') }, + pick(evs[1], 'type', 'count', 'text', 'keys') + ) + -- Counted insert: entry cmd + text + keys, with count and the + -- session's inserted text as fields. + feed('3iZ') + eq({ type = 'insert', count = 3, text = 'Z' }, pick(atom_last(), 'type', 'count', 'text')) + end) + + it("operatorfunc atom includes the getchar()'d payload", function() + n.exec(t_atom.minisurround_vim) + fn.setline(1, { 'alpha beta' }) + feed('gg0') + atoms_start() + feed('ysiw"') + -- The atom is the redobuff plus the getchar()'d payload: a replayed + -- opfunc reads the same wrap char. + eq({ 'g@iw"' }, atoms_tail(1)) + eq({ '"alpha" beta' }, get_lines()) + end) + + it('a ":call" payload mapping publishes its resolved RHS', function() + -- The atom published to CmdAtom carries the RESOLVED RHS (the ":call" line). + n.exec(t_atom.delsurround_vim) + fn.setline(1, { 'a (one)' }) + feed('gg0f(') + atoms_start() + feed('ds)') -- ")" is the getchar()'d payload + eq({ 'a one' }, get_lines()) + local ev = atoms()[#atoms()] + eq('ds', ev.lhs) + t.matches(':call DelSurround%(%)', ev.keys) + end) + + it('"," repeats the last motion atom', function() + -- Keep in sync with the example in runtime/doc/repeat.txt. + n.exec_lua([[ + local last ---@type string? + vim.api.nvim_create_autocmd('CmdAtom', { + pattern = 'motion', + callback = function(ev) + last = ev.data.keys + end, + }) + vim.keymap.set('n', ',', function() + -- CmdAtom delivery is deferred: schedule the replay AFTER any pending + -- event, so `last` is fresh even when "," immediately follows a + -- motion. A scheduled replay is programmatic input: it emits no + -- CmdAtom itself (no feedback loop). + vim.schedule(function() + if last then + vim.api.nvim_feedkeys(last, 'n', false) -- "n": already resolved + end + end) + end) + ]]) + local screen = Screen.new(30, 3) + fn.setline(1, { 'aa bb cc dd ee' }) + feed('gg0') + feed('2w') -- last motion: "2w" (count included) + n.poke_eventloop() -- deliver the deferred CmdAtom before "," + screen:expect([[ + aa bb ^cc dd ee | + {1:~ }| + | + ]]) + feed(',') -- repeated: "2w" again + screen:expect([[ + aa bb cc dd ^ee | + {1:~ }| + | + ]]) + feed('0fb') -- last motion: "fb" (payload char included) + n.poke_eventloop() + screen:expect([[ + aa ^bb cc dd ee | + {1:~ }| + | + ]]) + feed(',') -- repeated "fb": the second "b" + screen:expect([[ + aa b^b cc dd ee | + {1:~ }| + | + ]]) + end) + + it('activates a temporary mapping ("submode"), expired by the next unrelated atom', function() + -- Keep in sync with the example in runtime/doc/repeat.txt. + n.exec_lua([==[ + local active = false + vim.api.nvim_create_autocmd('CmdAtom', { + callback = function(ev) + -- `cmd` is a key-notation name (unlike `keys`, which is raw bytes). + local resize = ev.data.cmd == '+' or ev.data.cmd == '-' + if resize then + -- Activate. Use of "+"/"-" resolves to the same cmd => re-activates. + vim.keymap.set('n', '+', '+') + vim.keymap.set('n', '-', '-') + active = true + elseif active then + vim.keymap.del('n', '+') + vim.keymap.del('n', '-') + active = false + end + end, + }) + -- Also works if + was mapped to something else: + vim.cmd[[nnoremap + +]] + ]==]) + --- Waits for the deferred CmdAtom to (de)activate the temporary mappings. + local function wait_active(active) + eq( + true, + n.exec_lua( + [[ + local active = ... + return vim.wait(1000, function() + return (vim.fn.maparg('+', 'n') ~= '') == active + end) + ]], + active + ) + ) + end + fn.setline(1, { 'one', 'two' }) + command('split') + local height = fn.winheight(0) + feed('+') + wait_active(true) -- the deferred CmdAtom activated the mappings + eq(height + 1, fn.winheight(0)) + feed('+') -- temporary mapping: resizes without the CTRL-W prefix + n.poke_eventloop() + eq(height + 2, fn.winheight(0)) + feed('-') -- its own use emits the same resolved keys: stays active + n.poke_eventloop() + eq(height + 1, fn.winheight(0)) + feed('j') -- any unrelated atom expires the mappings + wait_active(false) + feed('-') -- back to the builtin: a motion (up one line), not a resize + n.poke_eventloop() + eq(height + 1, fn.winheight(0)) + eq(1, fn.line('.')) + -- A mapping whose atom RESOLVES to a resize also activates the submode. + feed('\\+') + n.poke_eventloop() + eq(height + 2, fn.winheight(0)) + wait_active(true) + end) +end) diff --git a/test/functional/legacy/094_visual_mode_operators_spec.lua b/test/functional/legacy/094_visual_mode_operators_spec.lua index 425783d5f7..74f3f0cee9 100644 --- a/test/functional/legacy/094_visual_mode_operators_spec.lua +++ b/test/functional/legacy/094_visual_mode_operators_spec.lua @@ -55,67 +55,63 @@ describe('Visual mode and operator', function() end) it('simple change in Visual mode', function() - insert([[ - apple banana cherry + -- Nvim: visual-repeat re-executes the visual operation, not a fixed-size reselect. + -- Exercise characterwise Visual mode plus operator, with count and repeat. + insert('apple banana cherry') + feed_command('/^apple') + feed('lvld.l3vd.') + expect('a ') + + -- Same in linewise Visual mode. + feed_command('%delete _') + insert([[ line 1 line 1 line 2 line 2 line 3 line 3 line 4 line 4 line 5 line 5 - line 6 line 6 + line 6 line 6]]) + feed_command('/^line 1') + feed('Vcnewlinej.j2Vd.') + expect([[ + newline + newline]]) + -- Same in blockwise Visual mode. + feed_command('%delete _') + insert([[ xxxxxxxxxxxxx xxxxxxxxxxxxx xxxxxxxxxxxxx xxxxxxxxxxxxx]]) - - -- Exercise characterwise Visual mode plus operator, with count and repeat. - feed_command('/^apple') - feed('lvld.l3vd.') - - -- Same in linewise Visual mode. - feed_command('/^line 1') - feed('Vcnewlinej.j2Vd.') - - -- Same in blockwise Visual mode. feed_command('/^xxxx') feed('jlc l.l2c----l.') - - -- Assert buffer contents. expect([[ - a y - - newline - newline - - --------x - --------x - xxxx--------x - xxxx--------x]]) + -------- + -------- + xxxx-------- + xxxx--------]]) end) it('Visual mode mapping', function() - insert([[ - KiwiRaspberryDateWatermelonPeach - JambuRambutanBananaTangerineMango]]) + -- Nvim: visual-repeat re-executes the visual operation, not a fixed-size reselect. -- Set up Visual mode mappings. feed_command('vnoremap W /\\u/s-1') feed_command('vnoremap iW :call SelectInCaps()') -- Do a simple change using the simple vmap, also with count and repeat. + feed_command([[call setline(1, 'KiwiRaspberryDateWatermelonPeach')]]) feed_command('/^Kiwi') feed('vWcNol.fD2vd.') + expect('NoNoberry') -- Same, using the vmap that maps to an Ex command. + feed_command([[call setline(1, 'JambuRambutanBananaTangerineMango')]]) feed_command('/^Jambu') feed('llviWc-l.l2vdl.') - - -- Assert buffer contents. - expect([[ - NoNoberryach - --ago]]) + expect('--a') end) it('Operator-pending mode mapping', function() diff --git a/test/functional/legacy/listlbr_spec.lua b/test/functional/legacy/listlbr_spec.lua index d085dcaa4a..33fb07f4df 100644 --- a/test/functional/legacy/listlbr_spec.lua +++ b/test/functional/legacy/listlbr_spec.lua @@ -185,8 +185,8 @@ describe('listlbr', function() 1111-2222-1111-11-1111-2222-1111 Test 9: using redo after block visual mode - AaA - AaA + AAA + AAA A Test 10: using normal commands after block-visual diff --git a/test/functional/legacy/mapping_spec.lua b/test/functional/legacy/mapping_spec.lua index 86ea1352a1..7b6667fcf4 100644 --- a/test/functional/legacy/mapping_spec.lua +++ b/test/functional/legacy/mapping_spec.lua @@ -119,6 +119,8 @@ describe('mapping', function() command('imapclear') command('set whichwrap=<,>,[,]') feed('G3o2k') + -- Nvim: "." re-executes the whole insert session, including the (wrapping) + -- CTRL-G U , instead of only the text typed after it. command( [[:exe ":norm! iTest3: text with a (parenthesis here\U\new line here\\\."]] ) @@ -131,8 +133,8 @@ describe('mapping', function() Test2: text wit a (here some more text [und undo]) - new line here Test3: text with a (parenthesis here + new line hereTest3: text with a (parenthesis here new line here ]]) end) diff --git a/test/functional/lua/comment_spec.lua b/test/functional/lua/comment_spec.lua index dbf7864e93..95debcfc53 100644 --- a/test/functional/lua/comment_spec.lua +++ b/test/functional/lua/comment_spec.lua @@ -426,14 +426,14 @@ describe('commenting', function() eq(get_lines(), { '# aa', '# aa', '# aa', '', ' aa', ' aa', 'aa' }) eq(get_cursor(), { 1, 0 }) - -- Dot-repeat after first application in Visual mode should apply to the same - -- relative region + -- Dot-repeat after first application in Visual mode applies to the paragraph at cursor (not + -- a fixed-size region). feed('.') eq(get_lines(), example_lines) set_cursor(3, 0) feed('.') - eq(get_lines(), { 'aa', ' aa', ' # aa', ' #', ' # aa', ' aa', 'aa' }) + eq(get_lines(), { '# aa', '# aa', '# aa', '', ' aa', ' aa', 'aa' }) end) it("respects 'commentstring'", function() diff --git a/test/old/testdir/test_increment.vim b/test/old/testdir/test_increment.vim index 5c61f25103..682c6eb346 100644 --- a/test/old/testdir/test_increment.vim +++ b/test/old/testdir/test_increment.vim @@ -354,7 +354,9 @@ endfunc " 2 2 func Test_visual_increment_14() call setline(1, repeat(["1 1"], 2)) - exec "norm! G\k\w." + " Nvim: "." re-executes the keysequence, so the replayed "k" needs a line + " above: repeat from the last line, like the original. + exec "norm! G\k\Gw." call assert_equal(["2 2", "2 2"], getline(1, '$')) call assert_equal([0, 1, 3, 0], getpos('.')) endfunc @@ -441,7 +443,9 @@ endfunc func Test_visual_increment_18() call setline(1, repeat(["0"], 4)) exec "norm! GV3kg\" - exec "norm! .." + " Nvim: "." re-executes "V3kg": replay from the last line, like the + " original. + exec "norm! G.G." call assert_equal(["3", "6", "9", "12"], getline(1, '$')) call assert_equal([0, 1, 1, 0], getpos('.')) endfunc @@ -684,9 +688,12 @@ endfunc " Tab code, spaces and character-visual increment and redo func Test_visual_increment_35() call setline(1, ["\123", " 123", "\123", "\123"]) + " Nvim: "." re-executes "vjf3"; the replayed "f3" fails (the numbers + " are now "124"), aborting the replay and (in :norm) flushing the rest. exec "norm! ggvjf3\..." - call assert_equal(["\127", " 127", "\123", "\123"], getline(1, '$')) - call assert_equal([0, 1, 2, 0], getpos('.')) + exe "norm! \" + call assert_equal(["\124", " 124", "\123", "\123"], getline(1, '$')) + call assert_equal([0, 2, 9, 0], getpos('.')) endfunc " Tab code, spaces and blockwise-visual increment and redo @@ -696,7 +703,9 @@ func Test_visual_increment_36() call assert_equal([" 123", "\556789"], getline(1, '$')) call assert_equal([0, 1, 1, 0], getpos('.')) - exec "norm! ..." + " Nvim: "." re-executes "kl": replay from the last line, like the + " original. + exec "norm! G0.G0.G0." call assert_equal([" 123", "\856789"], getline(1, '$')) call assert_equal([0, 1, 1, 0], getpos('.')) endfunc diff --git a/test/old/testdir/test_listlbr.vim b/test/old/testdir/test_listlbr.vim index 23340cad0e..a4659ce2ec 100644 --- a/test/old/testdir/test_listlbr.vim +++ b/test/old/testdir/test_listlbr.vim @@ -268,11 +268,13 @@ endfunc func Test_undo_after_block_visual() call s:test_windows() call setline(1, ["aaa", "aaa", "a"]) + " Nvim: "." re-executes the captured keysequence ("2jg~"), not an + " equal-size reselect (|visual-repeat|). exe "norm! gg\2j~e." let lines = s:screen_lines([1, 3], winwidth(0)) let expect = [ -\ "AaA ", -\ "AaA ", +\ "AAA ", +\ "AAA ", \ "A ", \ ] call s:compare_lines(expect, lines) diff --git a/test/old/testdir/test_mapping.vim b/test/old/testdir/test_mapping.vim index 5505d0f4d4..291d817220 100644 --- a/test/old/testdir/test_mapping.vim +++ b/test/old/testdir/test_mapping.vim @@ -279,9 +279,11 @@ endfunc func Test_break_undo() set whichwrap=<,>,[,] call feedkeys("G4o2k", "xt") + " Nvim: "." re-executes the whole insert session, including the (wrapping) + " CTRL-G U , instead of only the text typed after it. exe ":norm! iTest3: text with a (parenthesis here\U\new line here\\\." - call assert_equal('new line here', getline(line('$') - 3)) - call assert_equal('Test3: text with a (parenthesis here', getline(line('$') - 2)) + call assert_equal('Test3: text with a (parenthesis here', getline(line('$') - 3)) + call assert_equal('new line hereTest3: text with a (parenthesis here', getline(line('$') - 2)) call assert_equal('new line here', getline(line('$') - 1)) set nomodified endfunc diff --git a/test/old/testdir/test_normal.vim b/test/old/testdir/test_normal.vim index 868e80e3f8..0d5d535424 100644 --- a/test/old/testdir/test_normal.vim +++ b/test/old/testdir/test_normal.vim @@ -535,9 +535,13 @@ func Test_normal09c_operatorfunc() new call setline(1, ['first', 'first', 'third', 'third', 'second']) normal! 1GVjg@ - normal! 5G. normal! 3G. - call assert_equal(['_____', '_____', '_____', '_____', '______'], getline(1, '$')) + " Nvim: "." re-executes the captured keysequence ("Vjg@"), not an equal-size + " reselect. At the last line the replayed "j" fails, aborting the replay: + " nothing is applied (and the pending Visual mode is left active). + normal! 5G. + exe "normal! \" + call assert_equal(['_____', '_____', '_____', '_____', 'second'], getline(1, '$')) bwipe! set operatorfunc= endfunc diff --git a/test/old/testdir/test_visual.vim b/test/old/testdir/test_visual.vim index f08b03ab25..6c7ae13c1d 100644 --- a/test/old/testdir/test_visual.vim +++ b/test/old/testdir/test_visual.vim @@ -505,8 +505,10 @@ func Test_visual_mode_op() call setline(1, 'apple banana cherry') call cursor(1, 1) + " Nvim: "." re-executes the captured keysequence (|visual-repeat|): the final + " "." replays "3vd", multiplying the previous (grown) selection again. normal lvld.l3vd. - call assert_equal('a y', getline(1)) + call assert_equal('a ', getline(1)) call setline(1, ['line 1 line 1', 'line 2 line 2', 'line 3 line 3', \ 'line 4 line 4', 'line 5 line 5', 'line 6 line 6']) @@ -518,10 +520,10 @@ func Test_visual_mode_op() call setline(1, ['xxxxxxxxxxxxx', 'xxxxxxxxxxxxx', 'xxxxxxxxxxxxx', \ 'xxxxxxxxxxxxx']) exe "normal \jlc \l.l2\c----\l." - call assert_equal([' --------x', - \ ' --------x', - \ 'xxxx--------x', - \ 'xxxx--------x'], getline(1, '$')) + call assert_equal([' --------', + \ ' --------', + \ 'xxxx--------', + \ 'xxxx--------'], getline(1, '$')) bwipe! endfunc @@ -545,15 +547,18 @@ func Test_visual_mode_maps() vnoremap W /\u/s-1 vnoremap iW :call SelectInCaps() + " Nvim: a selection extended by a search or Ex motion is not replayable: + " "." falls back to an equal-size reselect ("1v"), like Vim. But "." after + " "2vd" re-executes "2vd", multiplying the previous (grown) area again. call setline(1, 'KiwiRaspberryDateWatermelonPeach') call cursor(1, 1) exe "normal vWcNo\l.fD2vd." - call assert_equal('NoNoberryach', getline(1)) + call assert_equal('NoNoberry', getline(1)) call setline(1, 'JambuRambutanBananaTangerineMango') call cursor(1, 1) exe "normal llviWc-\l.l2vdl." - call assert_equal('--ago', getline(1)) + call assert_equal('--a', getline(1)) vunmap W vunmap iW