vi-mode: vi-compliant word movements - #12269
Conversation
ddcab25 to
d14f8e2
Compare
13e5a03 to
faf86ed
Compare
| 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 |
There was a problem hiding this comment.
(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)
5f3e93a to
ac71ca6
Compare
|
I learned that vim's Well let's not copy this behavior since it causes more inconsistency and code perplexity. But I added a doc for this in |
| [(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 | ||
| ]; |
There was a problem hiding this comment.
this is a rewrite combined with a behavior change
If it makes sense, can you
- start with a commit that does the rewrite without behavior change
- add the behavior change in another commit
This could make both easier to review.
There was a problem hiding this comment.
I rewrote the logic of state machine and outer control at the same time thus it is quite hard to split the changes
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
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.
|
Fixed many issues mentioned in the review comments, which can be browsed by |
| Cjk, // CJK Ideographs | ||
| Hangul, // Hangul Syllables | ||
| } | ||
|
|
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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(); |
There was a problem hiding this comment.
Removing this conversion, it's no longer needed (if we change everything to char)
| Ordering::Greater | ||
| } else { | ||
| Ordering::Equal | ||
| } |
There was a problem hiding this comment.
extracting a function for the comparison
| /// Character class interval | ||
| struct ClassInterval { | ||
| first: u32, | ||
| last: u32, |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
| // if at word end, forward/kill char, otherwise forward/kill word end | ||
| if self.is_at_autosuggestion() { | ||
| self.accept_autosuggestion(AutosuggestionPortion::PerMoveWordStyle( | ||
| style, true, |
There was a problem hiding this comment.
enums support named fields, so I'm changing the type to
enum AutosuggestionPortion {
Count(usize),
Line,
PerMoveWordStyle{style: MoveWordStyle, to_word_end: bool},
}| #[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 |
There was a problem hiding this comment.
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^");
- }
}There was a problem hiding this comment.
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,
},
},
})
}
}| fn validate_visitor( | ||
| direction: Direction, | ||
| style: MoveWordStyle, | ||
| fn validate_helper( |
There was a problem hiding this comment.
I realized that "setup" is probably a better name for this function, changing to that
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
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]>
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,
{,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 settingset iskeyword-=_in vim).{forward,kill}-{word,bigword}-vi,{forward,backward,kill,backward-kill}-{word,bigword}-endandkill-{a,inner}-{word,bigword}corresponding to above-mentioned bindings.There are also some key bindings in default mode also affected by this patch, including
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).
With multiple spaces or punctuations, fish does not locate word boundary correctly.
In vim, word-based movements do not skip over empty lines
In vim, unicode characters with different categories are separated into words
When there is a space under cursor,
diw,diWin vim just delete the spaces,In vim,
daw,daWalso trims spaces on one side of the wordTODOs: