zshrs 0.12.18

The first compiled Unix shell — bytecode VM, worker pool, AOP intercept, Rkyv caching
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
//! Port of `_values` from `Completion/Base/Utility/_values`
//! (spec: `/usr/share/zsh/5.9/functions/_values`).
//!
//! Faithful 1:1 translation. `_values` drives the C builtin
//! `compvalues` (ported at `crate::ported::zle::computil::bin_compvalues`)
//! — it is NOT routed through `_alternative`. The engine is:
//!
//! ```text
//! sh:  1  #autoload
//! sh:  6  zparseopts -D -a garbage s+:=keep S+:=keep w+=keep C=usecc O:=subopts …
//! sh:  9  (( $#subopts )) && subopts=( "${(@P)subopts[2]}" )
//! sh: 11  if compvalues -i "$keep[@]" "$@"; then       # parse value defs
//! sh: 16    compvalues -S argsep                        # value/arg separator
//! sh: 17    compvalues -s sep && test="[^sep]#"         # list separator
//! sh: 19    if ! compvalues -D descr action; then       # completing NAMES
//! sh: 21      _tags values
//! sh: 25      compvalues -V noargs args opts            # active value sets
//! sh: 27      if PREFIX = *argsep test; then            # typing `name=<arg>`
//! sh: 31        compvalues -L name descr action  (or disambiguate via compadd -D)
//! sh: 52      else _describe … noargs args opts; return # list value names
//! sh: 69    else compvalues -C subc                     # completing an ARG
//! sh: 74    _tags arguments; _description arguments expl "$descr"
//! sh: 88    if action = ->state: compvalues -v val_args; set $state; return 1
//! sh:104    else action dispatch (empty / ((…)) / (…) / {…} / ` …` / cmd)
//! sh:155    [[ nm -ne $compstate[nmatches] ]]
//! sh:156  else curcontext="$oldcontext"; return 1
//! ```
//!
//! Approximations (see inline comments): `compadd -D array` array
//! pruning (sh:42) is reproduced in Rust because the `computil`
//! port does not yet write the `dpar` array back; the multi-group
//! `_describe … -- … -- …` call (sh:60) is passed verbatim to the
//! `_describe` port, which currently reads the group array names and
//! ignores the per-group `-S`/`-qS`/`-r` flags.

use crate::compsys::ported::_all_labels::_all_labels;
use crate::compsys::ported::_describe::_describe;
use crate::compsys::ported::_description::_description;
use crate::compsys::ported::_message::_message;
use crate::compsys::ported::_next_label::_next_label;
use crate::compsys::ported::_tags::_tags;
use crate::ported::exec::{dispatch_function_call, execute_script};
use crate::ported::params::{
    getaparam, getiparam, getsparam, setaparam, setsparam, unsetparam,
};
use crate::ported::zle::compcore::{get_compstate_str, set_compstate_str};
use crate::ported::zle::complete::bin_compadd;
use crate::ported::zle::computil::bin_compvalues;
use crate::ported::zsh_h::{options, MAX_OPS};

fn make_ops() -> options {
    options {
        ind: [0u8; MAX_OPS],
        args: Vec::new(),
        argscount: 0,
        argsalloc: 0,
    }
}

/// Call `compvalues <sub> <params…>`. Returns the builtin exit status
/// (0 = success). The named params are read back by the caller via
/// `getsparam`/`getaparam`/`gethparam`.
fn compvalues(argv: &[&str]) -> i32 {
    let v: Vec<String> = argv.iter().map(|s| s.to_string()).collect();
    bin_compvalues("compvalues", &v, &make_ops(), 0)
}

/// `${ctx%:*}:new` — replace the last `:`-delimited field of a
/// curcontext string (sh:23,50,71,93).
fn replace_last_field(ctx: &str, new: &str) -> String {
    let base = match ctx.rfind(':') {
        Some(i) => &ctx[..i],
        None => ctx,
    };
    format!("{}:{}", base, new)
}

