feat(vi-mode): make word movements vi-compliant

- 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 achieved 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.

- Closes #10393.

Closes #12269

Co-authored-by: Johannes Altmanninger <aclopte@gmail.com>
This commit is contained in:
SharzyL
2026-01-10 16:18:04 +08:00
committed by Johannes Altmanninger
parent 4c3fcc7b16
commit bbb2f0de8d
9 changed files with 1058 additions and 326 deletions

View File

@@ -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

View File

@@ -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 <cmd-bind-backward-kill-path-component>` to the left.
move one :ref:`path component <cmd-bind-backward-kill-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 <cmd-bind-forward-word>`, but stops at the start of the next word (like vim's ``w``)
``forward-word-end``
like :ref:`forward-word <cmd-bind-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 <cmd-bind-forward-bigword>`, but stops at the start of the next word (like vim's ``W``)
``forward-bigword-end``
like :ref:`forward-bigword <cmd-bind-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;

View File

@@ -24,6 +24,19 @@ The following parameters are available:
Further information on how to use :ref:`vi mode <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
--------

View File

@@ -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

View File

@@ -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),

View File

@@ -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,

View File

@@ -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

View File

@@ -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<SmallWordMovementState>),
PathComponent(State<PathComponentState>),
PathComponentBackward(State<PathComponentBackwardState>),
PathComponentForward(State<PathComponentForwardState>),
BigWord(State<BigWordMovementState>),
}
@@ -40,222 +45,321 @@ enum NonFinalState<S> {
}
trait HasNextState {
fn next_state(state: NonFinalState<&Self>, text: &wstr, idx: usize, c: char) -> State<Self>
fn next_state(
direction: MoveWordDir,
state: NonFinalState<&Self>,
text: &wstr,
idx: usize,
c: char,
) -> State<Self>
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<MWS: HasNextState>(state: &mut State<MWS>, text: &wstr, idx: usize) -> bool {
fn to_next_state<MWS: HasNextState>(
direction: MoveWordDir,
state: &mut State<MWS>,
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<Self> {
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<Self> {
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<Self> {
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<Self> {
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<WMS: WordMovementState>(
direction: MoveWordDir,
state: NonFinalState<&WMS>,
char_class: WordCharClass,
) -> State<WMS> {
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<WordCharClass>,
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<Self> {
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<Self> {
// 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<Self> {
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^");
}
}

View File

@@ -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()