Skip to content

vi-mode: vi-compliant word movements - #12269

Closed
SharzyL wants to merge 3 commits into
fish-shell:masterfrom
SharzyL:feat/vi_mode
Closed

vi-mode: vi-compliant word movements#12269
SharzyL wants to merge 3 commits into
fish-shell:masterfrom
SharzyL:feat/vi_mode

Conversation

@SharzyL

@SharzyL SharzyL commented Jan 4, 2026

Copy link
Copy Markdown
Contributor

Description

Though fish has a vi-mode key binding, it differs from the the actual vi/vim in many ways, especially in word-based movements. This patch aims to give word-based movements vim-compliant behavior.

In summary,

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

There are also some key bindings in default mode also affected by this patch, including

# found in share/functions/__fish_shared_key_bindings.fish
__fish_per_os_bind --preset $argv ctrl-right forward-token forward-word
__fish_per_os_bind --preset $argv ctrl-left backward-token backward-word
bind --preset $argv shift-right forward-bigword
bind --preset $argv shift-left backward-bigword
bind --preset $argv alt-b prevd-or-backward-word
bind --preset $argv alt-f nextd-or-forward-word
bind --preset $argv alt-d 'if test "$(commandline; printf .)" = \n.; __fish_echo dirh; else; commandline -f kill-word; end'

# found in share/functions/fish_default_key_bindings.fish
__fish_per_os_bind --preset $argv alt-backspace backward-kill-word backward-kill-token
__fish_per_os_bind --preset $argv ctrl-alt-h backward-kill-word backward-kill-token
__fish_per_os_bind --preset $argv ctrl-backspace backward-kill-token backward-kill-word
__fish_per_os_bind --preset $argv alt-delete kill-word kill-token
__fish_per_os_bind --preset $argv ctrl-delete kill-token kill-word

List of Differences

The following is an incomplete list of behavior differences, in which | represents the cursor.

Fish jumps over a single punctuation but vim does not (for backward motion this issue is more evident, like #10393).

// pressing `w` consecutively in fish
|abc_def
abc_|def
abc_de|f

// pressing `w` consecutively in vim
|abc_def
abc|_def
abc_|def
abc_de|f

With multiple spaces or punctuations, fish does not locate word boundary correctly.

// pressing `w` consecutively in fish
|abc  def
abc | def
abc  de|f

// pressing `w` consecutively in vim
|abc  def
abc  |def
abc  de|f
// pressing `w` consecutively in fish
|abc...def
abc.|..def
abc...|def
abc...de|f

// pressing `w` consecutively in vim
|abc...def
abc|...def
abc...|def
abc...de|f

In vim, word-based movements do not skip over empty lines

// pressing `w` consecutively in fish
a\n|\n\nb
a\n\n\n|b

// pressing `w` consecutively in vim
a\n|\n\nb
a\n\n|\nb
a\n\n\n|b

In vim, unicode characters with different categories are separated into words

// pressing `w` consecutively in fish
|abcあいう甲乙丙
abcあいう甲乙|丙

// pressing `w` consecutively in vim
|abcあいう甲乙丙
abc|あいう甲乙丙
abcあいう|甲乙丙
abcあいう甲乙|丙

When there is a space under cursor, diw, diW in vim just delete the spaces,

// pressing `diw` in fish
abc| def
abc |

// pressing `diw` in vim
abc| def
abcdef

In vim, daw, daW also trims spaces on one side of the word

// pressing `daw` in fish
abc |def gh
abc | gh

// pressing `daw` in vim
abc |def gh
abc |gh

TODOs:

  • Changes to fish usage are reflected in user documentation/manpages.
  • Tests have been added for regressions fixed
  • User-visible changes noted in CHANGELOG.rst

@SharzyL SharzyL changed the title vi-mode: make word movements more vi-compiant vi-mode: vi-compiant word-based movements Jan 4, 2026
@SharzyL
SharzyL marked this pull request as draft January 4, 2026 11:14
@SharzyL
SharzyL force-pushed the feat/vi_mode branch 6 times, most recently from ddcab25 to d14f8e2 Compare January 4, 2026 13:11
@SharzyL
SharzyL marked this pull request as ready for review January 4, 2026 13:23
@SharzyL
SharzyL force-pushed the feat/vi_mode branch 3 times, most recently from 13e5a03 to faf86ed Compare January 4, 2026 14:12
@SharzyL SharzyL changed the title vi-mode: vi-compiant word-based movements vi-mode: vi-compliant word movements Jan 4, 2026
bind -s --preset e 'set fish_cursor_end_mode exclusive' forward-single-char forward-word backward-char 'set fish_cursor_end_mode inclusive'
bind -s --preset E 'set fish_cursor_end_mode exclusive' forward-single-char forward-bigword backward-char 'set fish_cursor_end_mode inclusive'
bind -s --preset g,e backward-word-end
bind -s --preset g,E backward-bigword-end

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(FWIW this PR has a small-ish conflict with #12170,
not sure if @HeitorAugustoLN will have time to finish that one, else it would be good if someone else could.
We'll merge first whichever is ready first)

Comment thread src/input.rs
Comment thread src/input.rs
Comment thread src/tokenizer.rs Outdated
Comment thread src/tokenizer.rs Outdated
Comment thread src/tokenizer.rs
@SharzyL
SharzyL force-pushed the feat/vi_mode branch 9 times, most recently from 5f3e93a to ac71ca6 Compare January 7, 2026 14:42
@SharzyL

SharzyL commented Jan 7, 2026

Copy link
Copy Markdown
Contributor Author

I learned that vim's cw is not dwi

https://vi.stackexchange.com/questions/6194/why-do-cw-and-ce-do-the-same-thing

Well let's not copy this behavior since it causes more inconsistency and code perplexity. But I added a doc for this in doc_src/cmds/fish_vi_key_bindings.rst.

@krobelus krobelus added this to the fish 4.4 milestone Jan 8, 2026
Comment thread CHANGELOG.rst
Comment thread CHANGELOG.rst Outdated
Comment thread CHANGELOG.rst Outdated
Comment thread crates/wchar/src/classify.rs Outdated
Comment thread crates/wchar/src/classify.rs Outdated
Comment thread src/tokenizer.rs
[(S_SPACE, true), (S_END, false), (S_SEP, true)], // S_SEP
[(S_SPACE, true), (S_END, false), (S_END, false)], // S_SPACE
[(S_END, false), (S_END, false), (S_END, false)], // S_END
];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is a rewrite combined with a behavior change
If it makes sense, can you

  1. start with a commit that does the rewrite without behavior change
  2. add the behavior change in another commit

This could make both easier to review.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I rewrote the logic of state machine and outer control at the same time thus it is quite hard to split the changes

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't see evidence yet why it would be hard:
once we have a golden commit, we could insert a new commit before it,
and re-do the rewrite of the state machine logic.
Conflicts are trivially resolved by resetting the final commit to the golden state.
The re-doing part is a bit of manual work but it's less effort than writing the original PR, and it leads to better changes.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok I get it now -- I split it into f33fef3 and bbb2f0d and the split seems less useful than expected, since the structure changed quite a bit.
It's disappointing that both the word/bigword as well as path component changes seemed to have used the wrong approach before.

I also merged the count support and added a hack to resolve an incompatibility with forward-word-vi in cases like d3w. Let's see if that works out

Comment thread src/tokenizer.rs
Comment thread src/tokenizer.rs
Comment thread src/tokenizer.rs Outdated
Comment thread src/tokenizer.rs
Add `use Direction::*` and `use MoveWordStyle::*` in tests to reduce
verbosity. Reformat tests to one-line style and reorder by test type.
No behavior change.
- 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.

- Closes fish-shell#10393.
@SharzyL

SharzyL commented Jan 10, 2026

Copy link
Copy Markdown
Contributor Author

Fixed many issues mentioned in the review comments, which can be browsed by git diff ac71ca612.

Cjk, // CJK Ideographs
Hangul, // Hangul Syllables
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work; I had some more comments on style; I made the changes in the two diffs below.
I'll give you some time to review and make further changes if you want,
but I'm also happy to finish this.

diff --git a/crates/wchar/src/word_char.rs b/crates/wchar/src/word_char.rs
index b5ffcb7575..83c3008f9b 100644
--- a/crates/wchar/src/word_char.rs
+++ b/crates/wchar/src/word_char.rs
@@ -1,6 +1,6 @@
 //! Support for character classification for vi-mode word movements
 
-use std::cmp::Ordering;
+use std::{cmp::Ordering, ops::RangeInclusive};
 
 /// Character class for word movements
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -19,35 +19,13 @@
     Hangul,      // Hangul Syllables
 }
 
-/// Check if codepoint is in emoji table using binary search (like vim's intable)
-fn is_emoji(c: char) -> bool {
-    let c = u32::from(c);
-    EMOJI_ALL
-        .binary_search_by(|&(first, last)| {
-            if last < c {
-                Ordering::Less
-            } else if first > c {
-                Ordering::Greater
-            } else {
-                Ordering::Equal
-            }
-        })
-        .is_ok()
-}
-
-/// Check if codepoint is a word character (alphanumeric)
-/// Note: Different from vim default behavior, we do not regard underscore as a word character!
-fn is_latin1_word_char(c: char) -> bool {
-    c.is_ascii_alphanumeric() || (matches!( c, | 'À'..='ÿ') && c != '×' && c != '÷')
-}
-
 pub fn is_blank(c: char) -> bool {
-    WordCharClass::from(c) == WordCharClass::Blank
+    WordCharClass::from_char(c) == WordCharClass::Blank
 }
 