/// `${s%%sep*}` — text before the first occurrence of `sep` (sh:30).
fn before_first<'a>(s: &'a str, sep: &str) -> &'a str {
    match s.find(sep) {
        Some(i) => &s[..i],
        None => s,
    }
}

/// `${s#*sep}` — text after the first occurrence of `sep` (sh:33,37).
fn after_first(s: &str, sep: &str) -> String {
    match s.find(sep) {
        Some(i) => s[i + sep.len()..].to_string(),
        None => s.to_string(),
    }
}

/// Glob test for `[[ "$PREFIX" = *${argsep}${~test} ]]` (sh:27).
/// `test` is `*` (unrestricted) or `[^sep]#` (restricted to a run of
/// non-separator chars). The pattern matches iff there is an `argsep`
/// occurrence whose tail satisfies `test`; for the restricted form the
/// last `argsep`'s tail must contain no separator char (an earlier
/// occurrence can only have a longer, superset tail).
fn prefix_is_arg(prefix: &str, argsep: &str, restricted: bool, sep_char: &str) -> bool {
    if argsep.is_empty() {
        return false;
    }
    if !restricted {
        // test == '*'  →  PREFIX matches `*argsep*`
        return prefix.contains(argsep);
    }
    match prefix.rfind(argsep) {
        Some(i) => {
            let tail = &prefix[i + argsep.len()..];
            sep_char.is_empty() || !tail.contains(sep_char)
        }
        None => false,
    }
}

/// `_values` — generic value completion. Parses `name[desc]:msg:action`
/// specs into the `compvalues` engine and completes either the value
/// names (for a list) or the argument of one named value.
pub fn _values(args: &[String]) -> i32 {
    let r = values_impl(args);
    // sh: the locals `noargs args opts descr action expl sep argsep subc
    // test ws val_args` are function-scoped in zsh and vanish on return.
    // The port has no local-param scope, so unset the by-name scratch
    // params here (state / state_descr / context / curcontext / PREFIX /
    // SUFFIX / IPREFIX / compstate persist for the caller).
    for p in [
        "noargs", "args", "opts", "descr", "action", "expl", "sep", "argsep", "subc", "val_args",
        "ws",
    ] {
        unsetparam(p);
    }
    r
}

