diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 8138ff502..30df7dae4 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -9,6 +9,9 @@ Scripting improvements Interactive improvements ------------------------ +- Improves vi-mode to make its word movements more vim-compatible. + - The behavior of ``{,d}{w,W}``, ``{,d}{,g}{e,E}`` bindings in vi-mode is now more compatible with vim, except that the underscore is not a keyword (which can be archived by setting ``set iskeyword-=_`` in vim). + - Add commands ``{forward,kill}-{word,bigword}-vi``, ``{forward,backward,kill,backward-kill}-{word,bigword}-end`` and ``kill-{a,inner}-{word,bigword}`` corresponding to above-mentioned bindings. - Vi mode key bindings now support counts for movement and deletion commands (e.g. `d3w` or `3l`), via a new operator mode (:issue:`2192`). New or improved bindings diff --git a/doc_src/cmds/bind.rst b/doc_src/cmds/bind.rst index 77dbf5cf1..1e8d36938 100644 --- a/doc_src/cmds/bind.rst +++ b/doc_src/cmds/bind.rst @@ -131,18 +131,12 @@ The following special input functions are available: move one character to the left, but do not trigger any non-movement-related operations. If the cursor is at the start of the commandline, does nothing. Does not change the selected item in the completion pager UI when shown. -``backward-bigword`` - move one whitespace-delimited word to the left - ``backward-token`` move one argument to the left ``backward-delete-char`` deletes one character of input to the left of the cursor -``backward-kill-bigword`` - move the whitespace-delimited word to the left of the cursor to the killring - ``backward-kill-token`` move the argument to the left of the cursor to the killring @@ -155,13 +149,31 @@ The following special input functions are available: move one path component to the left of the cursor to the killring. A path component is everything likely to belong to a path component, i.e. not any of the following: `/={,}'\":@ |;<>&`, plus newlines and tabs. ``backward-kill-word`` - move the word to the left of the cursor to the killring. The "word" here is everything up to punctuation or whitespace. + move the word to the left of the cursor to the killring, until the start of the current word (like vim's ``db``) + +``backward-kill-bigword`` + move the whitespace-delimited word to the left of the cursor to the killring, until the start of the current word (like vim's ``dB``) + +``backward-kill-word-end`` + move from the cursor to the end of the previous word to the killring (like vim's ``dge``) + +``backward-kill-bigword-end`` + move from the cursor to the end of the previous whitespace-delimited word to the killring (like vim's ``dgE``) ``backward-path-component`` - move one :ref:`path component ` to the left. + move one :ref:`path component ` to the left ``backward-word`` - move one word to the left + move one word to the left, stopping at the start of the previous word (like vim's ``b``, or Emacs' ``M-b`` but differs slightly in word division rules) + +``backward-bigword`` + move one whitespace-delimited word to the left, stopping at the start of the previous word (like vim's ``B``) + +``backward-word-end`` + move to the end of the previous word (like vim's ``ge``) + +``backward-bigword-end`` + move to the end of the previous whitespace-delimited word (like vim's ``gE``) ``beginning-of-buffer`` moves to the beginning of the buffer, i.e. the start of the first line @@ -236,9 +248,6 @@ The following special input functions are available: ``exit`` exit the shell -``forward-bigword`` - move one whitespace-delimited word to the right - ``forward-token`` move one argument to the right @@ -257,10 +266,29 @@ The following special input functions are available: ``forward-single-char`` move one character to the right; or if at the end of the commandline, accept a single char from the current autosuggestion. +.. _cmd-bind-forward-word: + ``forward-word`` - move one word to the right; or if at the end of the commandline, accept one word + move one word to the right, stopping after the end of the current word; or if at the end of the commandline, accept one word from the current autosuggestion. +``forward-word-vi`` + like :ref:`forward-word `, but stops at the start of the next word (like vim's ``w``) + +``forward-word-end`` + like :ref:`forward-word `, but stops at the end of the next word (like vim's ``e``) + +.. _cmd-bind-forward-bigword: + +``forward-bigword`` + move one whitespace-delimited word to the right, stopping after the end of the current word; or if at the end of the commandline, accept one word from the current autosuggestion. + +``forward-bigword-vi`` + like :ref:`forward-bigword `, but stops at the start of the next word (like vim's ``W``) + +``forward-bigword-end`` + like :ref:`forward-bigword `, but stops at the end of the next word (like vim's ``E``) + ``history-pager`` invoke the searchable pager on history (incremental search); or if the history pager is already active, search further backwards in time. @@ -312,9 +340,6 @@ The following special input functions are available: The input function is useful to emulate ``ib`` vi text object. The following brackets are considered: ``([{}])`` -``kill-bigword`` - move the next whitespace-delimited word to the killring - ``kill-token`` move the next argument to the killring @@ -334,7 +359,34 @@ The following special input functions are available: move the line (without the following newline) to the killring ``kill-word`` - move the next word to the killring + move the next word to the killring, stopping after the end of the killed word + +``kill-word-vi`` + move the next word to the killring, stopping at the start of the next word (like vim's ``dw``) + +``kill-bigword`` + move the next whitespace-delimited word to the killring, stopping after the end of the current word + +``kill-bigword-vi`` + move the next whitespace-delimited word to the killring, stopping at the start of the next word (like vim's ``dW``) + +``kill-bigword-end`` + move from the cursor to the end of the current whitespace-delimited word to the killring (like vim's ``dE``) + +``kill-word-end`` + move from the cursor to the end of the current word to the killring (like vim's ``de``) + +``kill-inner-word`` + delete the word under the cursor (like vim's ``diw``) + +``kill-inner-bigword`` + delete the whitespace-delimited word under the cursor (like vim's ``diW``) + +``kill-a-word`` + delete the word under the cursor plus surrounding whitespace (like vim's ``daw``) + +``kill-a-bigword`` + delete the whitespace-delimited word under the cursor plus surrounding whitespace (like vim's ``daW``) ``nextd-or-forward-word`` if the commandline is empty, then move forward in the directory history, otherwise move one word to the right; diff --git a/doc_src/cmds/fish_vi_key_bindings.rst b/doc_src/cmds/fish_vi_key_bindings.rst index d9005ca4f..743c8599d 100644 --- a/doc_src/cmds/fish_vi_key_bindings.rst +++ b/doc_src/cmds/fish_vi_key_bindings.rst @@ -24,6 +24,19 @@ The following parameters are available: Further information on how to use :ref:`vi mode `. +Differences from Vim +-------------------- + +Fish's vi mode aims to be familiar to vim users, but there are some differences: + +**Word character handling** + In vim, underscore (``_``) is treated as a keyword character by default, so word motions like ``w``, ``b``, and ``e`` treat ``foo_bar`` as a single word. In fish, underscore is treated as punctuation, so word motions stop at underscores. For example, pressing ``w`` on ``foo_bar`` in fish stops at the ``_``, while in vim it would jump past the entire identifier. + +**The** ``cw`` **command** + In vim, ``cw`` has special behavior: when the cursor is on a non-space character, it behaves like ``ce`` (change to end of word), but when the cursor is on a space, it behaves like ``dwi`` (delete word then insert). + + In fish, ``cw`` always behaves like ``dwi`` - it deletes to the start of the next word (including trailing whitespace), then enters insert mode. To get vim's ``cw`` behavior in fish, use ``ce`` instead. + Examples -------- diff --git a/share/functions/fish_vi_key_bindings.fish b/share/functions/fish_vi_key_bindings.fish index d1749ca3f..89183d34a 100644 --- a/share/functions/fish_vi_key_bindings.fish +++ b/share/functions/fish_vi_key_bindings.fish @@ -14,7 +14,6 @@ function fish_vi_run_count --description 'Run a command $__fish_vi_count times' set -l count (__fish_vi_consume_count __fish_vi_count) for i in (seq $count) - # Check if the first argument is a defined shell function (like fish_vi_move_end_word) if functions -q -- $argv[1] $argv else @@ -72,51 +71,76 @@ function fish_vi_exec_motion commandline -f kill-whole-line yank end case swap-case - for i in (seq $total) - commandline -f beginning-of-line begin-selection down-line togglecase-selection end-selection + # Not implemented yet + return + end + else + set -l use_selection true + set -l swap_case_hack + switch $motion + case forward-word-vi forward-bigword-vi + if test $__fish_vi_operator = swap-case + set swap_case_hack (string replace -r -- '^forward-((?:big)?word)-vi$' '$1' $motion) + else + set use_selection false + set motion (string replace -- forward kill $motion) end end - commandline -f repaint-mode - else + if $use_selection + commandline -f begin-selection + else + commandline -f begin-undo-group + end switch $__fish_vi_operator case delete - commandline -f begin-selection for i in (seq $total) commandline -f $motion end - commandline -f kill-selection end-selection repaint-mode + if $use_selection + commandline -f kill-selection + end case change - commandline -f begin-selection for i in (seq $total) commandline -f $motion end - commandline -f kill-selection end-selection repaint-mode - # Switch to insert mode + if $use_selection + commandline -f kill-selection + end set fish_bind_mode insert case yank - commandline -f begin-selection for i in (seq $total) commandline -f $motion end - commandline -f kill-selection yank end-selection repaint-mode + if $use_selection + commandline -f kill-selection + end + commandline -f yank case swap-case - commandline -f begin-selection for i in (seq $total) commandline -f $motion end - commandline -f togglecase-selection end-selection repaint-mode + if set -q swap_case_hack[1] + set -l word $swap_case_hack + commandline -f \ + backward-$word \ + forward-$word-end \ + togglecase-selection \ + backward-$word \ + forward-$word-vi + else + commandline -f togglecase-selection + end + end + if $use_selection + commandline -f end-selection + else + commandline -f end-undo-group end end + commandline -f repaint-mode set -g __fish_vi_operator end -function fish_vi_move_end_word - set -l cmd $argv[1] - set fish_cursor_end_mode exclusive - commandline -f forward-single-char $cmd backward-char - set fish_cursor_end_mode inclusive -end - # TODO: Currently we do not support hexadecimal and octal values. function fish_vi_inc_dec --description 'increment or decrement the number below the cursor' # The cursor is zero based, but all string functions assume 1 to be @@ -276,14 +300,14 @@ function fish_vi_key_bindings --description 'vi-like key bindings for fish' bind -s --preset -M default b 'fish_vi_run_count backward-word' bind -s --preset -M default B 'fish_vi_run_count backward-bigword' - bind -s --preset -M default g,e 'fish_vi_run_count backward-word' - bind -s --preset -M default g,E 'fish_vi_run_count backward-bigword' + bind -s --preset -M default g,e 'fish_vi_run_count backward-word-end' + bind -s --preset -M default g,E 'fish_vi_run_count backward-bigword-end' - bind -s --preset -M default w 'fish_vi_run_count forward-word forward-single-char' - bind -s --preset -M default W 'fish_vi_run_count forward-bigword forward-single-char' + bind -s --preset -M default w 'fish_vi_run_count forward-word-vi' + bind -s --preset -M default W 'fish_vi_run_count forward-bigword-vi' - bind -s --preset -M default e 'fish_vi_run_count fish_vi_move_end_word forward-word' - bind -s --preset -M default E 'fish_vi_run_count fish_vi_move_end_word forward-bigword' + bind -s --preset -M default e 'fish_vi_run_count forward-word-end' + bind -s --preset -M default E 'fish_vi_run_count forward-bigword-end' bind -s --preset -M default x 'fish_vi_run_count delete-char' bind -s --preset -M default X 'fish_vi_run_count backward-delete-char' @@ -317,6 +341,9 @@ function fish_vi_key_bindings --description 'vi-like key bindings for fish' bind -s --preset ] history-token-search-forward bind -s --preset -m insert / history-pager repaint-mode + __fish_per_os_bind --preset $argv ctrl-right forward-token forward-word-vi + # ctrl-left is same as emacs mode + bind -s --preset -M insert ctrl-n accept-autosuggestion # Vi/Vim doesn't support these keys in insert mode but that seems silly so we do so anyway. @@ -355,12 +382,12 @@ function fish_vi_key_bindings --description 'vi-like key bindings for fish' bind -s --preset -M operator j 'fish_vi_exec_motion down-line' bind -s --preset -M operator b 'fish_vi_exec_motion backward-word' bind -s --preset -M operator B 'fish_vi_exec_motion backward-bigword' - bind -s --preset -M operator g,e 'fish_vi_exec_motion backward-word' - bind -s --preset -M operator g,E 'fish_vi_exec_motion backward-bigword' - bind -s --preset -M operator w 'fish_vi_exec_motion forward-word' - bind -s --preset -M operator W 'fish_vi_exec_motion forward-bigword' - bind -s --preset -M operator e 'fish_vi_exec_motion forward-single-char forward-word backward-char' - bind -s --preset -M operator E 'fish_vi_exec_motion forward-single-char forward-bigword backward-char' + bind -s --preset -M operator g,e 'fish_vi_exec_motion backward-word-end' + bind -s --preset -M operator g,E 'fish_vi_exec_motion backward-bigword-end' + bind -s --preset -M operator w 'fish_vi_exec_motion forward-word-vi' + bind -s --preset -M operator W 'fish_vi_exec_motion forward-bigword-vi' + bind -s --preset -M operator e 'fish_vi_exec_motion forward-word-end' + bind -s --preset -M operator E 'fish_vi_exec_motion forward-bigword-end' bind -s --preset -M operator 0 'fish_vi_exec_motion beginning-of-line' bind -s --preset -M operator \^ 'fish_vi_exec_motion beginning-of-line' @@ -383,10 +410,10 @@ function fish_vi_key_bindings --description 'vi-like key bindings for fish' bind -s --preset d,\^ backward-kill-line bind -s --preset d,0 backward-kill-line - bind -s --preset d,i,w forward-single-char forward-single-char backward-word kill-word - bind -s --preset d,i,W forward-single-char forward-single-char backward-bigword kill-bigword - bind -s --preset d,a,w forward-single-char forward-single-char backward-word kill-word - bind -s --preset d,a,W forward-single-char forward-single-char backward-bigword kill-bigword + bind -s --preset d,i,w kill-inner-word + bind -s --preset d,i,W kill-inner-bigword + bind -s --preset d,a,w kill-a-word + bind -s --preset d,a,W kill-a-bigword bind -s --preset d,i,b jump-till-matching-bracket and jump-till-matching-bracket and begin-selection jump-till-matching-bracket kill-selection end-selection bind -s --preset d,a,b jump-to-matching-bracket and jump-to-matching-bracket and begin-selection jump-to-matching-bracket kill-selection end-selection bind -s --preset d,i backward-jump-till and repeat-jump-reverse and begin-selection repeat-jump kill-selection end-selection @@ -401,10 +428,10 @@ function fish_vi_key_bindings --description 'vi-like key bindings for fish' bind -s --preset -m insert c,\^ backward-kill-line repaint-mode bind -s --preset -m insert c,0 backward-kill-line repaint-mode - bind -s --preset -m insert c,i,w forward-single-char forward-single-char backward-word kill-word repaint-mode - bind -s --preset -m insert c,i,W forward-single-char forward-single-char backward-bigword kill-bigword repaint-mode - bind -s --preset -m insert c,a,w forward-single-char forward-single-char backward-word kill-word repaint-mode - bind -s --preset -m insert c,a,W forward-single-char forward-single-char backward-bigword kill-bigword repaint-mode + bind -s --preset -m insert c,i,w kill-inner-word repaint-mode + bind -s --preset -m insert c,i,W kill-inner-bigword repaint-mode + bind -s --preset -m insert c,a,w kill-a-word repaint-mode + bind -s --preset -m insert c,a,W kill-a-bigword repaint-mode bind -s --preset -m insert c,i,b jump-till-matching-bracket and jump-till-matching-bracket and begin-selection jump-till-matching-bracket kill-selection end-selection bind -s --preset -m insert c,a,b jump-to-matching-bracket and jump-to-matching-bracket and begin-selection jump-to-matching-bracket kill-selection end-selection bind -s --preset -m insert c,i backward-jump-till and repeat-jump-reverse and begin-selection repeat-jump kill-selection end-selection repaint-mode @@ -425,11 +452,10 @@ function fish_vi_key_bindings --description 'vi-like key bindings for fish' bind -s --preset y,\$ kill-line yank bind -s --preset y,\^ backward-kill-line yank bind -s --preset y,0 backward-kill-line yank - - bind -s --preset y,i,w forward-single-char forward-single-char backward-word kill-word yank - bind -s --preset y,i,W forward-single-char forward-single-char backward-bigword kill-bigword yank - bind -s --preset y,a,w forward-single-char forward-single-char backward-word kill-word yank - bind -s --preset y,a,W forward-single-char forward-single-char backward-bigword kill-bigword yank + bind -s --preset y,i,w kill-inner-word yank + bind -s --preset y,i,W kill-inner-bigword yank + bind -s --preset y,a,w kill-a-word yank + bind -s --preset y,a,W kill-a-bigword yank bind -s --preset y,i,b jump-till-matching-bracket and jump-till-matching-bracket and begin-selection jump-till-matching-bracket kill-selection yank end-selection bind -s --preset y,a,b jump-to-matching-bracket and jump-to-matching-bracket and begin-selection jump-to-matching-bracket kill-selection yank end-selection bind -s --preset y,i backward-jump-till and repeat-jump-reverse and begin-selection repeat-jump kill-selection yank end-selection @@ -500,12 +526,12 @@ function fish_vi_key_bindings --description 'vi-like key bindings for fish' bind -s --preset -M visual b backward-word bind -s --preset -M visual B backward-bigword - bind -s --preset -M visual g,e backward-word - bind -s --preset -M visual g,E backward-bigword - bind -s --preset -M visual w forward-word - bind -s --preset -M visual W forward-bigword - bind -s --preset -M visual e 'set fish_cursor_end_mode exclusive' forward-single-char forward-word backward-char 'set fish_cursor_end_mode inclusive' - bind -s --preset -M visual E 'set fish_cursor_end_mode exclusive' forward-single-char forward-bigword backward-char 'set fish_cursor_end_mode inclusive' + bind -s --preset -M visual g,e backward-word-end + bind -s --preset -M visual g,E backward-bigword-end + bind -s --preset -M visual w forward-word-vi + bind -s --preset -M visual W forward-bigword-vi + bind -s --preset -M visual e forward-word-end + bind -s --preset -M visual E forward-bigword-end bind -s --preset -M visual o swap-selection-start-stop repaint-mode bind -s --preset -M visual % jump-to-matching-bracket diff --git a/src/input.rs b/src/input.rs index 5b4981104..51d194daf 100644 --- a/src/input.rs +++ b/src/input.rs @@ -107,19 +107,23 @@ const fn make_md(name: &'static wstr, code: ReadlineCmd) -> InputFunctionMetadat make_md(L!("accept-autosuggestion"), ReadlineCmd::AcceptAutosuggestion), make_md(L!("and"), ReadlineCmd::FuncAnd), make_md(L!("backward-bigword"), ReadlineCmd::BackwardBigword), + make_md(L!("backward-bigword-end"), ReadlineCmd::BackwardBigwordEnd), make_md(L!("backward-char"), ReadlineCmd::BackwardChar), make_md(L!("backward-char-passive"), ReadlineCmd::BackwardCharPassive), make_md(L!("backward-delete-char"), ReadlineCmd::BackwardDeleteChar), make_md(L!("backward-jump"), ReadlineCmd::BackwardJump), make_md(L!("backward-jump-till"), ReadlineCmd::BackwardJumpTill), make_md(L!("backward-kill-bigword"), ReadlineCmd::BackwardKillBigword), + make_md(L!("backward-kill-bigword-end"), ReadlineCmd::BackwardKillBigwordEnd), make_md(L!("backward-kill-line"), ReadlineCmd::BackwardKillLine), make_md(L!("backward-kill-path-component"), ReadlineCmd::BackwardKillPathComponent), make_md(L!("backward-kill-token"), ReadlineCmd::BackwardKillToken), make_md(L!("backward-kill-word"), ReadlineCmd::BackwardKillWord), + make_md(L!("backward-kill-word-end"), ReadlineCmd::BackwardKillWordEnd), make_md(L!("backward-path-component"), ReadlineCmd::BackwardPathComponent), make_md(L!("backward-token"), ReadlineCmd::BackwardToken), make_md(L!("backward-word"), ReadlineCmd::BackwardWord), + make_md(L!("backward-word-end"), ReadlineCmd::BackwardWordEnd), make_md(L!("begin-selection"), ReadlineCmd::BeginSelection), make_md(L!("begin-undo-group"), ReadlineCmd::BeginUndoGroup), make_md(L!("beginning-of-buffer"), ReadlineCmd::BeginningOfBuffer), @@ -146,7 +150,9 @@ const fn make_md(name: &'static wstr, code: ReadlineCmd) -> InputFunctionMetadat make_md(L!("exit"), ReadlineCmd::Exit), make_md(L!("expand-abbr"), ReadlineCmd::ExpandAbbr), make_md(L!("force-repaint"), ReadlineCmd::ForceRepaint), - make_md(L!("forward-bigword"), ReadlineCmd::ForwardBigword), + make_md(L!("forward-bigword"), ReadlineCmd::ForwardBigwordEmacs), + make_md(L!("forward-bigword-end"), ReadlineCmd::ForwardBigwordEnd), + make_md(L!("forward-bigword-vi"), ReadlineCmd::ForwardBigwordVi), make_md(L!("forward-char"), ReadlineCmd::ForwardChar), make_md(L!("forward-char-passive"), ReadlineCmd::ForwardCharPassive), make_md(L!("forward-jump"), ReadlineCmd::ForwardJump), @@ -154,7 +160,9 @@ const fn make_md(name: &'static wstr, code: ReadlineCmd) -> InputFunctionMetadat make_md(L!("forward-path-component"), ReadlineCmd::ForwardPathComponent), make_md(L!("forward-single-char"), ReadlineCmd::ForwardSingleChar), make_md(L!("forward-token"), ReadlineCmd::ForwardToken), - make_md(L!("forward-word"), ReadlineCmd::ForwardWord), + make_md(L!("forward-word"), ReadlineCmd::ForwardWordEmacs), + make_md(L!("forward-word-end"), ReadlineCmd::ForwardWordEnd), + make_md(L!("forward-word-vi"), ReadlineCmd::ForwardWordVi), make_md(L!("history-delete"), ReadlineCmd::HistoryDelete), make_md(L!("history-last-token-search-backward"), ReadlineCmd::HistoryLastTokenSearchBackward), make_md(L!("history-last-token-search-forward"), ReadlineCmd::HistoryLastTokenSearchForward), @@ -171,15 +179,23 @@ const fn make_md(name: &'static wstr, code: ReadlineCmd) -> InputFunctionMetadat make_md(L!("insert-line-under"), ReadlineCmd::InsertLineUnder), make_md(L!("jump-till-matching-bracket"), ReadlineCmd::JumpTillMatchingBracket), make_md(L!("jump-to-matching-bracket"), ReadlineCmd::JumpToMatchingBracket), - make_md(L!("kill-bigword"), ReadlineCmd::KillBigword), + make_md(L!("kill-a-bigword"), ReadlineCmd::KillABigWord), + make_md(L!("kill-a-word"), ReadlineCmd::KillAWord), + make_md(L!("kill-bigword"), ReadlineCmd::KillBigwordEmacs), + make_md(L!("kill-bigword-end"), ReadlineCmd::KillBigwordEnd), + make_md(L!("kill-bigword-vi"), ReadlineCmd::KillBigwordVi), + make_md(L!("kill-inner-bigword"), ReadlineCmd::KillInnerBigWord), make_md(L!("kill-inner-line"), ReadlineCmd::KillInnerLine), + make_md(L!("kill-inner-word"), ReadlineCmd::KillInnerWord), make_md(L!("kill-line"), ReadlineCmd::KillLine), make_md(L!("kill-path-component"), ReadlineCmd::KillPathComponent), make_md(L!("kill-selection"), ReadlineCmd::KillSelection), make_md(L!("kill-token"), ReadlineCmd::KillToken), make_md(L!("kill-whole-line"), ReadlineCmd::KillWholeLine), - make_md(L!("kill-word"), ReadlineCmd::KillWord), - make_md(L!("nextd-or-forward-word"), ReadlineCmd::NextdOrForwardWord), + make_md(L!("kill-word"), ReadlineCmd::KillWordEmacs), + make_md(L!("kill-word-end"), ReadlineCmd::KillWordEnd), + make_md(L!("kill-word-vi"), ReadlineCmd::KillWordVi), + make_md(L!("nextd-or-forward-word"), ReadlineCmd::NextdOrForwardWordEmacs), make_md(L!("or"), ReadlineCmd::FuncOr), make_md(L!("pager-toggle-search"), ReadlineCmd::PagerToggleSearch), make_md(L!("prevd-or-backward-word"), ReadlineCmd::PrevdOrBackwardWord), diff --git a/src/input_common.rs b/src/input_common.rs index a5c609e24..e06c3a9e7 100644 --- a/src/input_common.rs +++ b/src/input_common.rs @@ -50,15 +50,21 @@ pub enum ReadlineCmd { BackwardCharPassive, ForwardSingleChar, ForwardCharPassive, - ForwardWord, BackwardWord, - ForwardBigword, + ForwardWordEmacs, + ForwardBigwordEmacs, BackwardBigword, + ForwardWordEnd, + BackwardWordEnd, + ForwardBigwordEnd, + BackwardBigwordEnd, + ForwardWordVi, + ForwardBigwordVi, ForwardPathComponent, ForwardToken, BackwardPathComponent, BackwardToken, - NextdOrForwardWord, + NextdOrForwardWordEmacs, PrevdOrBackwardWord, HistoryDelete, HistorySearchBackward, @@ -81,13 +87,23 @@ pub enum ReadlineCmd { BackwardKillLine, KillWholeLine, KillInnerLine, - KillWord, - KillBigword, + KillWordEmacs, + KillBigwordEmacs, + KillWordVi, + KillBigwordVi, + KillWordEnd, + KillBigwordEnd, + KillInnerWord, + KillInnerBigWord, + KillAWord, + KillABigWord, KillPathComponent, KillToken, BackwardKillWord, + BackwardKillWordEnd, BackwardKillPathComponent, BackwardKillBigword, + BackwardKillBigwordEnd, BackwardKillToken, HistoryTokenSearchBackward, HistoryTokenSearchForward, diff --git a/src/reader/reader.rs b/src/reader/reader.rs index 7e44c5243..abec3496d 100644 --- a/src/reader/reader.rs +++ b/src/reader/reader.rs @@ -49,7 +49,7 @@ use super::history_search::{ReaderHistorySearch, SearchMode, smartcase_flags}; use super::iothreads::{self, Debouncers}; -use super::word_motion::{MoveWordStateMachine, MoveWordStyle}; +use super::word_motion::{MoveWordDir, MoveWordStateMachine, MoveWordStyle}; use crate::abbrs::abbrs_match; use crate::ast::{self, Kind, is_same_node}; use crate::builtins::shared::ErrorCode; @@ -121,7 +121,6 @@ have_proc_stat, hup_jobs, is_interactive_session, job_reap, jobs_requiring_warning_on_exit, print_exit_warning_for_jobs, proc_update_jiffies, }; -use crate::reader::word_motion::MoveWordDir; use crate::screen::is_dumb; use crate::screen::{CharOffset, Screen, screen_force_clear_to_end}; use crate::should_flog; @@ -376,6 +375,7 @@ pub fn current_data() -> Option<&'static mut ReaderData> { .map(|data| unsafe { Pin::get_unchecked_mut(Pin::as_mut(data)) }) } pub use current_data as reader_current_data; +use fish_wchar::word_char::is_blank; /// Add a new reader to the reader stack. /// If `history_name` is empty, then save history in-memory only; do not write it to disk. @@ -2187,39 +2187,64 @@ fn move_word( erase: bool, style: MoveWordStyle, newv: bool, + to_word_end: bool, ) { let move_right = direction == MoveWordDir::Right; - // Return if we are already at the edge. + let state_machine_dir = if to_word_end { + match direction { + MoveWordDir::Left => MoveWordDir::Right, + MoveWordDir::Right => MoveWordDir::Left, + } + } else { + direction + }; let el = self.edit_line(elt); let boundary = if move_right { el.len() } else { 0 }; + + // Return if we are already at the edge. if el.position() == boundary { return; } // When moving left, a value of 1 means the character at index 0. - let mut state = MoveWordStateMachine::new(style); + let mut state = MoveWordStateMachine::new(style, state_machine_dir); let start_buff_pos = el.position(); let mut buff_pos = el.position(); - while buff_pos != boundary { - let idx = if move_right { buff_pos } else { buff_pos - 1 }; - if !state.consume_char(el.text(), idx) { + + let end = if move_right { + if to_word_end { el.len() - 1 } else { el.len() } + } else if to_word_end { + usize::MAX + } else { + 0 + }; + + while buff_pos != end { + if move_right && buff_pos >= el.len() { break; } - buff_pos = if move_right { - buff_pos + 1 + let char_pos = if move_right { + if to_word_end { buff_pos + 1 } else { buff_pos } + } else if to_word_end { + buff_pos } else { - buff_pos - 1 + buff_pos.wrapping_sub(1) }; + let consumed = state.consume_char(el.text(), char_pos); + if consumed { + buff_pos = if move_right { + buff_pos + 1 + } else { + buff_pos.wrapping_sub(1) + }; + } else { + break; + } } - // Always consume at least one character. - if buff_pos == start_buff_pos { - buff_pos = if move_right { - buff_pos + 1 - } else { - buff_pos - 1 - }; + if buff_pos == usize::MAX { + buff_pos = 0; } // If we are moving left, buff_pos-1 is the index of the first character we do not delete @@ -2231,15 +2256,150 @@ fn move_word( } if move_right { - self.kill(elt, start_buff_pos..buff_pos, Kill::Append, newv); + self.kill( + elt, + start_buff_pos..(buff_pos + if to_word_end { 1 } else { 0 }), + Kill::Append, + newv, + ); } else { - self.kill(elt, buff_pos..start_buff_pos, Kill::Prepend, newv); + self.kill( + elt, + buff_pos..start_buff_pos + if to_word_end { 1 } else { 0 }, + Kill::Prepend, + newv, + ); } } else { self.update_buff_pos(elt, Some(buff_pos)); } } + fn delete_a_word(&mut self, elt: EditableLineTag, style: MoveWordStyle, newv: bool) { + let el = self.edit_line(elt); + if el.is_empty() { + return; + } + let text_slice = el.text().as_char_slice(); + let pos = el.position(); + let on_blank = is_blank(text_slice[pos]); + + if on_blank { + // first move backward until non-spaces + let mut begin_pos = 0; + for idx in (0..pos).rev() { + if !is_blank(text_slice[idx]) { + begin_pos = idx + 1; + break; + } + } + self.update_buff_pos(elt, Some(begin_pos)); + // then delete to next word end + self.move_word(elt, MoveWordDir::Right, true, style, newv, true) + } else { + // first, move right by 1 + if pos < el.len() - 1 { + self.update_buff_pos(elt, Some(el.position() + 1)); + } + // then move to word start + self.move_word( + elt, + MoveWordDir::Left, + /*erase=*/ false, + style, + newv, + false, + ); + let word_start = self.edit_line(elt).position(); + self.move_word( + elt, + MoveWordDir::Right, + /*erase=*/ false, + style, + newv, + true, + ); + + let el = self.edit_line(elt); + let word_end = el.position() + 1; + let text_slice = el.text().as_char_slice(); + let len = el.len(); + let kill_range = if word_end < len && is_blank(text_slice[word_end]) { + let mut end_pos = len; + for (idx, &c) in text_slice.iter().enumerate().skip(word_end + 1) { + if !is_blank(c) { + end_pos = idx; + break; + } + } + word_start..end_pos + } else if word_start > 0 && is_blank(text_slice[word_start - 1]) { + let mut begin_pos = 0; + for idx in (0..word_start - 1).rev() { + if !is_blank(text_slice[idx]) { + begin_pos = idx + 1; + break; + } + } + begin_pos..word_end + } else { + word_start..word_end + }; + self.update_buff_pos(elt, Some(word_start)); + self.kill(elt, kill_range, Kill::Append, newv); + } + } + + fn delete_inner_word(&mut self, elt: EditableLineTag, style: MoveWordStyle, newv: bool) { + let el = self.edit_line(elt); + let len = el.len(); + if len == 0 { + return; + } + let pos = el.position(); + let text_slice = el.text().as_char_slice(); + if is_blank(text_slice[pos]) { + // Cursor is on whitespace: delete whitespace only + let mut begin_pos = 0; + for idx in (0..pos).rev() { + if !is_blank(text_slice[idx]) { + begin_pos = idx + 1; + break; + } + } + let mut end_pos = len; + for (idx, &c) in text_slice.iter().enumerate().skip(pos) { + if !is_blank(c) { + end_pos = idx; + break; + } + } + self.kill(elt, begin_pos..end_pos, Kill::Append, newv); + } else { + if el.position() != 0 { + // first, move right by 1 + self.update_buff_pos(elt, Some(el.position() + 1)); + // then move to word start + self.move_word( + elt, + MoveWordDir::Left, + /*erase=*/ false, + style, + newv, + false, + ); + } + self.move_word( + elt, + MoveWordDir::Right, + /*erase=*/ true, + style, + newv, + true, + ); + } + } + fn jump_to_matching_bracket( &mut self, precision: JumpPrecision, @@ -3403,21 +3563,77 @@ fn handle_readline_command(&mut self, c: ReadlineCmd) { } } } - rl::BackwardKillWord | rl::BackwardKillPathComponent | rl::BackwardKillBigword => { + rl::ForwardWordEmacs + | rl::ForwardBigwordEmacs + | rl::KillWordEmacs + | rl::KillBigwordEmacs + | rl::NextdOrForwardWordEmacs => { + if c == rl::PrevdOrBackwardWord && self.command_line.is_empty() { + self.eval_bind_cmd(L!("prevd")); + self.schedule_prompt_repaint(); + return; + } + let (elt, el) = self.active_edit_line(); + let is_kill = matches!(c, rl::KillWordEmacs | rl::KillBigwordEmacs); let style = match c { - rl::BackwardKillBigword => MoveWordStyle::Whitespace, - rl::BackwardKillPathComponent => MoveWordStyle::PathComponents, - rl::BackwardKillWord => MoveWordStyle::Punctuation, + rl::ForwardWordEmacs | rl::NextdOrForwardWordEmacs | rl::KillWordEmacs => { + MoveWordStyle::Punctuation + } + rl::ForwardBigwordEmacs | rl::KillBigwordEmacs => MoveWordStyle::Whitespace, _ => unreachable!(), }; + let is_word_end = el.position() + 1 < el.len() + && !is_blank(el.at(el.position())) + && is_blank(el.at(el.position() + 1)); + + // if at word end, forward/kill char, otherwise forward/kill word end + if self.is_at_autosuggestion() { + self.accept_autosuggestion(AutosuggestionPortion::PerMoveWordStyle { + style, + to_word_end: true, + }); + } else if is_word_end { + if is_kill { + self.delete_char(/*backward*/ false); + } else { + let (_elt, el) = self.active_edit_line_mut(); + el.set_position(el.position() + 1); + } + } else { + self.data.move_word( + elt, + MoveWordDir::Right, + is_kill, + style, + self.rls().last_cmd != Some(c), + true, + ); + if !is_kill { + let (_elt, el) = self.active_edit_line_mut(); + if el.position() + 1 < el.len() { + el.set_position(el.position() + 1); + } + } + } + } + rl::BackwardKillWord + | rl::BackwardKillPathComponent + | rl::BackwardKillBigword + | rl::BackwardKillWordEnd + | rl::BackwardKillBigwordEnd => { + let style = match c { + rl::BackwardKillWord | rl::BackwardKillWordEnd => MoveWordStyle::Punctuation, + rl::BackwardKillBigword | rl::BackwardKillBigwordEnd => { + MoveWordStyle::Whitespace + } + rl::BackwardKillPathComponent => MoveWordStyle::PathComponents, + _ => unreachable!(), + }; + let to_word_end = matches!(c, rl::BackwardKillWordEnd | rl::BackwardKillBigwordEnd); // Is this the same killring item as the last kill? let newv = !matches!( self.rls().last_cmd, - Some( - rl::BackwardKillWord - | rl::BackwardKillPathComponent - | rl::BackwardKillBigword - ) + Some(rl::BackwardKillWord | rl::BackwardKillBigword) ); self.data.move_word( self.active_edit_line_tag(), @@ -3425,25 +3641,52 @@ fn handle_readline_command(&mut self, c: ReadlineCmd) { /*erase=*/ true, style, newv, + to_word_end, ) } - rl::KillWord | rl::KillPathComponent | rl::KillBigword => { + rl::KillWordVi + | rl::KillBigwordVi + | rl::KillPathComponent + | rl::KillWordEnd + | rl::KillBigwordEnd => { // The "bigword" functions differ only in that they move to the next whitespace, not // punctuation. let style = match c { - rl::KillBigword => MoveWordStyle::Whitespace, + rl::KillWordVi | rl::KillWordEnd => MoveWordStyle::Punctuation, + rl::KillBigwordVi | rl::KillBigwordEnd => MoveWordStyle::Whitespace, rl::KillPathComponent => MoveWordStyle::PathComponents, - rl::KillWord => MoveWordStyle::Punctuation, _ => unreachable!(), }; + let to_word_end = matches!(c, rl::KillWordEnd | rl::KillBigwordEnd); self.data.move_word( self.active_edit_line_tag(), MoveWordDir::Right, /*erase=*/ true, style, self.rls().last_cmd != Some(c), + to_word_end, ); } + rl::KillInnerWord | rl::KillInnerBigWord => { + let style = match c { + rl::KillInnerBigWord => MoveWordStyle::Whitespace, + rl::KillInnerWord => MoveWordStyle::Punctuation, + _ => unreachable!(), + }; + let elt = self.active_edit_line_tag(); + let newv = self.rls().last_cmd != Some(c); + self.data.delete_inner_word(elt, style, newv); + } + rl::KillAWord | rl::KillABigWord => { + let style = match c { + rl::KillABigWord => MoveWordStyle::Whitespace, + rl::KillAWord => MoveWordStyle::Punctuation, + _ => unreachable!(), + }; + let elt = self.active_edit_line_tag(); + let newv = self.rls().last_cmd != Some(c); + self.data.delete_a_word(elt, style, newv); + } rl::BackwardKillToken => { let Some(new_position) = self.backward_token() else { return; @@ -3506,51 +3749,59 @@ fn handle_readline_command(&mut self, c: ReadlineCmd) { } } rl::BackwardWord + | rl::BackwardWordEnd | rl::BackwardPathComponent | rl::BackwardBigword + | rl::BackwardBigwordEnd | rl::PrevdOrBackwardWord => { - if c == rl::PrevdOrBackwardWord && self.command_line.is_empty() { - self.eval_bind_cmd(L!("prevd")); - self.schedule_prompt_repaint(); - return; - } + let to_word_end = matches!(c, rl::BackwardWordEnd | rl::BackwardBigwordEnd); let style = match c { - rl::BackwardBigword => MoveWordStyle::Whitespace, + rl::BackwardWord | rl::BackwardWordEnd | rl::PrevdOrBackwardWord => { + MoveWordStyle::Punctuation + } + rl::BackwardBigword | rl::BackwardBigwordEnd => MoveWordStyle::Whitespace, rl::BackwardPathComponent => MoveWordStyle::PathComponents, - rl::BackwardWord | rl::PrevdOrBackwardWord => MoveWordStyle::Punctuation, _ => unreachable!(), }; + self.data.move_word( self.active_edit_line_tag(), MoveWordDir::Left, /*erase=*/ false, style, false, + to_word_end, ); } - rl::ForwardWord - | rl::ForwardPathComponent - | rl::ForwardBigword - | rl::NextdOrForwardWord => { - if c == rl::NextdOrForwardWord && self.command_line.is_empty() { - self.eval_bind_cmd(L!("nextd")); - self.schedule_prompt_repaint(); - return; - } - + rl::ForwardWordVi + | rl::ForwardBigwordVi + | rl::ForwardWordEnd + | rl::ForwardBigwordEnd + | rl::ForwardPathComponent => { let style = match c { - rl::ForwardBigword => MoveWordStyle::Whitespace, + rl::ForwardWordVi | rl::ForwardWordEnd => MoveWordStyle::Punctuation, + rl::ForwardBigwordVi | rl::ForwardBigwordEnd => MoveWordStyle::Whitespace, rl::ForwardPathComponent => MoveWordStyle::PathComponents, - rl::ForwardWord | rl::NextdOrForwardWord => MoveWordStyle::Punctuation, _ => unreachable!(), }; + let to_word_end = matches!(c, rl::ForwardWordEnd | rl::ForwardBigwordEnd); if self.is_at_autosuggestion() { - self.accept_autosuggestion(AutosuggestionPortion::PerMoveWordStyle(style)); + self.accept_autosuggestion(AutosuggestionPortion::PerMoveWordStyle { + style, + to_word_end, + }); } else if !self.is_at_end() { let (elt, _el) = self.active_edit_line(); - self.move_word(elt, MoveWordDir::Right, /*erase=*/ false, style, false); + self.move_word( + elt, + MoveWordDir::Right, + /*erase=*/ false, + style, + false, + to_word_end, + ); } } rl::BeginningOfHistory | rl::EndOfHistory => { @@ -3810,6 +4061,7 @@ fn handle_readline_command(&mut self, c: ReadlineCmd) { false, MoveWordStyle::Punctuation, false, + false, ); let (elt, el) = self.active_edit_line(); let mut replacement = WString::new(); @@ -5160,7 +5412,10 @@ fn get_autosuggestion_performer( enum AutosuggestionPortion { Count(usize), Line, - PerMoveWordStyle(MoveWordStyle), + PerMoveWordStyle { + style: MoveWordStyle, + to_word_end: bool, + }, } impl<'a> Reader<'a> { @@ -5341,16 +5596,24 @@ fn accept_autosuggestion(&mut self, amount: AutosuggestionPortion) { suggested[..line_end].to_owned(), ) } - AutosuggestionPortion::PerMoveWordStyle(style) => { + AutosuggestionPortion::PerMoveWordStyle { style, to_word_end } => { // Accept characters according to the specified style. - let mut state = MoveWordStateMachine::new(style); + + let state_machine_dir = if to_word_end { + MoveWordDir::Left + } else { + MoveWordDir::Right + }; + let mut state = MoveWordStateMachine::new(style, state_machine_dir); let have = search_string_range.len(); let mut want = have; while want < autosuggestion_text.len() { - if !state.consume_char(autosuggestion_text, want) { + let consumed = state.consume_char(autosuggestion_text, want); + if consumed { + want += 1; + } else { break; } - want += 1; } ( search_string_range.end..search_string_range.end, @@ -5878,13 +6141,19 @@ fn command_ends_paging(c: ReadlineCmd, focused_on_search_field: bool) -> bool { } rl::BeginningOfLine | rl::EndOfLine - | rl::ForwardWord + | rl::ForwardBigwordVi + | rl::ForwardWordVi + | rl::KillBigwordVi + | rl::KillWordVi + | rl::ForwardWordEmacs + | rl::ForwardBigwordEmacs + | rl::KillWordEmacs + | rl::KillBigwordEmacs | rl::BackwardWord - | rl::ForwardBigword | rl::BackwardBigword | rl::ForwardToken | rl::BackwardToken - | rl::NextdOrForwardWord + | rl::NextdOrForwardWordEmacs | rl::PrevdOrBackwardWord | rl::DeleteChar | rl::BackwardDeleteChar @@ -5894,8 +6163,6 @@ fn command_ends_paging(c: ReadlineCmd, focused_on_search_field: bool) -> bool { | rl::BackwardKillLine | rl::KillWholeLine | rl::KillInnerLine - | rl::KillWord - | rl::KillBigword | rl::KillToken | rl::BackwardKillWord | rl::BackwardKillPathComponent diff --git a/src/reader/word_motion.rs b/src/reader/word_motion.rs index d98180b72..d628a30e5 100644 --- a/src/reader/word_motion.rs +++ b/src/reader/word_motion.rs @@ -1,6 +1,9 @@ +use std::ops::ControlFlow; + use crate::prelude::*; use crate::reader::is_backslashed; use crate::tokenizer::tok_is_string_character; +use fish_wchar::word_char::{WordCharClass, is_blank}; #[derive(Clone, Copy, PartialEq, Eq)] pub enum MoveWordStyle { @@ -19,12 +22,14 @@ pub enum MoveWordDir { } pub struct MoveWordStateMachine { + direction: MoveWordDir, state: MoveWordState, } enum MoveWordState { SmallWord(State), - PathComponent(State), + PathComponentBackward(State), + PathComponentForward(State), BigWord(State), } @@ -40,222 +45,321 @@ enum NonFinalState { } trait HasNextState { - fn next_state(state: NonFinalState<&Self>, text: &wstr, idx: usize, c: char) -> State + fn next_state( + direction: MoveWordDir, + state: NonFinalState<&Self>, + text: &wstr, + idx: usize, + c: char, + ) -> State where Self: Sized; } impl MoveWordStateMachine { - pub fn new(style: MoveWordStyle) -> Self { + pub fn new(style: MoveWordStyle, direction: MoveWordDir) -> Self { use MoveWordState as MWS; use MoveWordStyle as Style; let state = match style { Style::Punctuation => MWS::SmallWord(State::Initial), - Style::PathComponents => MWS::PathComponent(State::Initial), + Style::PathComponents => match direction { + MoveWordDir::Left => MWS::PathComponentBackward(State::Initial), + MoveWordDir::Right => MWS::PathComponentForward(State::Initial), + }, Style::Whitespace => MWS::BigWord(State::Initial), }; - Self { state } + Self { direction, state } } pub fn consume_char(&mut self, text: &wstr, idx: usize) -> bool { use MoveWordState as MWS; + let direction = self.direction; match &mut self.state { - MWS::SmallWord(state) => Self::to_next_state(state, text, idx), - MWS::PathComponent(state) => Self::to_next_state(state, text, idx), - MWS::BigWord(state) => Self::to_next_state(state, text, idx), + MWS::SmallWord(state) => Self::to_next_state(direction, state, text, idx), + MWS::PathComponentBackward(state) => Self::to_next_state(direction, state, text, idx), + MWS::PathComponentForward(state) => Self::to_next_state(direction, state, text, idx), + MWS::BigWord(state) => Self::to_next_state(direction, state, text, idx), } } - fn to_next_state(state: &mut State, text: &wstr, idx: usize) -> bool { + fn to_next_state( + direction: MoveWordDir, + state: &mut State, + text: &wstr, + idx: usize, + ) -> bool { let input_state = match &*state { State::Initial => NonFinalState::Initial, State::Live(s) => NonFinalState::Live(s), State::Final => panic!(), }; let c = text.as_char_slice()[idx]; - *state = MWS::next_state(input_state, text, idx, c); + *state = MWS::next_state(direction, input_state, text, idx, c); !matches!(*state, State::Final) } } -#[derive(Clone, Copy)] -enum SmallWordMovementState { - Rest, - WhitespaceRest, - Whitespace, - Alphanumeric, +trait WordMovementState: HasNextState { + fn from_last_char_class(char_class: WordCharClass) -> Self; + fn last_char_class(&self) -> WordCharClass; +} + +struct SmallWordMovementState { + last_char_class: WordCharClass, +} +impl WordMovementState for SmallWordMovementState { + fn from_last_char_class(last_char_class: WordCharClass) -> Self { + Self { last_char_class } + } + fn last_char_class(&self) -> WordCharClass { + self.last_char_class + } } impl HasNextState for SmallWordMovementState { - fn next_state(state: NonFinalState<&Self>, _text: &wstr, _idx: usize, c: char) -> State { - use {NonFinalState as NFS, SmallWordMovementState as S}; - let final_state = State::Final; - State::Live(match state { - NFS::Initial => { - if c.is_whitespace() { - S::Whitespace - } else if c.is_alphanumeric() { - S::Alphanumeric - } else { - // Don't allow switching type (ws->nonws) after non-whitespace and - // non-alphanumeric. - S::Rest - } - } - NFS::Live(S::Rest) => { - if c.is_whitespace() { - // Consume only trailing whitespace. - S::WhitespaceRest - } else if c.is_alphanumeric() { - // Consume only alnums. - S::Alphanumeric - } else { - return final_state; - } - } - NFS::Live(s @ S::WhitespaceRest) | NFS::Live(s @ S::Whitespace) => { - // "whitespace" consumes whitespace and switches to alnums, - // "whitespace_rest" only consumes whitespace. - if c.is_whitespace() { - // Consumed whitespace. - *s - } else { - if !matches!(s, S::Whitespace) { - return final_state; - } - if c.is_alphanumeric() { - S::Alphanumeric - } else { - return final_state; - } - } - } - NFS::Live(S::Alphanumeric) => { - if c.is_alphanumeric() { - S::Alphanumeric - } else { - return final_state; - } - } - }) + fn next_state( + direction: MoveWordDir, + state: NonFinalState<&Self>, + _text: &wstr, + _idx: usize, + c: char, + ) -> State { + let char_class = WordCharClass::from_char(c); + next_word_movement_state(direction, state, char_class) } } -// Consume a "word" of printable characters plus any leading whitespace. -enum BigWordMovementState { - Blank, - Graph, +struct BigWordMovementState { + last_char_class: WordCharClass, } impl HasNextState for BigWordMovementState { - fn next_state(state: NonFinalState<&Self>, _text: &wstr, _idx: usize, c: char) -> State { - use {BigWordMovementState as S, NonFinalState as NFS}; - State::Live(match state { - NFS::Initial => { - // always consume the first character - // If it's not whitespace, only consume those from here. - if !c.is_whitespace() { - S::Graph - } else { - // If it's whitespace, keep consuming whitespace until the graphs. - S::Blank - } - } - NFS::Live(S::Blank) => { - if c.is_whitespace() { - // consumed whitespace - S::Blank - } else { - S::Graph - } - } - NFS::Live(S::Graph) => { - if !c.is_whitespace() { - // consumed printable non-space - S::Graph - } else { - return State::Final; - } - } - }) + fn next_state( + direction: MoveWordDir, + state: NonFinalState<&Self>, + _text: &wstr, + _idx: usize, + c: char, + ) -> State { + let char_class = if c == '\n' { + WordCharClass::Newline + } else if is_blank(c) { + WordCharClass::Blank + } else { + WordCharClass::Word + }; + next_word_movement_state(direction, state, char_class) + } +} +impl WordMovementState for BigWordMovementState { + fn from_last_char_class(last_char_class: WordCharClass) -> Self { + Self { last_char_class } + } + fn last_char_class(&self) -> WordCharClass { + self.last_char_class } } -fn is_path_component_character(c: char) -> bool { - tok_is_string_character(c, None) && !L!("/={,}'\":@#").as_char_slice().contains(&c) +fn next_word_movement_state( + direction: MoveWordDir, + state: NonFinalState<&WMS>, + char_class: WordCharClass, +) -> State { + let last_char_class_state = match state { + NonFinalState::Initial => None, + NonFinalState::Live(wms) => Some(wms.last_char_class()), + }; + if consume_char_word_movement(direction, last_char_class_state, char_class).is_break() { + return State::Final; + } + State::Live(WMS::from_last_char_class(char_class)) +} + +fn consume_char_word_movement( + direction: MoveWordDir, + last_char_class: Option, + cur_class: WordCharClass, +) -> ControlFlow<()> { + enum Transition { + Blank, + Newline, + SameClass, + DifferentClass, + } + use Transition as T; + let transition = if cur_class == WordCharClass::Blank { + T::Blank + } else if cur_class == WordCharClass::Newline { + T::Newline + } else if last_char_class == Some(cur_class) { + T::SameClass + } else { + T::DifferentClass + }; + + let Some(last_char_class) = last_char_class else { + return ControlFlow::Continue(()); + }; + if match direction { + MoveWordDir::Left => match last_char_class { + WordCharClass::Blank => false, + WordCharClass::Newline => matches!(transition, T::Newline), + _ => matches!(transition, T::Blank | T::Newline | T::DifferentClass), + }, + MoveWordDir::Right => match last_char_class { + WordCharClass::Blank => { + matches!(transition, T::SameClass | T::DifferentClass) + } + WordCharClass::Newline => { + matches!(transition, T::Newline | T::SameClass | T::DifferentClass) + } + _ => matches!(transition, T::DifferentClass), + }, + } { + ControlFlow::Break(()) + } else { + ControlFlow::Continue(()) + } } /// a path component contains either /// - [space +] end word /// - punc + end word /// - space + end punc -#[derive(Clone, Copy)] -enum PathComponentState { - Whitespace, - Separator, - Slash, - PathComponentCharacters, - InitialSeparator, +enum PathComponentTransition { + Blank, + PathComponent, + Punctuation, } -impl HasNextState for PathComponentState { - fn next_state(state: NonFinalState<&Self>, text: &wstr, idx: usize, c: char) -> State { - use {NonFinalState as NFS, PathComponentState as S}; - let is_escaped = is_backslashed(text, idx); - let is_whitespace = c.is_whitespace() && !is_escaped; - let is_path_component_character = - is_path_component_character(c) || (c.is_whitespace() && is_escaped); + +fn path_component_state_transition( + text: &wstr, + idx: usize, + c: char, +) -> ControlFlow<(), PathComponentTransition> { + let escaped = is_backslashed(text, idx); + if c == '\\' && !escaped { + return ControlFlow::Break(()); + }; + + use PathComponentTransition as T; + ControlFlow::Continue(if is_blank(c) && !escaped { + T::Blank + } else if is_path_component_character(c) || (is_blank(c) && escaped) { + T::PathComponent + } else { + T::Punctuation + }) +} + +fn is_path_component_character(c: char) -> bool { + tok_is_string_character(c, None) && !L!("/={,}'\":@#").as_char_slice().contains(&c) +} + +#[derive(Clone, Copy)] +enum PathComponentForwardState { + PathComponent, + Punctuation, + Blank, +} +impl HasNextState for PathComponentForwardState { + fn next_state( + _direction: MoveWordDir, + state: NonFinalState<&Self>, + text: &wstr, + idx: usize, + c: char, + ) -> State { + // Forward path component movement: skip current homogeneous component, stop at next. + // Each component type (word, slash, punctuation, whitespace) is a unit. + + let ControlFlow::Continue(transition) = path_component_state_transition(text, idx, c) + else { + return match state { + NFS::Initial => State::Initial, + NFS::Live(s) => State::Live(*s), + }; + }; + + use {NonFinalState as NFS, PathComponentForwardState as S, PathComponentTransition as T}; + let final_state = State::Final; + State::Live(match state { + NFS::Initial | NFS::Live(S::PathComponent) => match transition { + T::Blank => S::Blank, + T::PathComponent => S::PathComponent, + T::Punctuation => S::Punctuation, + }, + NFS::Live(S::Punctuation) => match transition { + T::Blank => S::Blank, + T::PathComponent => return final_state, + T::Punctuation => S::Punctuation, + }, + NFS::Live(S::Blank) => match transition { + T::Blank => S::Blank, + T::PathComponent => return final_state, + T::Punctuation => return final_state, + }, + }) + } +} + +#[derive(Clone, Copy)] +enum PathComponentBackwardState { + Punctuation, + PathComponent, + Space, + EndPathComponent, + EndPunctuation, +} +impl HasNextState for PathComponentBackwardState { + fn next_state( + _direction: MoveWordDir, + state: NonFinalState<&Self>, + text: &wstr, + idx: usize, + c: char, + ) -> State { + use {NonFinalState as NFS, PathComponentBackwardState as S, PathComponentTransition as T}; + + let ControlFlow::Continue(transition) = path_component_state_transition(text, idx, c) + else { + return match state { + NFS::Initial => State::Initial, + NFS::Live(s) => State::Live(*s), + }; + }; let final_state = State::Final; State::Live(match state { - NFS::Initial => { - if !is_path_component_character && !is_whitespace { - S::InitialSeparator - } else { - if is_path_component_character { - S::PathComponentCharacters - } else { - S::Whitespace - } - } - } - NFS::Live(S::Whitespace) => { - if is_whitespace { - S::Whitespace - } else if c == '/' { - S::Slash // path component - } else if is_path_component_character { - S::PathComponentCharacters - } else { - S::Separator // path separator - } - } - NFS::Live(S::Separator) => { - if !is_whitespace && !is_path_component_character { - S::Separator - } else { - return final_state; - } - } - NFS::Live(S::Slash) => { - if c == '/' { - S::Slash - } else { - S::PathComponentCharacters - } - } - NFS::Live(S::PathComponentCharacters) => { - if is_path_component_character { - S::PathComponentCharacters - } else { - return final_state; - } - } - NFS::Live(S::InitialSeparator) => { - if is_path_component_character { - S::PathComponentCharacters - } else if is_whitespace { - return final_state; - } else { - S::InitialSeparator - } - } + NFS::Initial => match transition { + T::Blank => S::Space, + T::PathComponent => S::PathComponent, + T::Punctuation => S::Punctuation, + }, + NFS::Live(ls) => match ls { + S::Punctuation => match transition { + T::Blank => return final_state, + T::PathComponent => S::EndPathComponent, + T::Punctuation => S::Punctuation, + }, + S::PathComponent => match transition { + T::Blank => return final_state, + T::PathComponent => S::EndPathComponent, + T::Punctuation => return final_state, + }, + S::Space => match transition { + T::Blank => S::Space, + T::PathComponent => S::PathComponent, + T::Punctuation => S::EndPunctuation, + }, + S::EndPathComponent => match transition { + T::Blank => return final_state, + T::PathComponent => S::EndPathComponent, + T::Punctuation => return final_state, + }, + S::EndPunctuation => match transition { + T::Blank => return final_state, + T::PathComponent => return final_state, + T::Punctuation => S::EndPunctuation, + }, + }, }) } } @@ -301,7 +405,7 @@ macro_rules! validate { let direction = $direction; let (command, mut stops, mut idx, end) = setup(direction, $line); assert!(!command.is_empty()); - let new_sm = || MoveWordStateMachine::new($style); + let new_sm = || MoveWordStateMachine::new($style, direction); let mut sm = new_sm(); while idx != end { let word_idx = if direction == MoveWordDir::Left { @@ -352,27 +456,69 @@ macro_rules! validate { validate!(Left, PathComponents, "^aaa ^@@@ ^@@^aa^"); validate!(Left, PathComponents, "^aa^@@ ^aa@@^a^"); validate!(Left, PathComponents, r#"^a\ ^b\ c/^d"^e\ f"^g"#); + validate!(Left, PathComponents, r#"^a\ ^b\ c/^d"^e\\\ f"^g"#); validate!(Left, PathComponents, r#"^a\"^bc^"#); - validate!(Left, PathComponents, "^/^foo/^bar/^baz/^"); - validate!(Left, PathComponents, "^echo ^--foo ^--bar^"); - validate!(Left, PathComponents, "^echo ^hi ^> ^/^dev/^null^"); + validate!(Right, PathComponents, "^/^foo/^bar/^baz/^"); + validate!(Right, PathComponents, "^echo ^--foo ^--bar^"); + validate!(Right, PathComponents, "^echo ^hi ^> ^/^dev/^null^"); + validate!(Right, PathComponents, "^echo ^/^foo/^bar{^aaa,^ccc}^bak/^"); + validate!(Right, PathComponents, "^echo ^bak ^///^"); + validate!(Right, PathComponents, "^aaa ^@ ^@^aaa^"); + validate!(Right, PathComponents, "^aa@@ ^aa@@^a^"); // General punctuation tests - validate!(Right, Punctuation, "^a^ bcd^"); - validate!(Right, Punctuation, "a^b^ cde^"); - validate!(Right, Punctuation, "^ab^ cde^"); - validate!(Right, Punctuation, "^ab^&cd^ ^& ^e^ f^&^"); + validate!(Left, Punctuation, "^a ^bcd^"); + validate!(Left, Punctuation, "^ab ^cd^e"); + validate!(Left, Punctuation, "^ab ^c^de"); + validate!(Left, Punctuation, "^ab ^cde^"); + validate!(Right, Punctuation, "^a ^bcd^"); + validate!(Right, Punctuation, "a^b ^cde^"); + validate!(Right, Punctuation, "ab^ ^cde^"); + validate!(Right, Punctuation, "^ab ^cde^"); - validate!(Left, Punctuation, "^echo ^hello_^world.^txt^"); - validate!(Right, Punctuation, "^echo^ hello^_world^.txt^"); + validate!(Left, Punctuation, "^echo ^hello^_^world^.^txt^"); + validate!(Right, Punctuation, "^echo ^hello^_^world^.^txt^"); - validate!(Left, Punctuation, "^echo ^foo_^foo_^foo/^/^/^/^/^ ^"); - validate!(Right, Punctuation, "^echo^ foo^_foo^_foo^/^/^/^/^/ ^"); + validate!(Left, Punctuation, "^echo ^foo^__^foo^_^foo^// ^"); + validate!(Right, Punctuation, "^echo ^foo^__^foo^_^foo^//^"); - // General whitespace tests - validate!(Right, Whitespace, "^a-b-c^ d-e-f^"); - validate!(Right, Whitespace, "^a-b-c^\n d-e-f^ ^"); - validate!(Right, Whitespace, "^a-b-c^\n\nd-e-f^ ^"); + validate!(Right, Punctuation, "^ab^&^cd ^& ^e ^f^&^"); + validate!(Left, Punctuation, "^ab^&^cd ^& ^e ^f^&^"); + + // General Whiltespace tests + validate!(Left, Whitespace, "^a ^bcd^"); + validate!(Left, Whitespace, "^ab ^cd^e"); + validate!(Left, Whitespace, "^ab ^c^de"); + validate!(Left, Whitespace, "^ab ^cde^"); + validate!(Right, Whitespace, "^a ^bcd^"); + validate!(Right, Whitespace, "a^b ^cde^"); + validate!(Right, Whitespace, "ab^ ^cde^"); + validate!(Right, Whitespace, "^ab ^cde^"); + + // Newline-related tests + validate!(Right, Punctuation, "^a \n ^bcd^"); + validate!(Left, Punctuation, "^a \n ^bcd^"); + validate!(Right, Whitespace, "^a \n ^bcd^"); + validate!(Left, Whitespace, "^a \n ^bcd^"); + + validate!(Right, Punctuation, "^a\n^\n^b^-^cd^"); + validate!(Left, Punctuation, "^a\n^\n^b^_^cd^"); + validate!(Right, Whitespace, "^a\n^\n^b_cd^"); + validate!(Left, Whitespace, "^a\n^\n^b_cd^"); + + validate!(Right, Punctuation, "^a \n \n \n^\n ^bcd^"); + validate!(Left, Punctuation, "^a \n \n \n^\n ^bcd^"); + validate!(Right, Whitespace, "^a \n \n \n^\n ^bcd^"); + validate!(Left, Whitespace, "^a \n \n \n^\n ^bcd^"); + + // Unicode-related tests + validate!(Right, Punctuation, "^hello ^中^@^文^あいう^漢字^"); + validate!(Left, Punctuation, "^hello ^中^@^文^あいう^漢字^"); + validate!(Right, Whitespace, "^hello ^中文あいう漢字^"); + validate!(Left, Whitespace, "^hello ^中文あいう漢字^"); + + validate!(Right, Punctuation, "^café ^naïve^"); + validate!(Left, Punctuation, "^café ^naïve^"); } } diff --git a/tests/pexpects/bind.py b/tests/pexpects/bind.py index 7552783d4..1e5838e87 100644 --- a/tests/pexpects/bind.py +++ b/tests/pexpects/bind.py @@ -325,6 +325,199 @@ expect_prompt( unmatched="Unexpected vi bind modes", ) +# Test word movements +# Test 'w' with underscore - should not jump over single punctuation +send("echo abc_def") +send("\033") +sleep(0.200) +send("0wwD\r") # From start, 'w' twice should stop at underscore, delete from there +expect_prompt( + "\r\n.*abc\r\n", unmatched="vi mode 'w' should stop at single punctuation" +) + +# Test 'w' with multiple spaces - should skip spaces and land at start of next word +send("echo abc def") +send("\033") +sleep(0.200) +send("0wwwD\r") # Skip 'echo', 'abc', 'def', then delete last char +expect_prompt("\r\n.*abc de\r\n", unmatched="vi mode 'w' with multiple spaces") + +# Test 'w' with multiple punctuations - should stop at punctuation group +send("echo abc...def") +send("\033") +sleep(0.200) +send("0wwD\r") # Skip 'echo', then 'w' should stop at first '.', delete to end +expect_prompt("\r\n.*abc\r\n", unmatched="vi mode 'w' with multiple punctuations") + +# Test 'diw' when cursor is on space - should delete only spaces +send("echo abc def") +send("\033") +sleep(0.200) +send("0wwhdiw\r") # Move to 'def', back to space, delete inner word (spaces only) +expect_prompt( + "\r\n.*abcdef\r\n", unmatched="vi mode 'diw' on space should delete spaces" +) + +# Test 'daw' - should delete word and trim trailing space +send("echo abc def ghi") +send("\033") +sleep(0.200) +send("0wwdaw\r") # Skip 'echo', move to 'def', delete word with space +expect_prompt("\r\n.*abc ghi\r\n", unmatched="vi mode 'daw' should trim trailing space") + +# Test 'b' backward movement with punctuation - should stop at punctuation +send("echo abc_def") +send("\033") +sleep(0.200) +send("bD\r") # From end, 'b' should stop at 'd', delete to end +expect_prompt("\r\n.*abc_\r\n", unmatched="vi mode 'b' should stop at punctuation") + +# Test 'e' end-of-word movement +send("echo abc_def") +send("\033") +sleep(0.200) +send("0weD\r") # From start, 'w' to 'abc', 'e' to end of 'abc', delete to end +expect_prompt("\r\n.*ab\r\n", unmatched="vi mode 'e' should move to word end") + +# Test 'W' WORD movement - should skip punctuation within WORD +send("echo abc-def ghi") +send("\033") +sleep(0.200) +send("0wWD\r") # From start, 'w' to 'abc', 'W' should skip 'abc-def', delete 'ghi' +expect_prompt( + "\r\n.*abc-def\r\n", + unmatched="vi mode 'W' should treat punctuation as part of WORD", +) + +# Test 'E' end-of-WORD movement +send("echo abc-def ghi") +send("\033") +sleep(0.200) +send("0wED\r") # From start, 'w' to 'abc', 'E' to end of 'abc-def', delete to end +expect_prompt("\r\n.*abc-de\r\n", unmatched="vi mode 'E' should move to WORD end") + +# Test 'B' backward WORD movement +send("echo abc-def ghi") +send("\033") +sleep(0.200) +send("BD\r") # From end, 'B' backward to 'ghi', delete to end +expect_prompt("\r\n.*abc-def\r\n", unmatched="vi mode 'B' backward WORD movement") + +# Test 'ge' backward to end of previous word +send("echo abc def") +send("\033") +sleep(0.200) +send("0wwgex\r") # Move to 'def', 'ge' to 'c' of 'abc', delete char with 'x' +expect_prompt( + "\r\n.*ab def\r\n", unmatched="vi mode 'ge' should move to previous word end" +) + +# Test 'gE' backward to end of previous WORD +send("echo abc-def ghi") +send("\033") +sleep(0.200) +send( + "0WWgEx\r" +) # Use 'W' to move by WORDs: to 'abc-def', then 'ghi', then 'gE' back to 'f' of 'abc-def', delete char +expect_prompt( + "\r\n.*abc-de ghi\r\n", unmatched="vi mode 'gE' should move to previous WORD end" +) + +# Test 'diW' (delete inner WORD) with punctuation +send("echo abc-def ghi") +send("\033") +sleep(0.200) +send("0wldiW\r") # Move to 'bc-def', delete inner WORD +expect_prompt("\r\n.*ghi\r\n", unmatched="vi mode 'diW' should delete entire WORD") + +# Test 'daW' (delete a WORD) with punctuation +send("echo abc-def ghi") +send("\033") +sleep(0.200) +send("0wldaW\r") # Move to 'bc-def', delete a WORD with space +expect_prompt("\r\n.*ghi\r\n", unmatched="vi mode 'daW' should delete WORD and space") + +# Test Unicode character category separation +# In vim, different unicode categories are separated into words +send("echo abcあいう") +send("\033") +sleep(0.200) +send("0wwD\r") # Skip 'echo', then from 'a' of 'abc', 'w' should stop at 'あ' +expect_prompt( + "\r\n.*abc\r\n", unmatched="vi mode 'w' should stop at Unicode category boundary" +) + +# Test Unicode with multiple categories +send("echo abcあいう甲乙") +send("\033") +sleep(0.200) +send("0wwwD\r") # Skip 'echo', 'abc', hiragana, then at kanji, delete to end +expect_prompt( + "\r\n.*abcあいう\r\n", unmatched="vi mode 'w' should separate hiragana and kanji" +) + +# Test 'cw' - change word, deletes to start of next word (like vim's 'dw') +send("echo abc def") +send("\033") +sleep(0.200) +send("0wcwXXX\r") # Move to 'abc', 'cw' deletes 'abc ' (including space), type 'XXX' +expect_prompt( + "\r\n.*XXXdef\r\n", unmatched="vi mode 'cw' should delete to start of next word" +) + +# Test 'ce' - change to end of word (like vim's 'de') +send("echo abc def") +send("\033") +sleep(0.200) +send("0wceXXX\r") # Move to 'abc', 'ce' deletes 'abc' (not the space), type 'XXX' +expect_prompt( + "\r\n.*XXX def\r\n", unmatched="vi mode 'ce' should change to end of word" +) + +# Test 'cW' - change WORD, deletes to start of next WORD (like vim's 'dW') +send("echo abc-def ghi") +send("\033") +sleep(0.200) +send( + "0wcWXXX\r" +) # Move to 'abc-def', 'cW' deletes 'abc-def ' (including space), type 'XXX' +expect_prompt( + "\r\n.*XXXghi\r\n", unmatched="vi mode 'cW' should delete to start of next WORD" +) + +# Test 'cE' - change to end of WORD (like vim's 'dE') +send("echo abc-def ghi") +send("\033") +sleep(0.200) +send( + "0wcEXXX\r" +) # Move to 'abc-def', 'cE' deletes 'abc-def' (not the space), type 'XXX' +expect_prompt( + "\r\n.*XXX ghi\r\n", unmatched="vi mode 'cE' should change to end of WORD" +) + +# Test running commands on empty line (should not crash) +send("\033") +sleep(0.200) +send("dawdiwdwdedgedgE\r") # run many commands +expect_prompt() + +# Test accepting autosuggestions with w/W +sendline("echo test-suggestion test-suggestion") +expect_prompt() +send("echo te") +sleep(0.100) +send("\033") # Enter normal mode +sleep(0.200) +send("w") # forward-word-vi should accept 'st' from autosuggestion +expect_str("echo test") +send("w") # forward-word-vi should accept '-' +expect_str("echo test-") +send("w") # forward-word-vi should accept 'suggestion ' from autosuggestion +expect_str("echo test-suggestion ") +send("W\r") # forward-word-vi should accept 'test-suggestion' from autosuggestion +expect_prompt("test-suggestion test-suggestion") + # Switch back to regular (emacs mode) key bindings. sendline("set -g fish_key_bindings fish_default_key_bindings") expect_prompt()