-/// Reference: <https://github.com/vim/vim/blob/48940d94/src/mbyte.c#L2866-L2982>
-impl From<char> for WordCharClass {
-    fn from(c: char) -> Self {
+impl WordCharClass {
+    /// Reference: <https://github.com/vim/vim/blob/48940d94/src/mbyte.c#L2866-L2982>
+    pub fn from_char(c: char) -> Self {
         // Quick check for Latin1 characters
         if u32::from(c) < 0x100 {
             // newline
@@ -71,17 +49,7 @@
 
         // binary search in table
         CLASSES
-            .binary_search_by(|interval| {
-                let first = char::try_from(interval.first).unwrap();
-                let last = char::try_from(interval.last).unwrap();
-                if last < c {
-                    Ordering::Less
-                } else if first > c {
-                    Ordering::Greater
-                } else {
-                    Ordering::Equal
-                }
-            })
+            .binary_search_by(|interval| compare_range_to_char(&interval.range, c))
             .map_or(
                 // most other characters are "word" characters
                 WordCharClass::Word,
@@ -90,24 +58,42 @@
     }
 }
 
+/// Check if codepoint is a word character (alphanumeric)
+/// Note: Different from vim default behavior, we do not regard underscore as a word character!
+fn is_latin1_word_char(c: char) -> bool {
+    c.is_ascii_alphanumeric() || (matches!( c, | 'À'..='ÿ') && c != '×' && c != '÷')
+}
+
+fn compare_range_to_char(range: &RangeInclusive<char>, c: char) -> Ordering {
+    if *range.end() < c {
+        Ordering::Less
+    } else if *range.start() > c {
+        Ordering::Greater
+    } else {
+        Ordering::Equal
+    }
+}
+
+/// Check if codepoint is in emoji table using binary search (like vim's intable)
+fn is_emoji(c: char) -> bool {
+    EMOJI_ALL
+        .binary_search_by(|range| compare_range_to_char(range, c))
+        .is_ok()
+}
+
 /// Character class interval
 struct ClassInterval {
-    first: u32,
-    last: u32,
+    range: RangeInclusive<char>,
     class: WordCharClass,
 }
 
 impl ClassInterval {
-    const fn new(first: char, last: char, class: WordCharClass) -> Self {
-        Self {
-            first: first as u32,
-            last: last as u32,
-            class,
-        }
+    const fn new(range: RangeInclusive<char>, class: WordCharClass) -> Self {
+        Self { range, class }
     }
 
     const fn single(c: char, class: WordCharClass) -> Self {
-        Self::new(c, c, class)
+        Self::new(c..=c, class)
     }
 }
 
@@ -117,228 +103,228 @@
     use ClassInterval as I;
     use WordCharClass::*;
     &[
-        I::single('\u{037e}', Punctuation),          // Greek question mark
-        I::single('\u{0387}', Punctuation),          // Greek ano teleia
-        I::new('\u{055a}', '\u{055f}', Punctuation), // Armenian punctuation
-        I::single('\u{0589}', Punctuation),          // Armenian full stop
+        I::single('\u{037e}', Punctuation), // Greek question mark
+        I::single('\u{0387}', Punctuation), // Greek ano teleia
+        I::new('\u{055a}'..='\u{055f}', Punctuation), // Armenian punctuation
+        I::single('\u{0589}', Punctuation), // Armenian full stop
         I::single('\u{05be}', Punctuation),
         I::single('\u{05c0}', Punctuation),
         I::single('\u{05c3}', Punctuation),
-        I::new('\u{05f3}', '\u{05f4}', Punctuation),
+        I::new('\u{05f3}'..='\u{05f4}', Punctuation),
         I::single('\u{060c}', Punctuation),
         I::single('\u{061b}', Punctuation),
         I::single('\u{061f}', Punctuation),
-        I::new('\u{066a}', '\u{066d}', Punctuation),
+        I::new('\u{066a}'..='\u{066d}', Punctuation),
         I::single('\u{06d4}', Punctuation),
-        I::new('\u{0700}', '\u{070d}', Punctuation), // Syriac punctuation
-        I::new('\u{0964}', '\u{0965}', Punctuation),
+        I::new('\u{0700}'..='\u{070d}', Punctuation), // Syriac punctuation
+        I::new('\u{0964}'..='\u{0965}', Punctuation),
         I::single('\u{0970}', Punctuation),
         I::single('\u{0df4}', Punctuation),
         I::single('\u{0e4f}', Punctuation),
-        I::new('\u{0e5a}', '\u{0e5b}', Punctuation),
-        I::new('\u{0f04}', '\u{0f12}', Punctuation),
-        I::new('\u{0f3a}', '\u{0f3d}', Punctuation),
+        I::new('\u{0e5a}'..='\u{0e5b}', Punctuation),
+        I::new('\u{0f04}'..='\u{0f12}', Punctuation),
+        I::new('\u{0f3a}'..='\u{0f3d}', Punctuation),
         I::single('\u{0f85}', Punctuation),
-        I::new('\u{104a}', '\u{104f}', Punctuation), // Myanmar punctuation
-        I::single('\u{10fb}', Punctuation),          // Georgian punctuation
-        I::new('\u{1361}', '\u{1368}', Punctuation), // Ethiopic punctuation
-        I::new('\u{166d}', '\u{166e}', Punctuation), // Canadian Syl. punctuation
+        I::new('\u{104a}'..='\u{104f}', Punctuation), // Myanmar punctuation
+        I::single('\u{10fb}', Punctuation),           // Georgian punctuation
+        I::new('\u{1361}'..='\u{1368}', Punctuation), // Ethiopic punctuation
+        I::new('\u{166d}'..='\u{166e}', Punctuation), // Canadian Syl. punctuation
         I::single('\u{1680}', Blank),
-        I::new('\u{169b}', '\u{169c}', Punctuation),
-        I::new('\u{16eb}', '\u{16ed}', Punctuation),
-        I::new('\u{1735}', '\u{1736}', Punctuation),
-        I::new('\u{17d4}', '\u{17dc}', Punctuation), // Khmer punctuation
-        I::new('\u{1800}', '\u{180a}', Punctuation), // Mongolian punctuation
-        I::new('\u{2000}', '\u{200b}', Blank),       // spaces
-        I::new('\u{200c}', '\u{2027}', Punctuation), // punctuation and symbols
-        I::new('\u{2028}', '\u{2029}', Blank),
-        I::new('\u{202a}', '\u{202e}', Punctuation), // punctuation and symbols
+        I::new('\u{169b}'..='\u{169c}', Punctuation),
+        I::new('\u{16eb}'..='\u{16ed}', Punctuation),
+        I::new('\u{1735}'..='\u{1736}', Punctuation),
+        I::new('\u{17d4}'..='\u{17dc}', Punctuation), // Khmer punctuation
+        I::new('\u{1800}'..='\u{180a}', Punctuation), // Mongolian punctuation
+        I::new('\u{2000}'..='\u{200b}', Blank),       // spaces
+        I::new('\u{200c}'..='\u{2027}', Punctuation), // punctuation and symbols
+        I::new('\u{2028}'..='\u{2029}', Blank),
+        I::new('\u{202a}'..='\u{202e}', Punctuation), // punctuation and symbols
         I::single('\u{202f}', Blank),
-        I::new('\u{2030}', '\u{205e}', Punctuation), // punctuation and symbols
+        I::new('\u{2030}'..='\u{205e}', Punctuation), // punctuation and symbols
         I::single('\u{205f}', Blank),
-        I::new('\u{2060}', '\u{206f}', Punctuation), // punctuation and symbols
-        I::new('\u{2070}', '\u{207f}', Superscript),
-        I::new('\u{2080}', '\u{2094}', Subscript),
-        I::new('\u{20a0}', '\u{27ff}', Punctuation), // all kinds of symbols
-        I::new('\u{2800}', '\u{28ff}', Braille),
-        I::new('\u{2900}', '\u{2998}', Punctuation), // arrows, brackets, etc.
-        I::new('\u{29d8}', '\u{29db}', Punctuation),
-        I::new('\u{29fc}', '\u{29fd}', Punctuation),
-        I::new('\u{2e00}', '\u{2e7f}', Punctuation), // supplemental punctuation
-        I::single('\u{3000}', Blank),                // ideographic space
-        I::new('\u{3001}', '\u{3020}', Punctuation), // ideographic punctuation
+        I::new('\u{2060}'..='\u{206f}', Punctuation), // punctuation and symbols
+        I::new('\u{2070}'..='\u{207f}', Superscript),
+        I::new('\u{2080}'..='\u{2094}', Subscript),
+        I::new('\u{20a0}'..='\u{27ff}', Punctuation), // all kinds of symbols
+        I::new('\u{2800}'..='\u{28ff}', Braille),
+        I::new('\u{2900}'..='\u{2998}', Punctuation), // arrows, brackets, etc.
+        I::new('\u{29d8}'..='\u{29db}', Punctuation),
+        I::new('\u{29fc}'..='\u{29fd}', Punctuation),
+        I::new('\u{2e00}'..='\u{2e7f}', Punctuation), // supplemental punctuation
+        I::single('\u{3000}', Blank),                 // ideographic space
+        I::new('\u{3001}'..='\u{3020}', Punctuation), // ideographic punctuation
         I::single('\u{3030}', Punctuation),
         I::single('\u{303d}', Punctuation),
-        I::new('\u{3040}', '\u{309f}', Hiragana),
-        I::new('\u{30a0}', '\u{30ff}', Katakana),
-        I::new('\u{3300}', '\u{9fff}', Cjk),
-        I::new('\u{ac00}', '\u{d7a3}', Hangul),
-        I::new('\u{f900}', '\u{faff}', Cjk),
-        I::new('\u{fd3e}', '\u{fd3f}', Punctuation),
-        I::new('\u{fe30}', '\u{fe6b}', Punctuation), // punctuation forms
-        I::new('\u{ff00}', '\u{ff0f}', Punctuation), // half/fullwidth ASCII
-        I::new('\u{ff1a}', '\u{ff20}', Punctuation), // half/fullwidth ASCII
-        I::new('\u{ff3b}', '\u{ff40}', Punctuation), // half/fullwidth ASCII
-        I::new('\u{ff5b}', '\u{ff65}', Punctuation), // half/fullwidth ASCII
-        I::new('\u{1d000}', '\u{1d24f}', Punctuation), // Musical notation
-        I::new('\u{1d400}', '\u{1d7ff}', Punctuation), // Mathematical Alphanumeric Symbols
-        I::new('\u{1f000}', '\u{1f2ff}', Punctuation), // Game pieces; enclosed characters
-        I::new('\u{1f300}', '\u{1f9ff}', Punctuation), // Many symbol blocks
-        I::new('\u{20000}', '\u{2a6df}', Cjk),
-        I::new('\u{2a700}', '\u{2b73f}', Cjk),
-        I::new('\u{2b740}', '\u{2b81f}', Cjk),
-        I::new('\u{2f800}', '\u{2fa1f}', Cjk),
+        I::new('\u{3040}'..='\u{309f}', Hiragana),
+        I::new('\u{30a0}'..='\u{30ff}', Katakana),
+        I::new('\u{3300}'..='\u{9fff}', Cjk),
+        I::new('\u{ac00}'..='\u{d7a3}', Hangul),
+        I::new('\u{f900}'..='\u{faff}', Cjk),
+        I::new('\u{fd3e}'..='\u{fd3f}', Punctuation),
+        I::new('\u{fe30}'..='\u{fe6b}', Punctuation), // punctuation forms
+        I::new('\u{ff00}'..='\u{ff0f}', Punctuation), // half/fullwidth ASCII
+        I::new('\u{ff1a}'..='\u{ff20}', Punctuation), // half/fullwidth ASCII
+        I::new('\u{ff3b}'..='\u{ff40}', Punctuation), // half/fullwidth ASCII
+        I::new('\u{ff5b}'..='\u{ff65}', Punctuation), // half/fullwidth ASCII
+        I::new('\u{1d000}'..='\u{1d24f}', Punctuation), // Musical notation
+        I::new('\u{1d400}'..='\u{1d7ff}', Punctuation), // Mathematical Alphanumeric Symbols
+        I::new('\u{1f000}'..='\u{1f2ff}', Punctuation), // Game pieces; enclosed characters
+        I::new('\u{1f300}'..='\u{1f9ff}', Punctuation), // Many symbol blocks
+        I::new('\u{20000}'..='\u{2a6df}', Cjk),
+        I::new('\u{2a700}'..='\u{2b73f}', Cjk),
+        I::new('\u{2b740}'..='\u{2b81f}', Cjk),
+        I::new('\u{2f800}'..='\u{2fa1f}', Cjk),
     ]
 };
 
 /// Reference: <https://github.com/vim/vim/blob/48940d94/src/mbyte.c#L2704-L2852>
-static EMOJI_ALL: &[(u32, u32)] = &[
-    (0x203c, 0x203c),
-    (0x2049, 0x2049),
-    (0x2122, 0x2122),
-    (0x2139, 0x2139),
-    (0x2194, 0x2199),
-    (0x21a9, 0x21aa),
-    (0x231a, 0x231b),
-    (0x2328, 0x2328),
-    (0x23cf, 0x23cf),
-    (0x23e9, 0x23f3),
-    (0x23f8, 0x23fa),
-    (0x24c2, 0x24c2),
-    (0x25aa, 0x25ab),
-    (0x25b6, 0x25b6),
-    (0x25c0, 0x25c0),
-    (0x25fb, 0x25fe),
-    (0x2600, 0x2604),
-    (0x260e, 0x260e),
-    (0x2611, 0x2611),
-    (0x2614, 0x2615),
-    (0x2618, 0x2618),
-    (0x261d, 0x261d),
-    (0x2620, 0x2620),
-    (0x2622, 0x2623),
-    (0x2626, 0x2626),
-    (0x262a, 0x262a),
-    (0x262e, 0x262f),
-    (0x2638, 0x263a),
-    (0x2640, 0x2640),
-    (0x2642, 0x2642),
-    (0x2648, 0x2653),
-    (0x265f, 0x2660),
-    (0x2663, 0x2663),
-    (0x2665, 0x2666),
-    (0x2668, 0x2668),
-    (0x267b, 0x267b),
-    (0x267e, 0x267f),
-    (0x2692, 0x2697),
-    (0x2699, 0x2699),
-    (0x269b, 0x269c),
-    (0x26a0, 0x26a1),
-    (0x26a7, 0x26a7),
-    (0x26aa, 0x26ab),
-    (0x26b0, 0x26b1),
-    (0x26bd, 0x26be),
-    (0x26c4, 0x26c5),
-    (0x26c8, 0x26c8),
-    (0x26ce, 0x26cf),
-    (0x26d1, 0x26d1),
-    (0x26d3, 0x26d4),
-    (0x26e9, 0x26ea),
-    (0x26f0, 0x26f5),
-    (0x26f7, 0x26fa),
-    (0x26fd, 0x26fd),
-    (0x2702, 0x2702),
-    (0x2705, 0x2705),
-    (0x2708, 0x270d),
-    (0x270f, 0x270f),
-    (0x2712, 0x2712),
-    (0x2714, 0x2714),
-    (0x2716, 0x2716),
-    (0x271d, 0x271d),
-    (0x2721, 0x2721),
-    (0x2728, 0x2728),
-    (0x2733, 0x2734),
-    (0x2744, 0x2744),
-    (0x2747, 0x2747),
-    (0x274c, 0x274c),
-    (0x274e, 0x274e),
-    (0x2753, 0x2755),
-    (0x2757, 0x2757),
-    (0x2763, 0x2764),
-    (0x2795, 0x2797),
-    (0x27a1, 0x27a1),
-    (0x27b0, 0x27b0),
-    (0x27bf, 0x27bf),
-    (0x2934, 0x2935),
-    (0x2b05, 0x2b07),
-    (0x2b1b, 0x2b1c),
-    (0x2b50, 0x2b50),
-    (0x2b55, 0x2b55),
-    (0x3030, 0x3030),
-    (0x303d, 0x303d),
-    (0x3297, 0x3297),
-    (0x3299, 0x3299),
-    (0x1f004, 0x1f004),
-    (0x1f0cf, 0x1f0cf),
-    (0x1f170, 0x1f171),
-    (0x1f17e, 0x1f17f),
-    (0x1f18e, 0x1f18e),
-    (0x1f191, 0x1f19a),
-    (0x1f1e6, 0x1f1ff),
-    (0x1f201, 0x1f202),
-    (0x1f21a, 0x1f21a),
-    (0x1f22f, 0x1f22f),
-    (0x1f232, 0x1f23a),
-    (0x1f250, 0x1f251),
-    (0x1f300, 0x1f321),
-    (0x1f324, 0x1f393),
-    (0x1f396, 0x1f397),
-    (0x1f399, 0x1f39b),
-    (0x1f39e, 0x1f3f0),
-    (0x1f3f3, 0x1f3f5),
-    (0x1f3f7, 0x1f4fd),
-    (0x1f4ff, 0x1f53d),
-    (0x1f549, 0x1f54e),
-    (0x1f550, 0x1f567),
-    (0x1f56f, 0x1f570),
-    (0x1f573, 0x1f57a),
-    (0x1f587, 0x1f587),
-    (0x1f58a, 0x1f58d),
-    (0x1f590, 0x1f590),
-    (0x1f595, 0x1f596),
-    (0x1f5a4, 0x1f5a5),
-    (0x1f5a8, 0x1f5a8),
-    (0x1f5b1, 0x1f5b2),
-    (0x1f5bc, 0x1f5bc),
-    (0x1f5c2, 0x1f5c4),
-    (0x1f5d1, 0x1f5d3),
-    (0x1f5dc, 0x1f5de),
-    (0x1f5e1, 0x1f5e1),
-    (0x1f5e3, 0x1f5e3),
-    (0x1f5e8, 0x1f5e8),
-    (0x1f5ef, 0x1f5ef),
-    (0x1f5f3, 0x1f5f3),
-    (0x1f5fa, 0x1f64f),
-    (0x1f680, 0x1f6c5),
-    (0x1f6cb, 0x1f6d2),
-    (0x1f6d5, 0x1f6d7),
-    (0x1f6dc, 0x1f6e5),
-    (0x1f6e9, 0x1f6e9),
-    (0x1f6eb, 0x1f6ec),
-    (0x1f6f0, 0x1f6f0),
-    (0x1f6f3, 0x1f6fc),
-    (0x1f7e0, 0x1f7eb),
-    (0x1f7f0, 0x1f7f0),
-    (0x1f90c, 0x1f93a),
-    (0x1f93c, 0x1f945),
-    (0x1f947, 0x1f9ff),
-    (0x1fa70, 0x1fa7c),
-    (0x1fa80, 0x1fa88),
-    (0x1fa90, 0x1fabd),
-    (0x1fabf, 0x1fac5),
-    (0x1face, 0x1fadb),
-    (0x1fae0, 0x1fae8),
-    (0x1faf0, 0x1faf8),
+static EMOJI_ALL: &[RangeInclusive<char>] = &[
+    '\u{203c}'..='\u{203c}',
+    '\u{2049}'..='\u{2049}',
+    '\u{2122}'..='\u{2122}',
+    '\u{2139}'..='\u{2139}',
+    '\u{2194}'..='\u{2199}',
+    '\u{21a9}'..='\u{21aa}',
+    '\u{231a}'..='\u{231b}',
+    '\u{2328}'..='\u{2328}',
+    '\u{23cf}'..='\u{23cf}',
+    '\u{23e9}'..='\u{23f3}',
+    '\u{23f8}'..='\u{23fa}',
+    '\u{24c2}'..='\u{24c2}',
+    '\u{25aa}'..='\u{25ab}',
+    '\u{25b6}'..='\u{25b6}',
+    '\u{25c0}'..='\u{25c0}',
+    '\u{25fb}'..='\u{25fe}',
+    '\u{2600}'..='\u{2604}',
+    '\u{260e}'..='\u{260e}',
+    '\u{2611}'..='\u{2611}',
+    '\u{2614}'..='\u{2615}',
+    '\u{2618}'..='\u{2618}',
+    '\u{261d}'..='\u{261d}',
+    '\u{2620}'..='\u{2620}',
+    '\u{2622}'..='\u{2623}',
+    '\u{2626}'..='\u{2626}',
+    '\u{262a}'..='\u{262a}',
+    '\u{262e}'..='\u{262f}',
+    '\u{2638}'..='\u{263a}',
+    '\u{2640}'..='\u{2640}',
+    '\u{2642}'..='\u{2642}',
+    '\u{2648}'..='\u{2653}',
+    '\u{265f}'..='\u{2660}',
+    '\u{2663}'..='\u{2663}',
+    '\u{2665}'..='\u{2666}',
+    '\u{2668}'..='\u{2668}',
+    '\u{267b}'..='\u{267b}',
+    '\u{267e}'..='\u{267f}',
+    '\u{2692}'..='\u{2697}',
+    '\u{2699}'..='\u{2699}',
+    '\u{269b}'..='\u{269c}',
+    '\u{26a0}'..='\u{26a1}',
+    '\u{26a7}'..='\u{26a7}',
+    '\u{26aa}'..='\u{26ab}',
+    '\u{26b0}'..='\u{26b1}',
+    '\u{26bd}'..='\u{26be}',
+    '\u{26c4}'..='\u{26c5}',
+    '\u{26c8}'..='\u{26c8}',
+    '\u{26ce}'..='\u{26cf}',
+    '\u{26d1}'..='\u{26d1}',
+    '\u{26d3}'..='\u{26d4}',
+    '\u{26e9}'..='\u{26ea}',
+    '\u{26f0}'..='\u{26f5}',
+    '\u{26f7}'..='\u{26fa}',
+    '\u{26fd}'..='\u{26fd}',
+    '\u{2702}'..='\u{2702}',
+    '\u{2705}'..='\u{2705}',
+    '\u{2708}'..='\u{270d}',
+    '\u{270f}'..='\u{270f}',
+    '\u{2712}'..='\u{2712}',
+    '\u{2714}'..='\u{2714}',
+    '\u{2716}'..='\u{2716}',
+    '\u{271d}'..='\u{271d}',
+    '\u{2721}'..='\u{2721}',
+    '\u{2728}'..='\u{2728}',
+    '\u{2733}'..='\u{2734}',
+    '\u{2744}'..='\u{2744}',
+    '\u{2747}'..='\u{2747}',
+    '\u{274c}'..='\u{274c}',
+    '\u{274e}'..='\u{274e}',
+    '\u{2753}'..='\u{2755}',
+    '\u{2757}'..='\u{2757}',
+    '\u{2763}'..='\u{2764}',
+    '\u{2795}'..='\u{2797}',
+    '\u{27a1}'..='\u{27a1}',
+    '\u{27b0}'..='\u{27b0}',
+    '\u{27bf}'..='\u{27bf}',
+    '\u{2934}'..='\u{2935}',
+    '\u{2b05}'..='\u{2b07}',
+    '\u{2b1b}'..='\u{2b1c}',
+    '\u{2b50}'..='\u{2b50}',
+    '\u{2b55}'..='\u{2b55}',
+    '\u{3030}'..='\u{3030}',
+    '\u{303d}'..='\u{303d}',
+    '\u{3297}'..='\u{3297}',
+    '\u{3299}'..='\u{3299}',
+    '\u{1f004}'..='\u{1f004}',
+    '\u{1f0cf}'..='\u{1f0cf}',
+    '\u{1f170}'..='\u{1f171}',
+    '\u{1f17e}'..='\u{1f17f}',
+    '\u{1f18e}'..='\u{1f18e}',
+    '\u{1f191}'..='\u{1f19a}',
+    '\u{1f1e6}'..='\u{1f1ff}',
+    '\u{1f201}'..='\u{1f202}',
+    '\u{1f21a}'..='\u{1f21a}',
+    '\u{1f22f}'..='\u{1f22f}',
+    '\u{1f232}'..='\u{1f23a}',
+    '\u{1f250}'..='\u{1f251}',
+    '\u{1f300}'..='\u{1f321}',
+    '\u{1f324}'..='\u{1f393}',
+    '\u{1f396}'..='\u{1f397}',
+    '\u{1f399}'..='\u{1f39b}',
+    '\u{1f39e}'..='\u{1f3f0}',
+    '\u{1f3f3}'..='\u{1f3f5}',
+    '\u{1f3f7}'..='\u{1f4fd}',
+    '\u{1f4ff}'..='\u{1f53d}',
+    '\u{1f549}'..='\u{1f54e}',
+    '\u{1f550}'..='\u{1f567}',
+    '\u{1f56f}'..='\u{1f570}',
+    '\u{1f573}'..='\u{1f57a}',
+    '\u{1f587}'..='\u{1f587}',
+    '\u{1f58a}'..='\u{1f58d}',
+    '\u{1f590}'..='\u{1f590}',
+    '\u{1f595}'..='\u{1f596}',
+    '\u{1f5a4}'..='\u{1f5a5}',
+    '\u{1f5a8}'..='\u{1f5a8}',
+    '\u{1f5b1}'..='\u{1f5b2}',
+    '\u{1f5bc}'..='\u{1f5bc}',
+    '\u{1f5c2}'..='\u{1f5c4}',
+    '\u{1f5d1}'..='\u{1f5d3}',
+    '\u{1f5dc}'..='\u{1f5de}',
+    '\u{1f5e1}'..='\u{1f5e1}',
+    '\u{1f5e3}'..='\u{1f5e3}',
+    '\u{1f5e8}'..='\u{1f5e8}',
+    '\u{1f5ef}'..='\u{1f5ef}',
+    '\u{1f5f3}'..='\u{1f5f3}',
+    '\u{1f5fa}'..='\u{1f64f}',
+    '\u{1f680}'..='\u{1f6c5}',
+    '\u{1f6cb}'..='\u{1f6d2}',
+    '\u{1f6d5}'..='\u{1f6d7}',
+    '\u{1f6dc}'..='\u{1f6e5}',
+    '\u{1f6e9}'..='\u{1f6e9}',
+    '\u{1f6eb}'..='\u{1f6ec}',
+    '\u{1f6f0}'..='\u{1f6f0}',
+    '\u{1f6f3}'..='\u{1f6fc}',
+    '\u{1f7e0}'..='\u{1f7eb}',
+    '\u{1f7f0}'..='\u{1f7f0}',
+    '\u{1f90c}'..='\u{1f93a}',
+    '\u{1f93c}'..='\u{1f945}',
+    '\u{1f947}'..='\u{1f9ff}',
+    '\u{1fa70}'..='\u{1fa7c}',
+    '\u{1fa80}'..='\u{1fa88}',
+    '\u{1fa90}'..='\u{1fabd}',
+    '\u{1fabf}'..='\u{1fac5}',
+    '\u{1face}'..='\u{1fadb}',
+    '\u{1fae0}'..='\u{1fae8}',
+    '\u{1faf0}'..='\u{1faf8}',
 ];
 
 #[cfg(test)]
@@ -347,59 +333,59 @@
 
     #[test]
     fn test_blank() {
-        assert_eq!(WordCharClass::from(' '), WordCharClass::Blank); // space
-        assert_eq!(WordCharClass::from('\t'), WordCharClass::Blank); // tab
-        assert_eq!(WordCharClass::from('\0'), WordCharClass::Blank); // NUL
-        assert_eq!(WordCharClass::from('\u{a0}'), WordCharClass::Blank); // non-breaking space
-        assert_eq!(WordCharClass::from('\u{3000}'), WordCharClass::Blank); // ideographic space
+        assert_eq!(WordCharClass::from_char(' '), WordCharClass::Blank); // space
+        assert_eq!(WordCharClass::from_char('\t'), WordCharClass::Blank); // tab
+        assert_eq!(WordCharClass::from_char('\0'), WordCharClass::Blank); // NUL
+        assert_eq!(WordCharClass::from_char('\u{a0}'), WordCharClass::Blank); // non-breaking space
+        assert_eq!(WordCharClass::from_char('\u{3000}'), WordCharClass::Blank); // ideographic space
     }
 
     #[test]
     fn test_word() {
-        assert_eq!(WordCharClass::from('a'), WordCharClass::Word); // 'a'
-        assert_eq!(WordCharClass::from('Z'), WordCharClass::Word); // 'Z'
-        assert_eq!(WordCharClass::from('0'), WordCharClass::Word); // '0'
-        assert_eq!(WordCharClass::from('e'), WordCharClass::Word); // 'e' with acute
+        assert_eq!(WordCharClass::from_char('a'), WordCharClass::Word); // 'a'
+        assert_eq!(WordCharClass::from_char('Z'), WordCharClass::Word); // 'Z'
+        assert_eq!(WordCharClass::from_char('0'), WordCharClass::Word); // '0'
+        assert_eq!(WordCharClass::from_char('e'), WordCharClass::Word); // 'e' with acute
     }
 
     #[test]
     fn test_punctuation() {
-        assert_eq!(WordCharClass::from('.'), WordCharClass::Punctuation);
-        assert_eq!(WordCharClass::from(','), WordCharClass::Punctuation);
-        assert_eq!(WordCharClass::from(';'), WordCharClass::Punctuation);
-        assert_eq!(WordCharClass::from(','), WordCharClass::Punctuation); // ideographic comma
-        assert_eq!(WordCharClass::from('_'), WordCharClass::Punctuation);
+        assert_eq!(WordCharClass::from_char('.'), WordCharClass::Punctuation);
+        assert_eq!(WordCharClass::from_char(','), WordCharClass::Punctuation);
+        assert_eq!(WordCharClass::from_char(';'), WordCharClass::Punctuation);
+        assert_eq!(WordCharClass::from_char(','), WordCharClass::Punctuation); // ideographic comma
+        assert_eq!(WordCharClass::from_char('_'), WordCharClass::Punctuation);
     }
 
     #[test]
     fn test_cjk() {
-        assert_eq!(WordCharClass::from('中'), WordCharClass::Cjk); // CJK character
-        assert_eq!(WordCharClass::from('𠀀'), WordCharClass::Cjk); // CJK Extension B
+        assert_eq!(WordCharClass::from_char('中'), WordCharClass::Cjk); // CJK character
+        assert_eq!(WordCharClass::from_char('𠀀'), WordCharClass::Cjk); // CJK Extension B
     }
 
     #[test]
     fn test_japanese() {
-        assert_eq!(WordCharClass::from('あ'), WordCharClass::Hiragana);
-        assert_eq!(WordCharClass::from('ア'), WordCharClass::Katakana);
+        assert_eq!(WordCharClass::from_char('あ'), WordCharClass::Hiragana);
+        assert_eq!(WordCharClass::from_char('ア'), WordCharClass::Katakana);
     }
 
     #[test]
     fn test_hangul() {
-        assert_eq!(WordCharClass::from('한'), WordCharClass::Hangul);
+        assert_eq!(WordCharClass::from_char('한'), WordCharClass::Hangul);
     }
 
     #[test]
     fn test_emoji() {
-        assert_eq!(WordCharClass::from('😀'), WordCharClass::Emoji); // grinning face
-        assert_eq!(WordCharClass::from('🚀'), WordCharClass::Emoji); // rocket
-        assert_eq!(WordCharClass::from('❤'), WordCharClass::Emoji); // red heart
-        assert_eq!(WordCharClass::from('✅'), WordCharClass::Emoji); // check mark
+        assert_eq!(WordCharClass::from_char('😀'), WordCharClass::Emoji); // grinning face
+        assert_eq!(WordCharClass::from_char('🚀'), WordCharClass::Emoji); // rocket
+        assert_eq!(WordCharClass::from_char('❤'), WordCharClass::Emoji); // red heart
+        assert_eq!(WordCharClass::from_char('✅'), WordCharClass::Emoji); // check mark
     }
 
     #[test]
     fn test_special() {
-        assert_eq!(WordCharClass::from('⁰'), WordCharClass::Superscript); // superscript zero
-        assert_eq!(WordCharClass::from('₀'), WordCharClass::Subscript); // subscript zero
-        assert_eq!(WordCharClass::from('\u{2800}'), WordCharClass::Braille); // braille blank
+        assert_eq!(WordCharClass::from_char('⁰'), WordCharClass::Superscript); // superscript zero
+        assert_eq!(WordCharClass::from_char('₀'), WordCharClass::Subscript); // subscript zero
+        assert_eq!(WordCharClass::from_char('\u{2800}'), WordCharClass::Braille); // braille blank
     }
 }
diff --git a/src/reader/reader.rs b/src/reader/reader.rs
index 61a2daf60b..23fcbe2328 100644
--- a/src/reader/reader.rs
+++ b/src/reader/reader.rs
@@ -3584,9 +3584,10 @@
 
                 // if at word end, forward/kill char, otherwise forward/kill word end
                 if self.is_at_autosuggestion() {
-                    self.accept_autosuggestion(AutosuggestionPortion::PerMoveWordStyle(
-                        style, true,
-                    ));
+                    self.accept_autosuggestion(AutosuggestionPortion::PerMoveWordStyle {
+                        style,
+                        to_word_end: true,
+                    });
                 } else if is_word_end {
                     if is_kill {
                         self.delete_char(/*backward*/ false);
@@ -3790,10 +3791,10 @@
                 let to_word_end = matches!(c, rl::ForwardWordEnd | rl::ForwardBigwordEnd);
 
                 if self.is_at_autosuggestion() {
-                    self.accept_autosuggestion(AutosuggestionPortion::PerMoveWordStyle(
+                    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(
@@ -5414,7 +5415,10 @@
 enum AutosuggestionPortion {
     Count(usize),
     Line,
-    PerMoveWordStyle(MoveWordStyle, /* to word end */ bool),
+    PerMoveWordStyle {
+        style: MoveWordStyle,
+        to_word_end: bool,
+    },
 }
 
 impl<'a> Reader<'a> {
@@ -5595,7 +5599,7 @@
                     suggested[..line_end].to_owned(),
                 )
             }
-            AutosuggestionPortion::PerMoveWordStyle(style, to_word_end) => {
+            AutosuggestionPortion::PerMoveWordStyle { style, to_word_end } => {
                 // Accept characters according to the specified style.
 
                 let state_machine_dir = if to_word_end {
diff --git a/src/tokenizer.rs b/src/tokenizer.rs
index 32e337c74c..d3f244b82e 100644
--- a/src/tokenizer.rs
+++ b/src/tokenizer.rs
@@ -1252,7 +1252,7 @@
             [(S_END, false),  (S_END, false), (S_END, false)], // S_END
         ];
 
-        let (new_state, result) = TRANSITION_TABLE[self.state as usize][transition_idx];
+        let (new_state, result) = TRANSITION_TABLE[usize::from(self.state)][transition_idx];
         self.state = new_state;
         result
     }
@@ -1294,7 +1294,7 @@
             [(S_END, true),   (S_WORD, true),     (S_PUNC, true)],     // S_END
         ];
 
-        let (new_state, result) = TRANSITION_TABLE[self.state as usize][transition_idx];
+        let (new_state, result) = TRANSITION_TABLE[usize::from(self.state)][transition_idx];
         self.state = new_state;
         result
     }
@@ -1313,7 +1313,7 @@
                 WordCharClass::Word
             }
         } else {
-            WordCharClass::from(c)
+            WordCharClass::from_char(c)
         };
         let transition_idx = if cur_cls == WordCharClass::Blank {
             0
@@ -1347,7 +1347,7 @@
             MoveWordDir::Right => FORWARD_TABLE,
             MoveWordDir::Left => BACKWARD_TABLE,
         };
-        let (new_state, result) = table[self.state as usize][transition_idx];
+        let (new_state, result) = table[usize::from(self.state)][transition_idx];
         self.state = new_state;
         self.last_char_cls = Some(cur_cls);
         result
@@ -1587,10 +1587,7 @@
     /// Test word motion (forward-word, etc.). Carets represent cursor stops.
     #[test]
     fn test_word_motion() {
-        fn validate_helper(
-            direction: MoveWordDir,
-            line: &str,
-        ) -> (WString, VecDeque<usize>, usize, usize) {
+        fn setup(direction: MoveWordDir, line: &str) -> (WString, VecDeque<usize>, usize, usize) {
             let mut command = WString::new();
             let mut stops = VecDeque::new();
 
@@ -1621,7 +1618,7 @@
         macro_rules! validate {
              ($direction:expr, $style:expr, $line:expr) => {
                 let direction = $direction;
-                let (command, mut stops, mut idx, end) = validate_helper(direction, $line);
+                let (command, mut stops, mut idx, end) = setup(direction, $line);
                 assert!(!command.is_empty());
                 let mut sm = MoveWordStateMachine::new($style, direction);
                 while idx != end {

}

/// Check if codepoint is in emoji table using binary search (like vim's intable)
fn is_emoji(c: char) -> bool {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm moving the public functions to the top of the file


/// Reference: <https://github.com/vim/vim/blob/48940d94/src/mbyte.c#L2866-L2982>
impl From<char> for WordCharClass {
fn from(c: char) -> Self {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

my bad -- I think we're not supposed to implement From here because this is a one-way conversion, i.e. it's not value-preserving.
Changing to from_char.

CLASSES
.binary_search_by(|interval| {
let first = char::try_from(interval.first).unwrap();
let last = char::try_from(interval.last).unwrap();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removing this conversion, it's no longer needed (if we change everything to char)

Ordering::Greater
} else {
Ordering::Equal
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

extracting a function for the comparison

/// Character class interval
struct ClassInterval {
first: u32,
last: u32,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

changing this to RangeInclusive<char>

bind -s --preset E forward-bigword-end

__fish_per_os_bind --preset $argv ctrl-right forward-token forward-word-vi
# ctrl-left is same as emacs mode

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FWIW we could make Vi-mode more faithful by making ctrl-{left,right} do {backward-word,forward-word} and never token movements.
I think the token movements are nice when using Emacs style but I don't really have an opinion on having them in Vi mode. Happy to hear suggestions.

Comment thread src/reader/reader.rs
// if at word end, forward/kill char, otherwise forward/kill word end
if self.is_at_autosuggestion() {
self.accept_autosuggestion(AutosuggestionPortion::PerMoveWordStyle(
style, true,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

enums support named fields, so I'm changing the type to

enum AutosuggestionPortion {
    Count(usize),
    Line,
    PerMoveWordStyle{style: MoveWordStyle, to_word_end: bool},
}

Comment thread src/tokenizer.rs
#[rustfmt::skip]
const FORWARD_TABLE: &[[(u8, bool); 4]] = &[
// space, newline, same-class, different-class
[(S_SPACE, true), (S_NEWLINE, true), (S_WORD, true), (S_WORD, true)], // S_INIT

@krobelus krobelus Jan 10, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some of the state transitions are never used.
For example, when state == S_INIT, same-class is impossible,
so I'd rather change the types to remove the need to specify them, or at least an assertion that communicates the fact.

Looks like the root of the problem is that

pub struct MoveWordStateMachine {
    state: u8,
    style: MoveWordStyle,
    direction: MoveWordDir,
    last_char_cls: Option<WordCharClass>,
}

allows to represent the impossible state state: 0, last_char_cls: Some(_).

Also, state is mostly redundant when we have last_char_cls,
since in this case state encodes a subset of last_char_cls (and initial/final state).

Also consume_char_path_component_backward::TRANSITION_TABLE has some weird state transitions such as (S_END, true) (that are never hit).

The fact that state is polymorphic without type-safety is an existing code smell.
Here's my attempt at cleaning all of this up.
Using match statements instead of raw table lookups brings some nice simplifications.
I also moved this to its own module, since the dependency on tokenizer is very minimal.
(I'll put the code movement into a separate commit tomorrow)

Details
diff --git a/src/reader/mod.rs b/src/reader/mod.rs
index afd1c30be3..551ba9a254 100644
--- a/src/reader/mod.rs
+++ b/src/reader/mod.rs
@@ -4,5 +4,6 @@
 pub mod iothreads;
 #[allow(clippy::module_inception)]
 pub mod reader;
+mod word_motion;
 
 pub use reader::*;
diff --git a/src/reader/reader.rs b/src/reader/reader.rs
index 23fcbe2328..498502728c 100644
--- a/src/reader/reader.rs
+++ b/src/reader/reader.rs
@@ -49,6 +49,7 @@
 
 use super::history_search::{ReaderHistorySearch, SearchMode, smartcase_flags};
 use super::iothreads::{self, Debouncers};
+use super::word_motion::{MoveWordDir, MoveWordStyle, move_word_state_machine};
 use crate::abbrs::abbrs_match;
 use crate::ast::{self, Kind, is_same_node};
 use crate::builtins::shared::ErrorCode;
@@ -143,8 +144,7 @@
 use crate::tokenizer::quote_end;
 use crate::tokenizer::variable_assignment_equals_pos;
 use crate::tokenizer::{
-    MoveWordDir, MoveWordStateMachine, MoveWordStyle, TOK_ACCEPT_UNFINISHED, TOK_SHOW_COMMENTS,
-    TokenType, Tokenizer, tok_command,
+    TOK_ACCEPT_UNFINISHED, TOK_SHOW_COMMENTS, TokenType, Tokenizer, tok_command,
 };
 use crate::tty_handoff::SCROLL_CONTENT_UP_TERMINFO_CODE;
 use crate::tty_handoff::XTGETTCAP_QUERY_OS_NAME;
@@ -2207,7 +2207,7 @@
         }
 
         // When moving left, a value of 1 means the character at index 0.
-        let mut state = MoveWordStateMachine::new(style, state_machine_dir);
+        let mut sm = move_word_state_machine(style, state_machine_dir);
         let start_buff_pos = el.position();
 
         let mut buff_pos = el.position();
@@ -2231,7 +2231,7 @@
             } else {
                 buff_pos.wrapping_sub(1)
             };
-            let consumed = state.consume_char(el.text(), char_pos);
+            let consumed = sm(el.text(), char_pos);
             if consumed {
                 buff_pos = if move_right {
                     buff_pos + 1
@@ -5607,11 +5607,11 @@
                 } else {
                     MoveWordDir::Right
                 };
-                let mut state = MoveWordStateMachine::new(style, state_machine_dir);
+                let mut sm = move_word_state_machine(style, state_machine_dir);
                 let have = search_string_range.len();
                 let mut want = have;
                 while want < autosuggestion_text.len() {
-                    let consumed = state.consume_char(autosuggestion_text, want);
+                    let consumed = sm(autosuggestion_text, want);
                     if consumed {
                         want += 1;
                     } else {
diff --git a/src/reader/word_motion.rs b/src/reader/word_motion.rs
new file mode 100644
index 0000000000..4aa91e2cee
--- /dev/null
+++ b/src/reader/word_motion.rs
@@ -0,0 +1,438 @@
+use std::ops::ControlFlow;
+
+use crate::prelude::*;
+use crate::tokenizer::tok_is_string_character;
+use fish_wchar::word_char::{WordCharClass, is_blank};
+
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub enum MoveWordStyle {
+    /// stop at punctuation
+    Punctuation,
+    /// stops at path components
+    PathComponents,
+    /// stops at whitespace
+    Whitespace,
+}
+
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub enum MoveWordDir {
+    Left,
+    Right,
+}
+
+type StateMachine = dyn FnMut(&wstr, usize) -> bool;
+
+pub fn move_word_state_machine(style: MoveWordStyle, direction: MoveWordDir) -> Box<StateMachine> {
+    use MoveWordStyle as Style;
+    match style {
+        Style::Punctuation => Box::new(state_machine(move |state, text, idx| {
+            let c = text.as_char_slice()[idx];
+            consume_char_word_movement(state, false, direction, c)
+        })),
+        Style::PathComponents => match direction {
+            MoveWordDir::Left => Box::new(state_machine(move |state, text, idx| {
+                let ControlFlow::Continue(transition) = path_component_state_transition(text, idx)
+                else {
+                    return true;
+                };
+                consume_char_path_component_backward(state, transition)
+            })),
+            MoveWordDir::Right => Box::new(state_machine(move |state, text, idx| {
+                let ControlFlow::Continue(transition) = path_component_state_transition(text, idx)
+                else {
+                    return true;
+                };
+                consume_char_path_component_forward(state, transition)
+            })),
+        },
+        Style::Whitespace => Box::new(state_machine(move |state, text, idx| {
+            let c = text.as_char_slice()[idx];
+            consume_char_word_movement(state, true, direction, c)
+        })),
+    }
+}
+
+enum State<T> {
+    Initial,
+    Live(T),
+    Final,
+}
+impl<T> State<T> {
+    fn is_final(&self) -> bool {
+        matches!(self, State::Final)
+    }
+}
+
+// TODO(MSRV=?) Define trait alias to extract return type.
+fn state_machine<T>(
+    mut consumer: impl FnMut(&mut State<T>, &wstr, usize) -> bool,
+) -> impl FnMut(&wstr, usize) -> bool {
+    let mut state = State::Initial;
+    move |text: &wstr, idx: usize| {
+        assert!(!state.is_final());
+        consumer(&mut state, text, idx)
+    }
+}
+
+struct WordMovementState {
+    last_char_class: WordCharClass,
+}
+fn consume_char_word_movement(
+    state: &mut State<WordMovementState>,
+    is_bigword: bool,
+    direction: MoveWordDir,
+    c: char,
+) -> bool {
+    let cur_class = if is_bigword {
+        if c == '\n' {
+            WordCharClass::Newline
+        } else if is_blank(c) {
+            WordCharClass::Blank
+        } else {
+            WordCharClass::Word
+        }
+    } else {
+        WordCharClass::from_char(c)
+    };
+    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 matches!(
+        *state, State::Live(WordMovementState { last_char_class }) if last_char_class == cur_class
+    ) {
+        T::SameClass
+    } else {
+        T::DifferentClass
+    };
+
+    use State as S;
+    let finalize = match *state {
+        S::Initial => false,
+        S::Live(WordMovementState { last_char_class }) => 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),
+            },
+        },
+        S::Final => unreachable!(),
+    };
+    if finalize {
+        return to_final(state);
+    }
+
+    *state = S::Live(WordMovementState {
+        last_char_class: cur_class,
+    });
+    true
+}
+
+enum PathComponentTransition {
+    Blank,
+    PathComponent,
+    Punctuation,
+}
+
+fn path_component_state_transition(
+    text: &wstr,
+    idx: usize,
+) -> ControlFlow<(), PathComponentTransition> {
+    let c = text.as_char_slice()[idx];
+    let mut escaped: bool = false;
+    for j in (0..idx).rev() {
+        if text.as_char_slice()[j] == '\\' {
+            escaped = !escaped;
+        } else {
+            break;
+        }
+    }
+
+    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)
+}
+
+fn to_final<T>(state: &mut State<T>) -> bool {
+    *state = State::Final;
+    false
+}
+
+enum PathComponentForwardState {
+    PathComponent,
+    Punctuation,
+    Blank,
+}
+fn consume_char_path_component_forward(
+    state: &mut State<PathComponentForwardState>,
+    transition: PathComponentTransition,
+) -> bool {
+    // Forward path component movement: skip current homogeneous component, stop at next.
+    // Each component type (word, slash, punctuation, whitespace) is a unit.
+
+    use {PathComponentForwardState as LS, PathComponentTransition as T, State as S};
+
+    *state = S::Live(match *state {
+        S::Initial | S::Live(LS::PathComponent) => match transition {
+            T::Blank => LS::Blank,
+            T::PathComponent => LS::PathComponent,
+            T::Punctuation => LS::Punctuation,
+        },
+        S::Live(LS::Punctuation) => match transition {
+            T::Blank => LS::Blank,
+            T::PathComponent => return to_final(state),
+            T::Punctuation => LS::Punctuation,
+        },
+        S::Live(LS::Blank) => match transition {
+            T::Blank => LS::Blank,
+            T::PathComponent => return to_final(state),
+            T::Punctuation => return to_final(state),
+        },
+        S::Final => unreachable!(),
+    });
+    true
+}
+
+enum PathComponentBackwardState {
+    Punctuation,
+    PathComponent,
+    Space,
+    EndPathComponent,
+    EndPunctuation,
+}
+fn consume_char_path_component_backward(
+    state: &mut State<PathComponentBackwardState>,
+    transition: PathComponentTransition,
+) -> bool {
+    // a path component contains either
+    // - [space +] end word
+    // - punc + end word
+    // - space + end punc
+
+    use {PathComponentBackwardState as LS, PathComponentTransition as T, State as S};
+
+    *state = S::Live(match state {
+        S::Initial => match transition {
+            T::Blank => LS::Space,
+            T::PathComponent => LS::PathComponent,
+            T::Punctuation => LS::Punctuation,
+        },
+        S::Live(ls) => match ls {
+            LS::Punctuation => match transition {
+                T::Blank => return to_final(state),
+                T::PathComponent => LS::EndPathComponent,
+                T::Punctuation => LS::Punctuation,
+            },
+            LS::PathComponent => match transition {
+                T::Blank => return to_final(state),
+                T::PathComponent => LS::EndPathComponent,
+                T::Punctuation => return to_final(state),
+            },
+            LS::Space => match transition {
+                T::Blank => LS::Space,
+                T::PathComponent => LS::PathComponent,
+                T::Punctuation => LS::EndPunctuation,
+            },
+            LS::EndPathComponent => match transition {
+                T::Blank => return to_final(state),
+                T::PathComponent => LS::EndPathComponent,
+                T::Punctuation => return to_final(state),
+            },
+            LS::EndPunctuation => match transition {
+                T::Blank => return to_final(state),
+                T::PathComponent => return to_final(state),
+                T::Punctuation => LS::EndPunctuation,
+            },
+        },
+        S::Final => unreachable!(),
+    });
+
+    true
+}
+
+#[cfg(test)]
+mod tests {
+    use super::{MoveWordDir, MoveWordStyle, move_word_state_machine};
+    use crate::prelude::*;
+    use std::collections::VecDeque;
+
+    #[test]
+    fn test_word_motion() {
+        fn setup(direction: MoveWordDir, line: &str) -> (WString, VecDeque<usize>, usize, usize) {
+            let mut command = WString::new();
+            let mut stops = VecDeque::new();
+
+            // Carets represent stops and should be cut out of the command.
+            for c in line.chars() {
+                if c == '^' {
+                    if direction == MoveWordDir::Left {
+                        stops.push_front(command.len());
+                    } else {
+                        stops.push_back(command.len());
+                    }
+                } else {
+                    command.push(c);
+                }
+            }
+            stops.pop_back();
+
+            let idx = stops.pop_front().unwrap();
+            let end = if direction == MoveWordDir::Left {
+                0
+            } else {
+                command.len()
+            };
+
+            (command, stops, idx, end)
+        }
+
+        macro_rules! validate {
+             ($direction:expr, $style:expr, $line:expr) => {
+                let direction = $direction;
+                let (command, mut stops, mut idx, end) = setup(direction, $line);
+                assert!(!command.is_empty());
+                let mut sm = move_word_state_machine($style, direction);
+                while idx != end {
+                    let word_idx = if direction == MoveWordDir::Left {
+                        idx - 1
+                    } else {
+                        idx
+                    };
+                    let consumed = sm(&command, word_idx);
+                    if consumed {
+                        idx = if direction == MoveWordDir::Left {
+                            idx - 1
+                        } else {
+                            idx + 1
+                        };
+                    } else {
+                        assert!(
+                            !stops.is_empty(),
+                            "unexpected stop at {idx}. String: {command:?}"
+                        );
+                        let expected_idx = stops.front().unwrap();
+                        assert_eq!(
+                            idx, *expected_idx,
+                            "Expected to stop={expected_idx} but stopped at {idx}. String: {command:?}"
+                        );
+                        stops.pop_front();
+                        sm = move_word_state_machine($style, direction);
+                    }
+                }
+                assert!(
+                    stops.is_empty(),
+                    "expected to stop at {stops:?} but not. String: {command:?}"
+                );
+             }
+        }
+
+        use MoveWordDir::*;
+        use MoveWordStyle::*;
+
+        // PathComponents tests
+        validate!(
+            Left,
+            PathComponents,
+            "^echo ^/^foo/^bar{^aaa,^bbb,^ccc}^bak/^"
+        );
+        validate!(Left, PathComponents, "^echo ^bak ^///^");
+        validate!(Left, PathComponents, "^aaa ^@ ^@^aaa^");
+        validate!(Left, PathComponents, "^aaa ^a ^@^aaa^");
+        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!(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!(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 ^foo^__^foo^_^foo^//    ^");
+        validate!(Right, Punctuation, "^echo ^foo^__^foo^_^foo^//^");
+
+        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/src/tokenizer.rs b/src/tokenizer.rs
index d3f244b82e..b4990a5d8f 100644
--- a/src/tokenizer.rs
+++ b/src/tokenizer.rs
@@ -8,7 +8,6 @@
 use crate::parser_keywords::parser_keywords_is_subcommand;
 use crate::prelude::*;
 use crate::redirection::RedirectionMode;
-use fish_wchar::word_char::{WordCharClass, is_blank};
 use libc::{STDIN_FILENO, STDOUT_FILENO};
 use nix::fcntl::OFlag;
 use std::ops::{BitAnd, BitAndAssign, BitOr, BitOrAssign, Not, Range};
@@ -103,30 +102,6 @@
     pub consumed: usize,
 }
 
-#[derive(Clone, Copy, PartialEq, Eq)]
-pub enum MoveWordStyle {
-    /// stop at punctuation
-    Punctuation,
-    /// stops at path components
-    PathComponents,
-    /// stops at whitespace
-    Whitespace,
-}
-
-#[derive(Clone, Copy, PartialEq, Eq)]
-pub enum MoveWordDir {
-    Left,
-    Right,
-}
-
-/// Our state machine that implements "one word" movement or erasure.
-pub struct MoveWordStateMachine {
-    state: u8,
-    style: MoveWordStyle,
-    direction: MoveWordDir,
-    last_char_cls: Option<WordCharClass>,
-}
-
 #[derive(Clone, Copy)]
 pub struct TokFlags(pub u8);
 
@@ -902,7 +877,7 @@
 
 /// Tests if this character can be a part of a string. Hash (#) starts a comment if it's the first
 /// character in a token; otherwise it is considered a string character. See issue #953.
-fn tok_is_string_character(c: char, next: Option<char>) -> bool {
+pub fn tok_is_string_character(c: char, next: Option<char>) -> bool {
     match c {
         // Unconditional separators.
         '\0' | ' ' | '\n' | '|' | '\t' | ';' | '\r' | '<' | '>' => false,
@@ -1183,181 +1158,6 @@
     s.parse().unwrap_or(-1)
 }
 
-const S_INIT: u8 = 0;
-
-impl MoveWordStateMachine {
-    pub fn new(style: MoveWordStyle, direction: MoveWordDir) -> Self {
-        MoveWordStateMachine {
-            state: S_INIT,
-            last_char_cls: None,
-            style,
-            direction,
-        }
-    }
-
-    pub fn consume_char(&mut self, text: &wstr, idx: usize) -> bool {
-        let c = text.as_char_slice()[idx];
-        match self.style {
-            MoveWordStyle::Punctuation => self.consume_char_word_movement(c, false),
-            MoveWordStyle::PathComponents => {
-                let mut escaped: bool = false;
-                for j in (0..idx).rev() {
-                    if text.as_char_slice()[j] == '\\' {
-                        escaped = !escaped;
-                    } else {
-                        break;
-                    }
-                }
-                match self.direction {
-                    MoveWordDir::Right => self.consume_char_path_component_forward(c, escaped),
-                    MoveWordDir::Left => self.consume_char_path_component_backward(c, escaped),
-                }
-            }
-            MoveWordStyle::Whitespace => self.consume_char_word_movement(c, true),
-        }
-    }
-
-    pub fn reset(&mut self) {
-        self.state = S_INIT;
-        self.last_char_cls = None;
-    }
-
-    fn consume_char_path_component_forward(&mut self, c: char, escaped: bool) -> bool {
-        // Forward path component movement: skip current homogeneous component, stop at next.
-        // Each component type (word, slash, punctuation, whitespace) is a unit.
-        const S_WORD: u8 = 1;
-        const S_SEP: u8 = 2;
-        const S_SPACE: u8 = 3;
-        const S_END: u8 = 4;
-
-        if c == '\\' && !escaped {
-            return true;
-        };
-
-        let transition_idx = if is_blank(c) && !escaped {
-            0
-        } else if is_path_component_character(c) || (is_blank(c) && escaped) {
-            1
-        } else {
-            2
-        };
-
-        #[rustfmt::skip]
-        const TRANSITION_TABLE: &[[(u8, bool); 3]] = &[
-            // space,         word,           sep,
-            [(S_SPACE, true), (S_WORD, true), (S_SEP, true)],  // S_INIT
-            [(S_SPACE, true), (S_WORD, true), (S_SEP, true)],  // S_WORD
-            [(S_SPACE, true), (S_END, false), (S_SEP, true)],  // S_SEP
-            [(S_SPACE, true), (S_END, false), (S_END, false)], // S_SPACE
-            [(S_END, false),  (S_END, false), (S_END, false)], // S_END
-        ];
-
-        let (new_state, result) = TRANSITION_TABLE[usize::from(self.state)][transition_idx];
-        self.state = new_state;
-        result
-    }
-
-    fn consume_char_path_component_backward(&mut self, c: char, escaped: bool) -> bool {
-        const S_PUNC: u8 = 1;
-        const S_WORD: u8 = 2;
-        const S_SPACE: u8 = 3;
-        const S_END_WORD: u8 = 4;
-        const S_END_PUNC: u8 = 5;
-        const S_END: u8 = 7;
-
-        // a path component contains either
-        // - [space +] end word
-        // - punc + end word
-        // - space + end punc
-
-        if c == '\\' && !escaped {
-            return true;
-        };
-
-        let transition_idx = if is_blank(c) && !escaped {
-            0
-        } else if is_path_component_character(c) || (is_blank(c) && escaped) {
-            1
-        } else {
-            2
-        };
-
-        #[rustfmt::skip]
-        const TRANSITION_TABLE: &[[(u8, bool); 3]] = &[
-            // space,         word,               punc,
-            [(S_SPACE, true), (S_WORD, true),     (S_PUNC, true)],     // S_INIT
-            [(S_END, false),  (S_END_WORD, true), (S_PUNC, true)],     // S_PUNC
-            [(S_END, false),  (S_WORD, true),     (S_SPACE, false)],   // S_WORD
-            [(S_SPACE, true), (S_WORD, true),     (S_END_PUNC, true)], // S_SPACE
-            [(S_END, false),  (S_END_WORD, true), (S_SPACE, false)],   // S_END_WORD
-            [(S_END, false),  (S_END, false),     (S_END_PUNC, true)], // S_END_PUNC
-            [(S_END, true),   (S_WORD, true),     (S_PUNC, true)],     // S_END
-        ];
-
-        let (new_state, result) = TRANSITION_TABLE[usize::from(self.state)][transition_idx];
-        self.state = new_state;
-        result
-    }
-
-    fn consume_char_word_movement(&mut self, c: char, is_bigword: bool) -> bool {
-        const S_SPACE: u8 = 1; // when seeing initial or midway space
-        const S_NEWLINE: u8 = 2; // when seeing a newline
-        const S_WORD: u8 = 3; // when seeing the word that will jump over
-        const S_END: u8 = 4;
-        let cur_cls = if is_bigword {
-            if c == '\n' {
-                WordCharClass::Newline
-            } else if is_blank(c) {
-                WordCharClass::Blank
-            } else {
-                WordCharClass::Word
-            }
-        } else {
-            WordCharClass::from_char(c)
-        };
-        let transition_idx = if cur_cls == WordCharClass::Blank {
-            0
-        } else if cur_cls == WordCharClass::Newline {
-            1
-        } else if self.last_char_cls == Some(cur_cls) {
-            2
-        } else {
-            3
-        };
-        // Transition tables for forward and backward word movement
-        #[rustfmt::skip]
-        const FORWARD_TABLE: &[[(u8, bool); 4]] = &[
-            // space,         newline,           same-class,     different-class
-            [(S_SPACE, true), (S_NEWLINE, true), (S_WORD, true), (S_WORD, true)], // S_INIT
-            [(S_SPACE, true), (S_NEWLINE, true), (S_END, false), (S_END, false)], // S_SPACE
-            [(S_SPACE, true), (S_END, false),    (S_END, false), (S_END, false)], // S_NEWLINE
-            [(S_SPACE, true), (S_NEWLINE, true), (S_WORD, true), (S_END, false)], // S_WORD
-            [(S_END, false),  (S_END, false),    (S_END, false), (S_END, false)], // S_END
-        ];
-        #[rustfmt::skip]
-        const BACKWARD_TABLE: &[[(u8, bool); 4]] = &[
-            // space,         newline,           same-class,     different-class
-            [(S_SPACE, true), (S_NEWLINE, true), (S_WORD, true), (S_WORD, true)], // S_INIT
-            [(S_SPACE, true), (S_NEWLINE, true), (S_WORD, true), (S_WORD, true)], // S_SPACE
-            [(S_SPACE, true), (S_END, false),    (S_WORD, true), (S_WORD, true)], // S_NEWLINE
-            [(S_END, false),  (S_END, false),    (S_WORD, true), (S_END, false)], // S_WORD
-            [(S_END, false),  (S_END, false),    (S_END, false), (S_END, false)], // S_END
-        ];
-        let table = match self.direction {
-            MoveWordDir::Right => FORWARD_TABLE,
-            MoveWordDir::Left => BACKWARD_TABLE,
-        };
-        let (new_state, result) = table[usize::from(self.state)][transition_idx];
-        self.state = new_state;
-        self.last_char_cls = Some(cur_cls);
-        result
-    }
-}
-
-fn is_path_component_character(c: char) -> bool {
-    tok_is_string_character(c, None) && !L!("/={,}'\":@#").as_char_slice().contains(&c)
-}
-
 /// The position of the equal sign in a variable assignment like foo=bar.
 ///
 /// Return the location of the equals sign, or none if the string does
@@ -1388,14 +1188,10 @@
 
 #[cfg(test)]
 mod tests {
-    use super::{
-        MoveWordDir, MoveWordStateMachine, MoveWordStyle, PipeOrRedir, TokFlags, TokenType,
-        Tokenizer, TokenizerError,
-    };
+    use super::{PipeOrRedir, TokFlags, TokenType, Tokenizer, TokenizerError};
     use crate::prelude::*;
     use crate::redirection::RedirectionMode;
     use libc::{STDERR_FILENO, STDOUT_FILENO};
-    use std::collections::VecDeque;
 
     #[test]
     fn test_tokenizer() {
@@ -1583,156 +1379,4 @@
         assert_eq!(get_redir_mode!("3<&0"), RedirectionMode::Fd);
         assert_eq!(get_redir_mode!("3</tmp/filetxt"), RedirectionMode::Input);
     }
-
-    /// Test word motion (forward-word, etc.). Carets represent cursor stops.
-    #[test]
-    fn test_word_motion() {
-        fn setup(direction: MoveWordDir, line: &str) -> (WString, VecDeque<usize>, usize, usize) {
-            let mut command = WString::new();
-            let mut stops = VecDeque::new();
-
-            // Carets represent stops and should be cut out of the command.
-            for c in line.chars() {
-                if c == '^' {
-                    if direction == MoveWordDir::Left {
-                        stops.push_front(command.len());
-                    } else {
-                        stops.push_back(command.len());
-                    }
-                } else {
-                    command.push(c);
-                }
-            }
-            stops.pop_back();
-
-            let idx = stops.pop_front().unwrap();
-            let end = if direction == MoveWordDir::Left {
-                0
-            } else {
-                command.len()
-            };
-
-            (command, stops, idx, end)
-        }
-
-        macro_rules! validate {
-             ($direction:expr, $style:expr, $line:expr) => {
-                let direction = $direction;
-                let (command, mut stops, mut idx, end) = setup(direction, $line);
-                assert!(!command.is_empty());
-                let mut sm = MoveWordStateMachine::new($style, direction);
-                while idx != end {
-                    let word_idx = if direction == MoveWordDir::Left {
-                        idx - 1
-                    } else {
-                        idx
-                    };
-                    let consumed = sm.consume_char(&command, word_idx);
-                    if consumed {
-                        idx = if direction == MoveWordDir::Left {
-                            idx - 1
-                        } else {
-                            idx + 1
-                        };
-                    } else {
-                        assert!(
-                            !stops.is_empty(),
-                            "unexpected stop at {idx}. String: {command:?}"
-                        );
-                        let expected_idx = stops.front().unwrap();
-                        assert_eq!(
-                            idx, *expected_idx,
-                            "Expected to stop={expected_idx} but stopped at {idx}. String: {command:?}"
-                        );
-                        stops.pop_front();
-                        sm.reset();
-                    }
-                }
-                assert!(
-                    stops.is_empty(),
-                    "expected to stop at {stops:?} but not. String: {command:?}"
-                );
-             }
-        }
-
-        use MoveWordDir::*;
-        use MoveWordStyle::*;
-
-        // PathComponents tests
-        validate!(
-            Left,
-            PathComponents,
-            "^echo ^/^foo/^bar{^aaa,^bbb,^ccc}^bak/^"
-        );
-        validate!(Left, PathComponents, "^echo ^bak ^///^");
-        validate!(Left, PathComponents, "^aaa ^@ ^@^aaa^");
-        validate!(Left, PathComponents, "^aaa ^a ^@^aaa^");
-        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!(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!(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 ^foo^__^foo^_^foo^//    ^");
-        validate!(Right, Punctuation, "^echo ^foo^__^foo^_^foo^//^");
-
-        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^");
-    }
 }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here's what I ended up with for word_motion.rs.
I agree that it's a good idea to share most logic between small-word and big-word movements.
Maybe we can also share logic between backward and forward patch component movements, but that can be done later.

Details
use std::ops::ControlFlow;

use crate::prelude::*;
use crate::tokenizer::tok_is_string_character;
use fish_wchar::word_char::{WordCharClass, is_blank};

#[derive(Clone, Copy, PartialEq, Eq)]
pub enum MoveWordStyle {
    /// stop at punctuation
    Punctuation,
    /// stops at path components
    PathComponents,
    /// stops at whitespace
    Whitespace,
}

#[derive(Clone, Copy, PartialEq, Eq)]
pub enum MoveWordDir {
    Left,
    Right,
}

pub struct MoveWordStateMachine {
    direction: MoveWordDir,
    state: MoveWordState,
}

enum MoveWordState {
    SmallWord(State<SmallWordMovementState>),
    PathComponentBackward(State<PathComponentBackwardState>),
    PathComponentForward(State<PathComponentForwardState>),
    BigWord(State<BigWordMovementState>),
}

enum State<S: LiveState> {
    Initial,
    Live(S),
    Final,
}

enum NonFinalState<S> {
    Initial,
    Live(S),
}

trait LiveState {
    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, direction: MoveWordDir) -> Self {
        use MoveWordState as MWS;
        use MoveWordStyle as Style;
        let state = match style {
            Style::Punctuation => MWS::SmallWord(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 { 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(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: LiveState>(
        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(direction, input_state, text, idx, c);
        !matches!(*state, State::Final)
    }
}

trait WordMovementState: LiveState {
    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 LiveState for SmallWordMovementState {
    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)
    }
}

struct BigWordMovementState {
    last_char_class: WordCharClass,
}
impl LiveState for BigWordMovementState {
    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 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
enum PathComponentTransition {
    Blank,
    PathComponent,
    Punctuation,
}

fn path_component_state_transition(
    text: &wstr,
    idx: usize,
    c: char,
) -> ControlFlow<(), PathComponentTransition> {
    let mut escaped: bool = false;
    for j in (0..idx).rev() {
        if text.as_char_slice()[j] == '\\' {
            escaped = !escaped;
        } else {
            break;
        }
    }

    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 LiveState 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 LiveState 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 => 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,
                },
            },
        })
    }
}

Comment thread src/tokenizer.rs
fn validate_visitor(
direction: Direction,
style: MoveWordStyle,
fn validate_helper(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I realized that "setup" is probably a better name for this function, changing to that

krobelus pushed a commit that referenced this pull request Jan 11, 2026
Add `use Direction::*` and `use MoveWordStyle::*` in tests to reduce
verbosity. Reformat tests to one-line style and reorder by test type.
No behavior change.

Part of #12269
krobelus added a commit that referenced this pull request Jan 11, 2026
The state machine uses polymorphic state stored as a raw u8.  This is
not very idiomatic Rust.

Define proper enum types. This helps the upcoming behavior change.

Ref: #12269 (comment)

Co-authored-by: SharzyL <[email protected]>
@krobelus krobelus closed this in bbb2f0d Jan 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants