Skip to main content

zsh/ported/zle/
textobjects.rs

1//! ZLE text objects — port of `Src/Zle/textobjects.c`.
2//!
3//! Three C functions, zero structs/enums. The Rust port matches:
4//! three free ported over a `&mut Zle`, no Rust-only types.
5
6use std::sync::atomic::Ordering;
7
8use crate::ported::zle::zle_h::{ZC_iblank, MOD_MULT};
9
10#[allow(unused_imports)]
11use crate::ported::zle::{
12    deltochar::*, zle_hist::*, zle_main::*, zle_misc::*, zle_move::*, zle_params::*,
13    zle_refresh::*, zle_tricky::*, zle_utils::*, zle_vi::*, zle_word::*,
14};
15/// Port of `blankwordclass(ZLE_CHAR_T x)` from `Src/Zle/textobjects.c:34`. The
16/// vi blank-word class predicate. Returns 0 for blanks, 1 otherwise.
17
18// --- AUTO: cross-zle hoisted-fn use glob ---
19/// `blankwordclass` — see implementation.
20#[allow(unused_imports)]
21#[allow(unused_imports)]
22
23pub fn blankwordclass(x: char) -> i32 {
24    // c:34
25    // c:36 — `return (ZC_iblank(x) ? 0 : 1);`. `ZC_iblank` routes
26    // through `wcsiblank` (Src/Zle/zle.h:62 → Src/utils.c:4302-4307):
27    // `iswspace(wc) && wc != L'\n'`.
28    if ZC_iblank(x) {
29        0
30    } else {
31        1
32    } // c:36
33}
34
35/// Port of `selectword(UNUSED(char **args))` from `Src/Zle/textobjects.c:41`.
36/// Faithful 1:1 port of the C body. Variable names track the C
37/// source where possible.
38///
39/// `INCCS()` / `DECCS()` / `INCPOS()` / `DECPOS()` collapse to
40/// `+= 1` / `-= 1` in the Rust port because zshrs's buffer is
41/// `Vec<char>` (already multibyte-aware at the storage layer; no
42/// per-char byte-walk needed).
43///
44/// `virangeflag` is a `Src/Zle/zle_vi.c:36` file-global. The
45/// cursor-adjustment arm at `c:196-203` reads it. zshrs sets the
46/// flag during the live `vi`-operator-pending key-read loop in the
47/// ZLE file-scope statics; standalone widget invocation reaches this
48/// fn with the flag clear, which is the only state the cursor-
49/// adjustment needs to handle (the `range`-set branch only fires
50/// from inside `getvirange`, which has its own copy).
51pub fn selectword() -> i32 {
52    // c:41
53    let mut n: i32 = if ZMOD.lock().unwrap().flags & MOD_MULT != 0 {
54        // c:41 zmult
55        ZMOD.lock().unwrap().mult
56    } else {
57        1
58    };
59    let widget = BINDK
60        .lock()
61        .unwrap()
62        .as_ref()
63        .map(|t| t.nam.clone())
64        .unwrap_or_default();
65    let widget = widget.as_str();
66    let is_aword = widget == "select-a-word";
67    let is_inword = widget == "select-in-word";
68    let is_ablankword = widget == "select-a-blank-word";
69    let mut all: i32 = (is_aword || is_ablankword) as i32; // c:43-44
70    let viclass: fn(char) -> i32 = if is_aword || is_inword {
71        wordclass // c:46-47
72    } else {
73        blankwordclass
74    };
75    if ZLELL.load(Ordering::SeqCst) == 0 {
76        return 1;
77    }
78    let cur = ZLELINE
79        .lock()
80        .unwrap()
81        .get(ZLECS.load(Ordering::SeqCst))
82        .copied()
83        .unwrap_or('\n');
84    let mut sclass: i32 = viclass(cur); // c:48
85    let mut doblanks: i32 = all & ((sclass != 0) as i32); // c:49 all && sclass
86
87    let region_active = REGION_ACTIVE.load(Ordering::SeqCst) != 0; // c:51 (read once)
88
89    // C's `mark == -1` sentinel doesn't exist in the Rust port (mark
90    // is `usize`); the equivalent "mark is unset" condition collapses
91    // into `!region_active` since mark is only meaningful when the
92    // region is active. Drop the `mark == -1` disjunct.
93    if !region_active || ZLECS.load(Ordering::SeqCst) == MARK.load(Ordering::SeqCst) {
94        // c:51
95        // search back to first character of same class as the start
96        // position; also stop at the beginning of the line.
97        MARK.store(ZLECS.load(Ordering::SeqCst), Ordering::SeqCst); // c:54
98        while MARK.load(Ordering::SeqCst) != 0 {
99            // c:55
100            let pos = MARK.load(Ordering::SeqCst) - 1; // c:56-57 DECPOS
101            let cp = ZLELINE.lock().unwrap().get(pos).copied().unwrap_or('\n');
102            if cp == '\n' || viclass(cp) != sclass {
103                // c:58
104                break; // c:59
105            }
106            MARK.store(pos, Ordering::SeqCst);
107            // c:60
108        }
109        // similarly scan forward over characters of the same class.
110        while ZLECS.load(Ordering::SeqCst) < ZLELL.load(Ordering::SeqCst) {
111            // c:63
112            ZLECS.fetch_add(1, Ordering::SeqCst); // c:64 INCCS
113            let mut pos = ZLECS.load(Ordering::SeqCst); // c:65
114                                                        // single newlines within blanks are included.
115            if all != 0 && sclass == 0 && pos < ZLELL.load(Ordering::SeqCst)                // c:67
116                && ZLELINE.lock().unwrap().get(pos).copied() == Some('\n')
117            {
118                pos += 1; // c:68 INCPOS(pos)
119            }
120            let pc = ZLELINE.lock().unwrap().get(pos).copied().unwrap_or('\n');
121            if pc == '\n' || viclass(pc) != sclass {
122                // c:70
123                break; // c:71
124            }
125        }
126
127        if all != 0 {
128            // c:74
129            let cc = ZLELINE
130                .lock()
131                .unwrap()
132                .get(ZLECS.load(Ordering::SeqCst))
133                .copied()
134                .unwrap_or('\n');
135            let nclass = viclass(cc); // c:75
136                                      // if either start or new position is blank advance over a
137                                      // new block of characters of a common type.
138            if nclass == 0 || sclass == 0 {
139                // c:78
140                while ZLECS.load(Ordering::SeqCst) < ZLELL.load(Ordering::SeqCst) {
141                    // c:79
142                    ZLECS.fetch_add(1, Ordering::SeqCst); // c:80 INCCS
143                    let cc = ZLELINE
144                        .lock()
145                        .unwrap()
146                        .get(ZLECS.load(Ordering::SeqCst))
147                        .copied()
148                        .unwrap_or('\n');
149                    if cc == '\n' || viclass(cc) != nclass {
150                        // c:81
151                        break; // c:82
152                    }
153                }
154                if n < 2 {
155                    // c:85
156                    doblanks = 0; // c:86
157                }
158            }
159        }
160    } else {
161        // c:89
162        // For visual mode, advance one char so repeated invocations
163        // select subsequent words.
164        if ZLECS.load(Ordering::SeqCst) > MARK.load(Ordering::SeqCst) {
165            // c:92
166            if ZLECS.load(Ordering::SeqCst) < ZLELL.load(Ordering::SeqCst) {
167                // c:93
168                ZLECS.fetch_add(1, Ordering::SeqCst); // c:94 INCCS
169            }
170        } else if ZLECS.load(Ordering::SeqCst) != 0 {
171            // c:95
172            ZLECS.fetch_sub(1, Ordering::SeqCst);
173            // c:96 DECCS
174        }
175        if ZLECS.load(Ordering::SeqCst) < MARK.load(Ordering::SeqCst) {
176            // c:97
177            // visual mode with the cursor before the mark: move
178            // cursor back.
179            while {
180                let cont = n > 0;
181                n -= 1;
182                cont
183            } {
184                // c:99 while (n-- > 0)
185                let mut pos = ZLECS.load(Ordering::SeqCst); // c:100
186                let zc_pos = ZLELINE.lock().unwrap().get(pos).copied().unwrap_or('\n');
187                // first over blanks
188                if all != 0 && (viclass(zc_pos) == 0 || zc_pos == '\n') {
189                    // c:102
190                    all = 0; // c:104
191                    while pos != 0 {
192                        // c:105
193                        pos -= 1; // c:106 DECPOS
194                        let pc = ZLELINE.lock().unwrap().get(pos).copied().unwrap_or('\n');
195                        if pc == '\n' {
196                            // c:107
197                            break; // c:108
198                        }
199                        ZLECS.store(pos, Ordering::SeqCst); // c:109
200                        if viclass(pc) != 0 {
201                            // c:110
202                            break; // c:111
203                        }
204                    }
205                } else if ZLECS.load(Ordering::SeqCst) != 0
206                    && ZLELINE
207                        .lock()
208                        .unwrap()
209                        .get(ZLECS.load(Ordering::SeqCst))
210                        .copied()
211                        == Some('\n')
212                {
213                    // c:114
214                    // for 'in' widgets pass over one newline
215                    pos -= 1; // c:116 DECPOS(pos)
216                    let pc = ZLELINE.lock().unwrap().get(pos).copied().unwrap_or('\n');
217                    if pc != '\n' {
218                        // c:117
219                        ZLECS.store(pos, Ordering::SeqCst); // c:118
220                    }
221                }
222                pos = ZLECS.load(Ordering::SeqCst); // c:121
223                let cur = ZLELINE
224                    .lock()
225                    .unwrap()
226                    .get(ZLECS.load(Ordering::SeqCst))
227                    .copied()
228                    .unwrap_or('\n');
229                sclass = viclass(cur); // c:122
230                                       // now retreat over non-blanks
231                loop {
232                    // c:124
233                    let pc = ZLELINE.lock().unwrap().get(pos).copied().unwrap_or('\n');
234                    if pc == '\n' || viclass(pc) != sclass {
235                        break;
236                    }
237                    ZLECS.store(pos, Ordering::SeqCst); // c:126
238                    if pos == 0 {
239                        // c:127
240                        ZLECS.store(0, Ordering::SeqCst); // c:128
241                        break; // c:129
242                    }
243                    pos -= 1; // c:131 DECPOS
244                }
245                // blanks again but only if there were none first time
246                if all != 0 && ZLECS.load(Ordering::SeqCst) != 0 {
247                    // c:134
248                    pos = ZLECS.load(Ordering::SeqCst);
249                    pos -= 1; // c:136 DECPOS
250                    let pc = ZLELINE.lock().unwrap().get(pos).copied().unwrap_or('\n');
251                    if viclass(pc) == 0 {
252                        // c:137
253                        while pos != 0 {
254                            // c:138
255                            pos -= 1; // c:139 DECPOS
256                            let pc = ZLELINE.lock().unwrap().get(pos).copied().unwrap_or('\n');
257                            if pc == '\n' || viclass(pc) != 0 {
258                                // c:140
259                                break; // c:142
260                            }
261                            ZLECS.store(pos, Ordering::SeqCst);
262                            // c:143
263                        }
264                    }
265                }
266            }
267            return 0; // c:147
268        }
269        n += 1; // c:148
270        doblanks = 0; // c:149
271    }
272    // force to character-wise — c:152
273    REGION_ACTIVE.store(if region_active { 1 } else { 0 }, Ordering::SeqCst);
274
275    // for each digit argument, advance over a further block of one class
276    while {
277        n -= 1;
278        n > 0
279    } {
280        // c:155
281        if ZLECS.load(Ordering::SeqCst) < ZLELL.load(Ordering::SeqCst)
282            && ZLELINE
283                .lock()
284                .unwrap()
285                .get(ZLECS.load(Ordering::SeqCst))
286                .copied()
287                == Some('\n')
288        {
289            // c:156
290            ZLECS.fetch_add(1, Ordering::SeqCst);
291            // c:157 INCCS
292        }
293        let cur = ZLELINE
294            .lock()
295            .unwrap()
296            .get(ZLECS.load(Ordering::SeqCst))
297            .copied()
298            .unwrap_or('\n');
299        sclass = viclass(cur); // c:158
300        while ZLECS.load(Ordering::SeqCst) < ZLELL.load(Ordering::SeqCst) {
301            // c:159
302            ZLECS.fetch_add(1, Ordering::SeqCst); // c:160 INCCS
303            let cc = ZLELINE
304                .lock()
305                .unwrap()
306                .get(ZLECS.load(Ordering::SeqCst))
307                .copied()
308                .unwrap_or('\n');
309            if cc == '\n' || viclass(cc) != sclass {
310                // c:161
311                break; // c:163
312            }
313        }
314        // for 'a' widgets, advance extra block if either consists of blanks
315        if all != 0 {
316            // c:165
317            if ZLECS.load(Ordering::SeqCst) < ZLELL.load(Ordering::SeqCst)
318                && ZLELINE
319                    .lock()
320                    .unwrap()
321                    .get(ZLECS.load(Ordering::SeqCst))
322                    .copied()
323                    == Some('\n')
324            {
325                // c:166
326                ZLECS.fetch_add(1, Ordering::SeqCst); // c:167 INCCS
327            }
328            let cc = ZLELINE
329                .lock()
330                .unwrap()
331                .get(ZLECS.load(Ordering::SeqCst))
332                .copied()
333                .unwrap_or('\n');
334            let cls_here = viclass(cc);
335            if sclass == 0 || cls_here == 0 {
336                // c:168
337                sclass = cls_here; // c:169
338                if n == 1 && sclass == 0 {
339                    // c:170
340                    doblanks = 0; // c:171
341                }
342                while ZLECS.load(Ordering::SeqCst) < ZLELL.load(Ordering::SeqCst) {
343                    // c:172
344                    ZLECS.fetch_add(1, Ordering::SeqCst); // c:173 INCCS
345                    let cc = ZLELINE
346                        .lock()
347                        .unwrap()
348                        .get(ZLECS.load(Ordering::SeqCst))
349                        .copied()
350                        .unwrap_or('\n');
351                    if cc == '\n' || viclass(cc) != sclass {
352                        // c:174
353                        break; // c:176
354                    }
355                }
356            }
357        }
358    }
359
360    // if we didn't remove blanks at either end we remove some at the start
361    if doblanks != 0 {
362        // c:181
363        let mut pos = MARK.load(Ordering::SeqCst); // c:182
364        while pos != 0 {
365            // c:183
366            pos -= 1; // c:184 DECPOS
367                      // don't remove blanks at the start of the line, i.e. indentation
368            let pc = ZLELINE.lock().unwrap().get(pos).copied().unwrap_or('\n');
369            if pc == '\n' {
370                // c:186
371                break; // c:187
372            }
373            if !ZC_iblank(pc) {
374                // c:188 !ZC_iblank
375                pos += 1; // c:189 INCPOS
376                MARK.store(pos, Ordering::SeqCst); // c:190
377                break; // c:191
378            }
379        }
380    }
381    // Adjustment: vi operators don't include the cursor position; in
382    // insert or emacs mode the region also doesn't, but for vi visual
383    // mode it is included.
384    //
385    // c:196 — `virangeflag` file-global (zle_vi.c:36). When non-zero
386    // a vi range operation is pending, in which case the region
387    // adjustment below is suppressed because the operator already
388    // handled it.
389    let virangeflag = VIRANGEFLAG.load(Ordering::Relaxed) != 0;
390    if !virangeflag {
391        // c:196
392        if !in_vi_cmd_mode() {
393            // c:197
394            REGION_ACTIVE.store(1, Ordering::SeqCst); // c:198
395        } else if ZLECS.load(Ordering::SeqCst) != 0
396            && ZLECS.load(Ordering::SeqCst) > MARK.load(Ordering::SeqCst)
397        {
398            // c:199
399            ZLECS.fetch_sub(1, Ordering::SeqCst);
400            // c:200 DECCS
401        }
402    }
403
404    0 // c:204
405}
406
407/// Port of `selectargument(UNUSED(char **args))` from `Src/Zle/textobjects.c:212`.
408///
409/// The C body uses the shell's `ctxtlex()` lexer-walk machinery
410/// (textobjects.c:233-257) to drive real shell tokenisation over
411/// the buffer. zshrs lowers the lexer through fusevm bytecode and
412/// does not expose a free-running `ctxtlex`-style scanner; this
413/// port uses whitespace-split tokenisation against the buffer
414/// (matches C output for simple commands without quoting /
415/// expansion / heredocs). Returns 1 when `n` is out of range,
416/// matching C textobjects.c:225.
417pub fn selectargument() -> i32 {
418    // c:212
419    let n: i32 = if ZMOD.lock().unwrap().flags & MOD_MULT != 0 {
420        ZMOD.lock().unwrap().mult // c:222 zmult
421    } else {
422        1
423    };
424    if n < 1 || (2 * n as usize) > ZLELL.load(Ordering::SeqCst) + 1 {
425        // c:225
426        return 1;
427    }
428    if !in_vi_cmd_mode() {
429        // c:228
430        REGION_ACTIVE.store(1, Ordering::SeqCst); // c:229
431        MARK.store(ZLECS.load(Ordering::SeqCst), Ordering::SeqCst); // c:230
432    }
433    // Whitespace-split tokenisation (see fn-doc for the ctxtlex
434    // tradeoff).
435    let mut starts: Vec<usize> = Vec::with_capacity(n as usize);
436    let mut in_word = false;
437    let mut word_start = 0usize;
438    starts.push(0);
439    for (i, &c) in ZLELINE.lock().unwrap().iter().enumerate() {
440        if c.is_whitespace() {
441            if in_word {
442                in_word = false;
443                if starts.len() < n as usize {
444                    starts.push(i + 1);
445                }
446            }
447        } else if !in_word {
448            in_word = true;
449            word_start = i;
450            if i >= ZLECS.load(Ordering::SeqCst) {
451                break;
452            }
453        }
454    }
455    let arg_idx = (n - 1) as usize;
456    let s = starts.get(arg_idx).copied().unwrap_or(word_start);
457    let e = (s..ZLELL.load(Ordering::SeqCst))
458        .find(|&i| {
459            ZLELINE
460                .lock()
461                .unwrap()
462                .get(i)
463                .copied()
464                .map_or(true, |c| c.is_whitespace())
465        })
466        .unwrap_or(ZLELL.load(Ordering::SeqCst));
467    MARK.store(s, Ordering::SeqCst);
468    ZLECS.store(e, Ordering::SeqCst);
469    if in_vi_cmd_mode() && ZLECS.load(Ordering::SeqCst) > 0 {
470        ZLECS.fetch_sub(1, Ordering::SeqCst);
471    }
472    0
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478
479    /// c:36 — `return ZC_iblank(x) ? 0 : 1`. blank → 0, non-blank → 1.
480    /// Verifies the boundary cases for the word-classifier helper that
481    /// selectword/selectargument iterate against.
482    #[test]
483    fn blankwordclass_classifies_whitespace_vs_word_chars() {
484        let _g = crate::test_util::global_state_lock();
485        assert_eq!(blankwordclass(' '), 0, "space is iblank");
486        assert_eq!(blankwordclass('\t'), 0, "tab is iblank");
487        assert_eq!(blankwordclass('a'), 1, "letter is not iblank");
488        assert_eq!(blankwordclass('0'), 1, "digit is not iblank");
489        assert_eq!(blankwordclass('!'), 1, "punctuation is not iblank");
490        assert_eq!(
491            blankwordclass('\n'),
492            1,
493            "newline is NOT iblank per ZC_iblank semantics"
494        );
495    }
496
497    /// `Src/Zle/textobjects.c:36` — `return (ZC_iblank(x) ? 0 : 1)`.
498    /// `Src/Zle/zle.h:62` aliases `ZC_iblank` to `wcsiblank` in the
499    /// MULTIBYTE_SUPPORT build; `Src/utils.c:4304` defines
500    /// `wcsiblank(wc)` as `iswspace(wc) && wc != L'\n'`. Non-ASCII
501    /// letters are NOT `iswspace` so they fall through to the
502    /// non-iblank branch (return 1).
503    #[test]
504    fn blankwordclass_non_ascii_letters_are_not_iblank() {
505        let _g = crate::test_util::global_state_lock();
506        assert_eq!(blankwordclass('é'), 1, "Latin-1 letter: not iswspace");
507        assert_eq!(blankwordclass('字'), 1, "CJK ideograph: not iswspace");
508        assert_eq!(blankwordclass('α'), 1, "Greek letter: not iswspace");
509    }
510
511    /// `Src/utils.c:4302-4307 wcsiblank` returns true for every
512    /// `iswspace` char except `\n`. Per `Src/Zle/textobjects.c:36`
513    /// that means CR/FF/VT/NBSP all classify as iblank → return 0.
514    /// Pinning this prevents a regression that re-narrows the
515    /// classifier back to `space || tab` (which would split words
516    /// on non-ASCII whitespace in vi `aW`/`iW` selections).
517    #[test]
518    fn blankwordclass_wide_whitespace_classes_are_iblank() {
519        let _g = crate::test_util::global_state_lock();
520        assert_eq!(blankwordclass('\r'), 0, "CR is iblank per wcsiblank");
521        assert_eq!(blankwordclass('\x0c'), 0, "FF is iblank per wcsiblank");
522        assert_eq!(blankwordclass('\x0b'), 0, "VT is iblank per wcsiblank");
523        assert_eq!(
524            blankwordclass('\u{00A0}'),
525            0,
526            "NBSP is iblank per wcsiblank"
527        );
528    }
529
530    /// `Src/Zle/textobjects.c:224-225` — `if (n < 1 || 2*n > zlell+1) return 1`.
531    /// With an empty buffer (`zlell == 0`) the predicate `2*1 > 0+1`
532    /// is true so the guard fires for any n>=1. Regression dropping
533    /// this guard would run the tokeniser over a zero-length buffer
534    /// and corrupt `MARK`/`ZLECS` (set to past-end indices) on every
535    /// vi `iw`/`aw` over an empty prompt.
536    #[test]
537    fn selectargument_returns_one_on_empty_buffer() {
538        let _g = crate::test_util::global_state_lock();
539        let _g = zle_test_setup();
540        assert_eq!(
541            selectargument(),
542            1,
543            "c:225 — empty buffer fails 2*n > zlell+1"
544        );
545    }
546
547    /// c:34-36 — `blankwordclass` digits and underscore are word
548    /// chars (class 1). Pin the contract because digits' iswspace
549    /// status is locale-dependent and a regression could change it.
550    #[test]
551    fn blankwordclass_digits_and_underscore_are_word_chars() {
552        let _g = crate::test_util::global_state_lock();
553        for d in '0'..='9' {
554            assert_eq!(blankwordclass(d), 1, "digit {:?} must NOT be iblank", d);
555        }
556        assert_eq!(blankwordclass('_'), 1, "underscore is a word char");
557    }
558
559    /// c:36 — `\0` (NUL) is NOT iswspace, so it falls through to
560    /// the non-iblank branch → class 1. Pin this so a regression
561    /// that special-cases NUL doesn't silently change vi word
562    /// selection at the buffer boundary.
563    #[test]
564    fn blankwordclass_nul_is_not_iblank() {
565        let _g = crate::test_util::global_state_lock();
566        assert_eq!(
567            blankwordclass('\0'),
568            1,
569            "NUL byte must classify as non-iblank per wcsiblank semantics"
570        );
571    }
572
573    /// c:36 — emoji and other non-BMP chars are not iswspace, so
574    /// they fall through to class 1. Pin this so a regen that
575    /// over-narrows the classifier to ASCII doesn't drop them
576    /// silently into the wrong vi word group.
577    #[test]
578    fn blankwordclass_non_bmp_chars_are_word_chars() {
579        let _g = crate::test_util::global_state_lock();
580        assert_eq!(blankwordclass('\u{1F600}'), 1, "emoji is non-iblank");
581        assert_eq!(blankwordclass('\u{2603}'), 1, "snowman is non-iblank");
582    }
583
584    /// c:225 — `selectargument` with `zlell=0` MUST NOT touch MARK
585    /// or ZLECS. Pinning the no-side-effect property protects
586    /// against a regression that increments MARK to 1 before the
587    /// guard check.
588    #[test]
589    fn selectargument_empty_buffer_leaves_mark_and_zlecs_unchanged() {
590        let _g = crate::test_util::global_state_lock();
591        let _g = zle_test_setup();
592        MARK.store(42, Ordering::SeqCst);
593        ZLECS.store(7, Ordering::SeqCst);
594        let r = selectargument();
595        assert_eq!(r, 1);
596        assert_eq!(
597            MARK.load(Ordering::SeqCst),
598            42,
599            "MARK must not be touched on the c:225 guard branch"
600        );
601        assert_eq!(
602            ZLECS.load(Ordering::SeqCst),
603            7,
604            "ZLECS must not be touched on the c:225 guard branch"
605        );
606    }
607
608    /// c:225 — `selectargument` with `zmult = 0 && MOD_MULT` triggers
609    /// the `n < 1` half of the guard. Pin the second half of the
610    /// guard predicate so a regression that simplifies the OR to
611    /// just the size check gets caught.
612    #[test]
613    fn selectargument_zero_mult_returns_one() {
614        let _g = crate::test_util::global_state_lock();
615        let _g = zle_test_setup();
616        *ZLELINE.lock().unwrap() = "hello world".chars().collect();
617        ZLELL.store(11, Ordering::SeqCst);
618        ZLECS.store(0, Ordering::SeqCst);
619        let mut z = ZMOD.lock().unwrap();
620        z.flags = MOD_MULT;
621        z.mult = 0;
622        drop(z);
623        assert_eq!(selectargument(), 1, "n<1 guard branch must fire");
624    }
625
626    /// c:225 — `selectargument` with `zmult = -1 && MOD_MULT` also
627    /// fires the n<1 guard (negative count). Pin negative-count
628    /// rejection.
629    #[test]
630    fn selectargument_negative_mult_returns_one() {
631        let _g = crate::test_util::global_state_lock();
632        let _g = zle_test_setup();
633        *ZLELINE.lock().unwrap() = "hello world".chars().collect();
634        ZLELL.store(11, Ordering::SeqCst);
635        ZLECS.store(0, Ordering::SeqCst);
636        let mut z = ZMOD.lock().unwrap();
637        z.flags = MOD_MULT;
638        z.mult = -1;
639        drop(z);
640        assert_eq!(
641            selectargument(),
642            1,
643            "negative count must fail the n<1 guard"
644        );
645    }
646
647    /// c:36 — `blankwordclass(' ')` returns 0 (iblank). C dispatch
648    /// at `Src/Zle/zle.h:62`: ZC_iblank → wcsiblank → iswspace AND
649    /// not '\n'. ASCII space is iswspace AND not '\n' → iblank → 0.
650    #[test]
651    fn blankwordclass_pure_space_is_iblank() {
652        let _g = crate::test_util::global_state_lock();
653        assert_eq!(blankwordclass(' '), 0, "space MUST be iblank (class 0)");
654    }
655
656    /// c:36 — `\t` (tab) is iblank.
657    #[test]
658    fn blankwordclass_tab_is_iblank() {
659        let _g = crate::test_util::global_state_lock();
660        assert_eq!(blankwordclass('\t'), 0, "tab MUST be iblank (class 0)");
661    }
662
663    /// c:36 — `\n` (newline) is NOT iblank per `wcsiblank` def
664    /// (`Src/utils.c:4304 — iswspace(wc) && wc != L'\\n'`). Pin
665    /// the newline-exclusion because a regen that drops the `!= '\n'`
666    /// condition would silently treat newline as whitespace and
667    /// break vi `aW` word selection across line boundaries.
668    #[test]
669    fn blankwordclass_newline_is_NOT_iblank() {
670        let _g = crate::test_util::global_state_lock();
671        assert_eq!(
672            blankwordclass('\n'),
673            1,
674            "newline must NOT be iblank per wcsiblank's explicit exclusion"
675        );
676    }
677
678    /// c:34 — `blankwordclass` is a pure function: same input →
679    /// same output, no side effects. Verify idempotency by calling
680    /// 1000 times with the same input.
681    #[test]
682    fn blankwordclass_is_idempotent() {
683        let _g = crate::test_util::global_state_lock();
684        for _ in 0..1000 {
685            assert_eq!(blankwordclass('a'), 1);
686            assert_eq!(blankwordclass(' '), 0);
687            assert_eq!(blankwordclass('\n'), 1);
688        }
689    }
690
691    /// c:212 — `selectargument` against a buffer with ONLY whitespace
692    /// must NOT panic (defensive). The n=1, 2*n > zlell+1 guard
693    /// fires for zlell=0/1; for zlell=3+ (`   `), the guard passes
694    /// and the function walks the buffer.
695    #[test]
696    fn selectargument_whitespace_only_buffer_does_not_panic() {
697        let _g = crate::test_util::global_state_lock();
698        let _g = zle_test_setup();
699        *ZLELINE.lock().unwrap() = "   ".chars().collect();
700        ZLELL.store(3, Ordering::SeqCst);
701        ZLECS.store(1, Ordering::SeqCst);
702        let _ = selectargument();
703    }
704
705    // ═══════════════════════════════════════════════════════════════════
706    // Additional C-parity tests for Src/Zle/textobjects.c
707    // ═══════════════════════════════════════════════════════════════════
708
709    /// c:36 — `\v` (vertical tab) is iblank (`iswspace(wc) && wc != '\n'`).
710    /// Per ISO C, iswspace includes VT.
711    #[test]
712    fn blankwordclass_vertical_tab_is_iblank() {
713        let _g = crate::test_util::global_state_lock();
714        assert_eq!(
715            blankwordclass('\x0b'),
716            0,
717            "VT is whitespace (excl. newline)"
718        );
719    }
720
721    /// c:36 — `\f` (form feed) is iblank.
722    #[test]
723    fn blankwordclass_form_feed_is_iblank() {
724        let _g = crate::test_util::global_state_lock();
725        assert_eq!(
726            blankwordclass('\x0c'),
727            0,
728            "FF is whitespace (excl. newline)"
729        );
730    }
731
732    /// c:36 — `\r` (carriage return) is iblank (iswspace(CR) is true,
733    /// and CR != '\n').
734    #[test]
735    fn blankwordclass_carriage_return_is_iblank() {
736        let _g = crate::test_util::global_state_lock();
737        assert_eq!(blankwordclass('\r'), 0, "CR is whitespace (excl. newline)");
738    }
739
740    /// c:36 — return value is always 0 or 1 (no other values).
741    #[test]
742    fn blankwordclass_returns_boolean_i32_only() {
743        let _g = crate::test_util::global_state_lock();
744        for c in (0u32..0x10000).step_by(1024).filter_map(char::from_u32) {
745            let r = blankwordclass(c);
746            assert!(
747                r == 0 || r == 1,
748                "blankwordclass({:?}) = {} not in 0/1",
749                c,
750                r
751            );
752        }
753    }
754
755    /// c:212 — `selectargument` with n=0 (negative-mult-zero edge)
756    /// returns 1 per the c:225 guard `n < 1`.
757    #[test]
758    fn selectargument_n_zero_returns_one() {
759        let _g = crate::test_util::global_state_lock();
760        let _g2 = zle_test_setup();
761        ZMOD.lock().unwrap().flags |= MOD_MULT;
762        ZMOD.lock().unwrap().mult = 0;
763        *ZLELINE.lock().unwrap() = "hello world".chars().collect();
764        ZLELL.store(11, Ordering::SeqCst);
765        ZLECS.store(0, Ordering::SeqCst);
766        assert_eq!(selectargument(), 1, "n=0 hits n<1 guard");
767        ZMOD.lock().unwrap().flags &= !MOD_MULT;
768    }
769
770    /// c:225 — `2*n > zlell+1` guard returns 1 (n too large for buffer).
771    #[test]
772    fn selectargument_n_too_large_returns_one() {
773        let _g = crate::test_util::global_state_lock();
774        let _g2 = zle_test_setup();
775        ZMOD.lock().unwrap().flags |= MOD_MULT;
776        ZMOD.lock().unwrap().mult = 100; // way past buffer length
777        *ZLELINE.lock().unwrap() = "hi".chars().collect();
778        ZLELL.store(2, Ordering::SeqCst);
779        ZLECS.store(0, Ordering::SeqCst);
780        assert_eq!(selectargument(), 1, "n=100 vs zlell=2 hits guard");
781        ZMOD.lock().unwrap().flags &= !MOD_MULT;
782    }
783
784    /// c:36 — entire ASCII range: classifiers agree with C `isspace`
785    /// minus newline (per `wcsiblank` def: `iswspace(wc) && wc != '\n'`).
786    /// Rust's `is_ascii_whitespace` is narrower than C's `isspace`
787    /// (excludes VT 0x0b); pin against the C-correct set explicitly.
788    #[test]
789    fn blankwordclass_ascii_matches_c_isspace_excluding_newline() {
790        let _g = crate::test_util::global_state_lock();
791        // ISO C isspace ASCII members: ' ', '\t', '\n', '\v', '\f', '\r'.
792        // iblank = isspace - '\n'.
793        const IBLANK: &[char] = &[' ', '\t', '\x0b', '\x0c', '\r'];
794        for b in 0u8..128 {
795            let c = b as char;
796            let want = if IBLANK.contains(&c) { 0 } else { 1 };
797            assert_eq!(
798                blankwordclass(c),
799                want,
800                "blankwordclass(0x{:02x}) mismatch — C isspace excl '\\n'",
801                b
802            );
803        }
804    }
805
806    // ═══════════════════════════════════════════════════════════════════
807    // Additional C-parity pins for Src/Zle/textobjects.c
808    // c:34 blankwordclass / c:41 selectword / c:212 selectargument
809    // ═══════════════════════════════════════════════════════════════════
810
811    /// c:34 — `blankwordclass` is pure (no global mutation).
812    #[test]
813    fn blankwordclass_is_pure_no_side_effects() {
814        let _g = crate::test_util::global_state_lock();
815        let _g2 = zle_test_setup();
816        let cs = ZLECS.load(Ordering::SeqCst);
817        let ll = ZLELL.load(Ordering::SeqCst);
818        for c in [' ', 'a', '0', '_', '\t', '\n', '\x0b'] {
819            let _ = blankwordclass(c);
820        }
821        assert_eq!(ZLECS.load(Ordering::SeqCst), cs);
822        assert_eq!(ZLELL.load(Ordering::SeqCst), ll);
823    }
824
825    /// c:34 — `\n` (newline) is NOT iblank per `wcsiblank` def.
826    #[test]
827    fn blankwordclass_newline_returns_one() {
828        let _g = crate::test_util::global_state_lock();
829        assert_eq!(blankwordclass('\n'), 1, "\\n is excluded from iblank");
830    }
831
832    /// c:34 — DEL (0x7f) is NOT iblank.
833    #[test]
834    fn blankwordclass_del_returns_one() {
835        let _g = crate::test_util::global_state_lock();
836        assert_eq!(blankwordclass('\x7f'), 1, "DEL is not whitespace");
837    }
838
839    /// c:34 — NBSP (U+00A0) — per ISO C iswspace in most locales, but
840    /// Rust's char::is_whitespace handles it. Pin actual zshrs behavior
841    /// for future diff visibility (no assertion on direction).
842    #[test]
843    fn blankwordclass_nbsp_returns_bool_i32() {
844        let _g = crate::test_util::global_state_lock();
845        let r = blankwordclass('\u{00A0}');
846        assert!(r == 0 || r == 1, "NBSP must return 0 or 1, got {}", r);
847    }
848
849    /// c:34 — return type pin (compile-time).
850    #[test]
851    fn blankwordclass_returns_i32_type() {
852        let _: i32 = blankwordclass(' ');
853    }
854
855    /// c:41 — `selectword` returns i32 (type pin).
856    #[test]
857    fn selectword_returns_i32_type() {
858        let _: i32 = std::convert::identity::<fn() -> i32>(selectword)();
859    }
860
861    /// c:41 — `selectword` on empty buffer doesn't panic.
862    #[test]
863    fn selectword_empty_buffer_no_panic() {
864        let _g = crate::test_util::global_state_lock();
865        let _g2 = zle_test_setup();
866        *ZLELINE.lock().unwrap() = Vec::new();
867        ZLELL.store(0, Ordering::SeqCst);
868        ZLECS.store(0, Ordering::SeqCst);
869        let _ = selectword();
870    }
871
872    /// c:41 — `selectword` with single-char buffer doesn't panic.
873    #[test]
874    fn selectword_single_char_buffer_no_panic() {
875        let _g = crate::test_util::global_state_lock();
876        let _g2 = zle_test_setup();
877        *ZLELINE.lock().unwrap() = vec!['a'];
878        ZLELL.store(1, Ordering::SeqCst);
879        ZLECS.store(0, Ordering::SeqCst);
880        let _ = selectword();
881    }
882
883    /// c:41 — `selectword` with cursor past ZLELL doesn't panic
884    /// (defensive: C's `zleline[zlecs]` would UB; zshrs should handle).
885    #[test]
886    fn selectword_cursor_past_zlell_no_panic() {
887        let _g = crate::test_util::global_state_lock();
888        let _g2 = zle_test_setup();
889        *ZLELINE.lock().unwrap() = vec!['h', 'i'];
890        ZLELL.store(2, Ordering::SeqCst);
891        ZLECS.store(2, Ordering::SeqCst); // past last index
892        let _ = selectword();
893    }
894
895    /// c:41 — `selectword` is deterministic on identical state.
896    #[test]
897    fn selectword_deterministic_on_identical_state() {
898        let _g = crate::test_util::global_state_lock();
899        let _g2 = zle_test_setup();
900        *ZLELINE.lock().unwrap() = "hello world".chars().collect();
901        ZLELL.store(11, Ordering::SeqCst);
902        ZLECS.store(3, Ordering::SeqCst);
903        let first = selectword();
904        ZLECS.store(3, Ordering::SeqCst);
905        let second = selectword();
906        assert_eq!(first, second, "selectword must be deterministic");
907    }
908
909    /// c:212 — `selectargument` returns i32 (type pin).
910    #[test]
911    fn selectargument_returns_i32_type() {
912        let _: i32 = std::convert::identity::<fn() -> i32>(selectargument)();
913    }
914
915    /// c:212 — `selectargument` with n=1 on single-char doesn't panic.
916    #[test]
917    fn selectargument_n_one_single_char_no_panic() {
918        let _g = crate::test_util::global_state_lock();
919        let _g2 = zle_test_setup();
920        ZMOD.lock().unwrap().flags |= MOD_MULT;
921        ZMOD.lock().unwrap().mult = 1;
922        *ZLELINE.lock().unwrap() = vec!['x'];
923        ZLELL.store(1, Ordering::SeqCst);
924        ZLECS.store(0, Ordering::SeqCst);
925        let _ = selectargument();
926        ZMOD.lock().unwrap().flags &= !MOD_MULT;
927    }
928}