fn values_impl(args: &[String]) -> i32 {
    // sh:6-9 — zparseopts. `keep` collects the `-s SEP` / `-S SEP` / `-w`
    // flags that feed `compvalues -i`; `-C` → usecc; `-O ARR` → subopts
    // expands the named array (`${(@P)subopts[2]}`); everything else
    // (`-M -J -V -1 -2 -o -n -F -X`) goes to `garbage` (discarded).
    let mut keep: Vec<String> = Vec::new();
    let mut usecc = false;
    let mut subopts: Vec<String> = Vec::new();
    let mut idx = 0usize;

    while idx < args.len() {
        let a = &args[idx];
        let b = a.as_bytes();
        if b.len() != 2 || b[0] != b'-' {
            break;
        }
        match b[1] {
            // s+:=keep / S+:=keep — flag + arg, kept for `compvalues -i`.
            b's' | b'S' if idx + 1 < args.len() => {
                keep.push(a.clone());
                keep.push(args[idx + 1].clone());
                idx += 2;
            }
            // w+=keep — flag, no arg.
            b'w' => {
                keep.push(a.clone());
                idx += 1;
            }
            // C=usecc — no arg.
            b'C' => {
                usecc = true;
                idx += 1;
            }
            // O:=subopts — arg is an array name; expand it into subopts.
            b'O' if idx + 1 < args.len() => {
                subopts = getaparam(&args[idx + 1]).unwrap_or_default();
                idx += 2;
            }
            // garbage, arg-taking: M: J: V: o+: F: X:
            b'M' | b'J' | b'V' | b'o' | b'F' | b'X' if idx + 1 < args.len() => {
                idx += 2;
            }
            // garbage, no arg: 1 2 n
            b'1' | b'2' | b'n' => {
                idx += 1;
            }
            _ => break,
        }
    }

    // sh:11 — `compvalues -i "$keep[@]" "$@"`. Remaining positionals are
    // the description + value specs.
    let mut cvi: Vec<String> = vec!["-i".to_string()];
    cvi.extend(keep.iter().cloned());
    cvi.extend(args[idx..].iter().cloned());
    if bin_compvalues("compvalues", &cvi, &make_ops(), 0) != 0 {
        // sh:156-159 — `oldcontext` is declared at sh:14 inside the
        // taken branch only; on this path it is unset, so zsh expands
        // `curcontext="$oldcontext"` to the empty string.
        let _ = setsparam("curcontext", "");
        return 1;
    }

    // sh:14 — capture curcontext for restoration.
    let oldcontext = getsparam("curcontext").unwrap_or_default();

    // sh:16 — value/argument separator (default '=').
    compvalues(&["-S", "argsep"]);
    let argsep = getsparam("argsep").unwrap_or_default();

    // sh:17 — list separator; `test="[^sep]#"` when a non-empty sep exists.
    let has_sep = compvalues(&["-s", "sep"]) == 0;
    let sep_char = if has_sep {
        getsparam("sep").unwrap_or_default()
    } else {
        String::new()
    };
    let test_restricted = has_sep && !sep_char.is_empty();

    // sh:19 — `if ! compvalues -D descr action` → completing value NAMES.
    if compvalues(&["-D", "descr", "action"]) != 0 {
        // sh:21
        if _tags(&["values".to_string()]) != 0 {
            return 1;
        }
        // sh:23
        let _ = setsparam("curcontext", &replace_last_field(&oldcontext, "values"));
        // sh:25 — active value sets by argument type.
        compvalues(&["-V", "noargs", "args", "opts"]);

        let prefix = getsparam("PREFIX").unwrap_or_default();
        // sh:27 — `[[ -n "$argsep" && "$PREFIX" = *${argsep}${~test} ]]`.
        if !argsep.is_empty() && prefix_is_arg(&prefix, &argsep, test_restricted, &sep_char) {
            // Completing the ARGUMENT of `name=<arg>`.
            // sh:30 — name = text before the first argsep.
            let name = before_first(&prefix, &argsep).to_string();
            // sh:31 — try the value directly.
            if compvalues(&["-L", name.as_str(), "descr", "action"]) == 0 {
                // sh:32-33 — shift the `name=` prefix out of PREFIX.
                let ipref = getsparam("IPREFIX").unwrap_or_default();
                let _ = setsparam("IPREFIX", &format!("{}{}{}", ipref, name, argsep));
                let _ = setsparam("PREFIX", &after_first(&prefix, &argsep));
            } else {
                // sh:34-51 — ambiguous partial name; disambiguate.
                let prefix_after = after_first(&prefix, &argsep); // sh:37
                let suffix = getsparam("SUFFIX").unwrap_or_default(); // sh:38
                let _ = setsparam("PREFIX", &name); // sh:39
                let _ = setsparam("SUFFIX", ""); // sh:40
                // sh:41 — args=( args opts ).
                let mut combined = getaparam("args").unwrap_or_default();
                combined.extend(getaparam("opts").unwrap_or_default());
                let _ = setaparam("args", combined.clone());
                // sh:42 — compadd -M … -D args - "${(@)args[@]%%:*}".
                // Adds nothing (with -D, compadd only prunes the array),
                // it filters `args` down to the entries whose name matches
                // the word on the line.
                let names: Vec<String> = combined
                    .iter()
                    .map(|e| before_first(e, ":").to_string())
                    .collect();
                let mut cadd = vec![
                    "-M".to_string(),
                    "r:|[_-]=* r:|=*".to_string(),
                    "-D".to_string(),
                    "args".to_string(),
                    "-".to_string(),
                ];
                cadd.extend(names);
                bin_compadd("compadd", &cadd, &make_ops(), 0);
                // APPROXIMATION: the `computil`/`compcore` port collects
                // `-D` targets (`dpar`) but does not yet write the pruned
                // array back. Reproduce `compadd -D`'s documented pruning
                // (drop entries whose name does not match the typed word)
                // so the `$#args -ne 1` disambiguation below works.
                let pruned: Vec<String> = combined
                    .into_iter()
                    .filter(|e| before_first(e, ":").starts_with(&name))
                    .collect();
                let _ = setaparam("args", pruned.clone());
                // sh:44 — need exactly one surviving value to proceed.
                if pruned.len() != 1 {
                    return 1;
                }
                // sh:46-48
                let _ = setsparam("PREFIX", &prefix_after);
                let _ = setsparam("SUFFIX", &suffix);
                let matched = before_first(&pruned[0], ":").to_string();
                let ipref = getsparam("IPREFIX").unwrap_or_default();
                let _ = setsparam("IPREFIX", &format!("{}{}{}", ipref, matched, argsep));
                // sh:49 — fetch descr/action/subc for the resolved value.
                compvalues(&["-L", matched.as_str(), "descr", "action", "subc"]);
                let subc = getsparam("subc").unwrap_or_default();
                // sh:50
                let _ = setsparam("curcontext", &replace_last_field(&oldcontext, &subc));
            }
            // Falls through to the arguments dispatch (sh:74).
        } else {
            // sh:52-67 — list the value NAMES via `_describe` and return.
            compvalues(&["-d", "descr"]); // sh:53
            let descr_v = getsparam("descr").unwrap_or_default();
            // sh:54-58 — sep=( -qS <char> ) or ().
            let sep_group: Vec<String> = if compvalues(&["-s", "sep"]) == 0 {
                vec!["-qS".to_string(), getsparam("sep").unwrap_or_default()]
            } else {
                Vec::new()
            };
            let sep2 = sep_group.get(1).cloned().unwrap_or_default();
            // sh:60-63 — three value groups (noargs / args / opts) with
            // per-group separators and the value-name matcher.
            let mut dargv: Vec<String> = vec![descr_v];
            dargv.push("noargs".to_string());
            dargv.extend(sep_group.iter().cloned());
            dargv.extend([
                "-M".to_string(),
                "r:|[_-]=* r:|=*".to_string(),
                "--".to_string(),
            ]);
            dargv.extend([
                "args".to_string(),
                "-S".to_string(),
                argsep.clone(),
                "-M".to_string(),
                "r:|[_-]=* r:|=*".to_string(),
                "--".to_string(),
            ]);
            dargv.extend([
                "opts".to_string(),
                "-qS".to_string(),
                argsep.clone(),
                "-r".to_string(),
                format!("{}{} \\t\\n\\-", argsep, sep2),
                "-M".to_string(),
                "r:|[_-]=* r:|=*".to_string(),
            ]);
            // NOTE: the `_describe` port reads the group array names but
            // ignores the per-group `-S`/`-qS`/`-r`/`--` flags; the value
            // names are still emitted (behavioral approximation).
            let r = _describe(&dargv);
            let _ = setsparam("curcontext", &oldcontext); // sh:65
            return r; // sh:67 — `return` yields _describe's status.
        }
    } else {
        // sh:69-72 — completing the ARGUMENT of an already-recognized value.
        compvalues(&["-C", "subc"]); // sh:70
        let subc = getsparam("subc").unwrap_or_default();
        let _ = setsparam("curcontext", &replace_last_field(&oldcontext, &subc)); // sh:71
    }

    // sh:74 — arguments dispatch (reached with descr/action set for a value).
    if _tags(&["arguments".to_string()]) != 0 {
        let _ = setsparam("curcontext", &oldcontext); // sh:75
        return 1;
    }

    let descr = getsparam("descr").unwrap_or_default();
    // sh:79
    let _ = _description(&[
        "arguments".to_string(),
        "expl".to_string(),
        descr.clone(),
    ]);

    // sh:84-86 — append the list separator as an autoremovable suffix
    // unless exactly one value remains. `snames`/`names`/`onames` are not
    // set by this function (sum 0 ≠ 1), so the suffix is added when a
    // separator exists.
    let mut sep_group: Vec<String> = Vec::new();
    let cnt = getaparam("snames").map(|v| v.len()).unwrap_or(0)
        + getaparam("names").map(|v| v.len()).unwrap_or(0)
        + getaparam("onames").map(|v| v.len()).unwrap_or(0);
    if cnt != 1 && compvalues(&["-s", "sep"]) == 0 {
        let s = getsparam("sep").unwrap_or_default();
        let flag = format!("-qS{}", s);
        let mut expl = getaparam("expl").unwrap_or_default();
        expl.insert(0, flag.clone());
        let _ = setaparam("expl", expl);
        sep_group = vec![flag];
    }

    let action = getsparam("action").unwrap_or_default();

    // sh:88 — `->state` form.
    if action.starts_with("->") {
        compvalues(&["-v", "val_args"]); // sh:89
        // sh:90 — state = action minus `->`, whitespace-trimmed.
        let state = action[2..].trim().to_string();
        let _ = setsparam("state", &state);
        let _ = setsparam("state_descr", &descr); // sh:91
        let subc = getsparam("subc").unwrap_or_default();
        if usecc {
            // sh:93
            let _ = setsparam("curcontext", &replace_last_field(&oldcontext, &subc));
        } else {
            // sh:95
            let _ = setsparam("context", &subc);
        }
        set_compstate_str("restore", ""); // sh:97
        return 1; // sh:98
    }

    // sh:100-102 — `typeset -A val_args; compvalues -v val_args`.
    compvalues(&["-v", "val_args"]);

    if action.trim().is_empty() {
        // sh:104-109 — empty action: just show the description.
        let _ = _message(&[
            "-e".to_string(),
            "arguments".to_string(),
            descr.clone(),
        ]);
        return 1;
    } else if action.starts_with("((") && action.ends_with("))") {
        // sh:111-118 — ((val:descr …)) literal set with descriptions.
        let body = &action[2..action.len() - 2];
        let ws: Vec<String> = body.split_whitespace().map(|s| s.to_string()).collect();
        let _ = setaparam("ws", ws);
        let mut d = vec![
            descr.clone(),
            "ws".to_string(),
            "-M".to_string(),
            "r:|[_-]=* r:|=*".to_string(),
        ];
        d.extend(subopts.iter().cloned());
        d.extend(sep_group.iter().cloned());
        let _ = _describe(&d);
    } else if action.starts_with('(') && action.ends_with(')') {
        // sh:120-126 — (val …) added directly.
        let body = &action[1..action.len() - 1];
        let ws: Vec<String> = body.split_whitespace().map(|s| s.to_string()).collect();
        let _ = setaparam("ws", ws);
        let mut a = vec![
            "arguments".to_string(),
            "expl".to_string(),
            descr.clone(),
            "compadd".to_string(),
        ];
        a.extend(subopts.iter().cloned());
        a.extend(sep_group.iter().cloned());
        a.extend(["-a".to_string(), "-".to_string(), "ws".to_string()]);
        let _ = _all_labels(&a);
    } else if action.starts_with('{') && action.ends_with('}') {
        // sh:127-133 — {body} evaluated per label.
        let body = &action[1..action.len() - 1];
        loop {
            if _next_label(&[
                "arguments".to_string(),
                "expl".to_string(),
                descr.clone(),
            ]) != 0
            {
                break;
            }
            let _ = execute_script(body);
        }
    } else if action.starts_with(' ') {
        // sh:134-141 — leading space: call the words directly (no expl).
        let parts: Vec<String> = action.split_whitespace().map(|s| s.to_string()).collect();
        loop {
            if _next_label(&[
                "arguments".to_string(),
                "expl".to_string(),
                descr.clone(),
            ]) != 0
            {
                break;
            }
            if let Some((cmd, rest)) = parts.split_first() {
                if cmd == "compadd" {
                    bin_compadd("compadd", rest, &make_ops(), 0);
                } else {
                    let _ = dispatch_function_call(cmd, rest);
                }
            }
        }
    } else {
        // sh:142-149 — call `cmd subopts expl rest…` per label.
        let parts: Vec<String> = action.split_whitespace().map(|s| s.to_string()).collect();
        if let Some((cmd, rest)) = parts.split_first() {
            loop {
                if _next_label(&[
                    "arguments".to_string(),
                    "expl".to_string(),
                    descr.clone(),
                ]) != 0
                {
                    break;
                }
                let expl_now = getaparam("expl").unwrap_or_default();
                let mut call = subopts.clone();
                call.extend(expl_now);
                call.extend(rest.iter().cloned());
                if cmd == "compadd" {
                    bin_compadd("compadd", &call, &make_ops(), 0);
                } else {
                    let _ = dispatch_function_call(cmd, &call);
                }
            }
        }
    }

    // sh:153
    let _ = setsparam("curcontext", &oldcontext);
    // sh:155 — success iff matches were added since the caller's `nm`.
    let nm = getiparam("nm");
    let nmatches = get_compstate_str("nmatches")
        .and_then(|s| s.parse::<i64>().ok())
        .unwrap_or(0);
    if nm != nmatches {
        0
    } else {
        1
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn empty_returns_one() {
        let _g = crate::test_util::global_state_lock();
        // No specs → `compvalues -i` fails (also gated on incompfunc);
        // outer else clears curcontext and returns 1.
        assert_eq!(_values(&[]), 1);
    }

    #[test]
    fn compvalues_i_failure_returns_one() {
        let _g = crate::test_util::global_state_lock();
        // Outside a completion function `compvalues -i` reports "can only
        // be called from completion function" and returns 1, so `_values`
        // takes the sh:156 else branch and returns 1 without ever
        // reaching `_alternative` (the old reimplementation's crash path).
        let r = _values(&[
            "-s".to_string(),
            ",".to_string(),
            "option name".to_string(),
            "alpha[first]:arg:(a b)".to_string(),
            "beta".to_string(),
        ]);
        assert_eq!(r, 1);
    }

    #[test]
    fn flag_parse_consumes_options_before_specs() {
        let _g = crate::test_util::global_state_lock();
        // `-C`, `-s SEP`, `-S SEP`, `-w` and a garbage `-V x` must all be
        // consumed; the run still bottoms out at the `compvalues -i`
        // incompfunc gate and returns 1. This pins that option parsing
        // never treats a spec as a flag (or vice versa).
        let r = _values(&[
            "-C".to_string(),
            "-s".to_string(),
            ",".to_string(),
            "-S".to_string(),
            "=".to_string(),
            "-w".to_string(),
            "-V".to_string(),
            "grp".to_string(),
            "descr".to_string(),
            "val:msg:action".to_string(),
        ]);
        assert_eq!(r, 1);
    }

    #[test]
    fn helper_replace_last_field() {
        assert_eq!(replace_last_field(":a:b:c", "x"), ":a:b:x");
        assert_eq!(replace_last_field("noc", "x"), "noc:x");
    }

    #[test]
    fn helper_prefix_is_arg() {
        // Unrestricted: any argsep present.
        assert!(prefix_is_arg("name=val", "=", false, ""));
        assert!(!prefix_is_arg("noeq", "=", false, ""));
        // Restricted: tail after last argsep must have no list separator.
        assert!(prefix_is_arg("name=val", "=", true, ","));
        assert!(!prefix_is_arg("name=a,b", "=", true, ","));
        // Trailing argsep → empty tail matches.
        assert!(prefix_is_arg("name=", "=", true, ","));
    }

    #[test]
    fn helper_before_after_first() {
        assert_eq!(before_first("a=b=c", "="), "a");
        assert_eq!(after_first("a=b=c", "="), "b=c");
        assert_eq!(before_first("noeq", "="), "noeq");
        assert_eq!(after_first("noeq", "="), "noeq");
    }
}