rust_args_parser/
lib.rs

1#![forbid(unsafe_code)]
2#![warn(missing_docs)]
3
4//! SPDX-License-Identifier: MIT or Apache-2.0
5//! rust-args-parser — Tiny, fast, callback-based CLI argument parser for Rust inspired by
6//! <https://github.com/milchinskiy/c-args-parser>
7
8use std::env;
9use std::fmt::{self, Write as _};
10use std::io::{self, Write};
11
12/* ================================ Public API ================================= */
13type BoxError = Box<dyn std::error::Error>;
14/// Library level error type.
15pub type Result<T> = std::result::Result<T, Error>;
16
17/// Each option/flag invokes a callback.
18pub type OptCallback<Ctx> =
19    for<'a> fn(Option<&'a str>, &mut Ctx) -> std::result::Result<(), BoxError>;
20
21/// Command runner for the resolved command (receives final positionals).
22pub type RunCallback<Ctx> = fn(&[&str], &mut Ctx) -> std::result::Result<(), BoxError>;
23
24/// Whether the option takes a value or not.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum ArgKind {
27    /// No value required
28    None,
29    /// Value required
30    Required,
31    /// Value optional
32    Optional,
33}
34
35/// Group mode.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum GroupMode {
38    /// No group
39    None,
40    /// Exclusive group
41    Xor,
42    /// Required one group
43    ReqOne,
44}
45/// Value hint.
46#[derive(Clone, Copy)]
47pub enum ValueHint {
48    /// Any value
49    Any,
50    /// Number
51    Number,
52}
53/// An option/flag specification.
54#[derive(Clone, Copy)]
55pub struct OptSpec<'a, Ctx: ?Sized> {
56    name: &'a str,            // long name without "--"
57    short: Option<char>,      // short form without '-'
58    arg: ArgKind,             // whether it takes a value
59    metavar: Option<&'a str>, // shown in help for value
60    help: &'a str,            // help text
61    env: Option<&'a str>,     // environment variable default (name)
62    default: Option<&'a str>, // string default
63    group_id: u16,            // 0 = none, >0 = group identifier
64    group_mode: GroupMode,    // XOR / REQ_ONE semantics
65    value_hint: ValueHint,    // value hint
66    cb: OptCallback<Ctx>,     // callback on set/apply
67}
68
69impl<'a, Ctx: ?Sized> OptSpec<'a, Ctx> {
70    /// Create a new option.
71    pub const fn new(name: &'a str, cb: OptCallback<Ctx>) -> Self {
72        Self {
73            name,
74            short: None,
75            arg: ArgKind::None,
76            metavar: None,
77            help: "",
78            env: None,
79            default: None,
80            group_id: 0,
81            group_mode: GroupMode::None,
82            value_hint: ValueHint::Any,
83            cb,
84        }
85    }
86    /// Set value hint
87    #[must_use]
88    pub const fn numeric(mut self) -> Self {
89        self.value_hint = ValueHint::Number;
90        self
91    }
92    /// Set short form.
93    #[must_use]
94    pub const fn short(mut self, short: char) -> Self {
95        self.short = Some(short);
96        self
97    }
98    /// Set metavar.
99    #[must_use]
100    pub const fn metavar(mut self, metavar: &'a str) -> Self {
101        self.metavar = Some(metavar);
102        self
103    }
104    /// Set help text.
105    #[must_use]
106    pub const fn help(mut self, help: &'a str) -> Self {
107        self.help = help;
108        self
109    }
110    /// Set argument kind.
111    #[must_use]
112    pub const fn arg(mut self, arg: ArgKind) -> Self {
113        self.arg = arg;
114        self
115    }
116    /// Set optional
117    #[must_use]
118    pub const fn optional(mut self) -> Self {
119        self.arg = ArgKind::Optional;
120        self
121    }
122    /// Set required
123    #[must_use]
124    pub const fn required(mut self) -> Self {
125        self.arg = ArgKind::Required;
126        self
127    }
128    /// Set flag
129    #[must_use]
130    pub const fn flag(mut self) -> Self {
131        self.arg = ArgKind::None;
132        self
133    }
134    /// Set environment variable name.
135    #[must_use]
136    pub const fn env(mut self, env: &'a str) -> Self {
137        self.env = Some(env);
138        self
139    }
140    /// Set default value.
141    #[must_use]
142    pub const fn default(mut self, val: &'a str) -> Self {
143        self.default = Some(val);
144        self
145    }
146    /// Set at most one state and group identifier.
147    #[must_use]
148    pub const fn at_most_one(mut self, group_id: u16) -> Self {
149        self.group_id = group_id;
150        self.group_mode = GroupMode::Xor;
151        self
152    }
153    /// Set at least one state and group identifier.
154    #[must_use]
155    pub const fn at_least_one(mut self, group_id: u16) -> Self {
156        self.group_id = group_id;
157        self.group_mode = GroupMode::ReqOne;
158        self
159    }
160}
161
162/// Positional argument specification.
163#[derive(Clone, Copy)]
164pub struct PosSpec<'a> {
165    name: &'a str,
166    desc: Option<&'a str>,
167    min: usize,
168    max: usize,
169}
170
171impl<'a> PosSpec<'a> {
172    /// Create a new positional argument.
173    #[must_use]
174    pub const fn new(name: &'a str) -> Self {
175        Self { name, desc: None, min: 0, max: 0 }
176    }
177    /// Set description.
178    #[must_use]
179    pub const fn desc(mut self, desc: &'a str) -> Self {
180        self.desc = Some(desc);
181        self
182    }
183    /// Set one required.
184    #[must_use]
185    pub const fn one(mut self) -> Self {
186        self.min = 1;
187        self.max = 1;
188        self
189    }
190    /// Set any number.
191    #[must_use]
192    pub const fn range(mut self, min: usize, max: usize) -> Self {
193        self.min = min;
194        self.max = max;
195        self
196    }
197}
198
199/// Command specification.
200pub struct CmdSpec<'a, Ctx: ?Sized> {
201    name: Option<&'a str>, // None for root
202    desc: Option<&'a str>,
203    opts: Box<[OptSpec<'a, Ctx>]>,
204    subs: Box<[CmdSpec<'a, Ctx>]>,
205    pos: Box<[PosSpec<'a>]>,
206    aliases: Box<[&'a str]>,
207    run: Option<RunCallback<Ctx>>, // called with positionals
208}
209
210impl<'a, Ctx: ?Sized> CmdSpec<'a, Ctx> {
211    /// Create a new command.
212    /// `name` is `None` for root command.
213    #[must_use]
214    pub fn new(name: Option<&'a str>, run: Option<RunCallback<Ctx>>) -> Self {
215        Self {
216            name,
217            desc: None,
218            opts: Vec::new().into_boxed_slice(),
219            subs: Vec::new().into_boxed_slice(),
220            pos: Vec::new().into_boxed_slice(),
221            aliases: Vec::new().into_boxed_slice(),
222            run,
223        }
224    }
225    /// Set description.
226    #[must_use]
227    pub const fn desc(mut self, desc: &'a str) -> Self {
228        self.desc = Some(desc);
229        self
230    }
231    /// Set options.
232    #[must_use]
233    pub fn opts<S>(mut self, s: S) -> Self
234    where
235        S: Into<Vec<OptSpec<'a, Ctx>>>,
236    {
237        self.opts = s.into().into_boxed_slice();
238        self
239    }
240    /// Set positionals.
241    #[must_use]
242    pub fn pos<S>(mut self, s: S) -> Self
243    where
244        S: Into<Vec<PosSpec<'a>>>,
245    {
246        self.pos = s.into().into_boxed_slice();
247        self
248    }
249    /// Set subcommands.
250    #[must_use]
251    pub fn subs<S>(mut self, s: S) -> Self
252    where
253        S: Into<Vec<Self>>,
254    {
255        self.subs = s.into().into_boxed_slice();
256        self
257    }
258    /// Set aliases.
259    #[must_use]
260    pub fn aliases<S>(mut self, s: S) -> Self
261    where
262        S: Into<Vec<&'a str>>,
263    {
264        self.aliases = s.into().into_boxed_slice();
265        self
266    }
267}
268
269/// Environment configuration
270pub struct Env<'a> {
271    name: &'a str,
272    version: Option<&'a str>,
273    author: Option<&'a str>,
274    auto_help: bool,
275    wrap_cols: usize,
276    color: bool,
277}
278
279impl<'a> Env<'a> {
280    /// Create a new environment.
281    #[must_use]
282    pub const fn new(name: &'a str) -> Self {
283        Self { name, version: None, author: None, auto_help: false, wrap_cols: 0, color: false }
284    }
285    /// Set version.
286    #[must_use]
287    pub const fn version(mut self, version: &'a str) -> Self {
288        self.version = Some(version);
289        self
290    }
291    /// Set author.
292    #[must_use]
293    pub const fn author(mut self, author: &'a str) -> Self {
294        self.author = Some(author);
295        self
296    }
297    /// Set auto help.
298    #[must_use]
299    pub const fn auto_help(mut self, auto_help: bool) -> Self {
300        self.auto_help = auto_help;
301        self
302    }
303    /// Set wrap columns.
304    #[must_use]
305    pub const fn wrap_cols(mut self, wrap_cols: usize) -> Self {
306        self.wrap_cols = wrap_cols;
307        self
308    }
309    /// Set color.
310    #[must_use]
311    pub const fn color(mut self, color: bool) -> Self {
312        self.color = color;
313        self
314    }
315    /// Set auto color.
316    /// Check for `NO_COLOR` env var.
317    #[must_use]
318    pub fn auto_color(mut self) -> Self {
319        self.color = env::var("NO_COLOR").is_err();
320        self
321    }
322}
323
324/// Parse and dispatch starting from `root` using `argv` (not including program name), writing
325/// auto help/version/author output to `out` when triggered.
326/// # Errors
327/// See [`Error`]
328pub fn dispatch_to<Ctx: ?Sized, W: Write>(
329    env: &Env<'_>,
330    root: &CmdSpec<'_, Ctx>,
331    argv: &[&str],
332    context: &mut Ctx,
333    out: &mut W,
334) -> Result<()> {
335    let mut idx = 0usize;
336    let mut cmd = root;
337    let mut chain: Vec<&str> = Vec::new();
338    while idx < argv.len() {
339        if let Some(next) = find_sub(cmd, argv[idx]) {
340            chain.push(argv[idx]);
341            cmd = next;
342            idx += 1;
343        } else {
344            break;
345        }
346    }
347    // If this command defines subcommands but no positional schema,
348    // the next bare token must be a known subcommand; otherwise it's an error.
349    if !cmd.subs.is_empty() && cmd.pos.is_empty() && idx < argv.len() {
350        let tok = argv[idx];
351        if !tok.starts_with('-') && tok != "--" && find_sub(cmd, tok).is_none() {
352            return Err(unknown_command_error(cmd, tok));
353        }
354    }
355    // small counter array parallel to opts
356    let mut gcounts: Vec<u8> = vec![0; cmd.opts.len()];
357    // parse options and collect positionals
358    let mut pos: Vec<&str> = Vec::with_capacity(argv.len().saturating_sub(idx));
359    let mut stop_opts = false;
360    while idx < argv.len() {
361        let tok = argv[idx];
362        if !stop_opts {
363            if tok == "--" {
364                stop_opts = true;
365                idx += 1;
366                continue;
367            }
368            if tok.starts_with("--") {
369                idx += 1;
370                parse_long(env, cmd, tok, &mut idx, argv, context, &mut gcounts, out, &chain)?;
371                continue;
372            }
373            if is_short_like(tok) {
374                idx += 1;
375                parse_short_cluster(
376                    env,
377                    cmd,
378                    tok,
379                    &mut idx,
380                    argv,
381                    context,
382                    &mut gcounts,
383                    out,
384                    &chain,
385                )?;
386                continue;
387            }
388            if !tok.starts_with('-') && tok != "--" && cmd.pos.is_empty() {
389                if let Some(next) = find_sub(cmd, tok) {
390                    apply_env_and_defaults(cmd, context, &mut gcounts)?;
391                    check_groups(cmd, &gcounts)?;
392                    chain.push(tok);
393                    cmd = next;
394                    idx += 1;
395                    gcounts = vec![0; cmd.opts.len()];
396                    pos.clear();
397                    continue;
398                }
399            }
400        }
401        pos.push(tok);
402        idx += 1;
403    }
404    // When no positional schema is declared, any leftover bare token is unexpected.
405    if cmd.pos.is_empty() && !pos.is_empty() {
406        return Err(Error::UnexpectedArgument(pos[0].to_string()));
407    }
408    apply_env_and_defaults(cmd, context, &mut gcounts)?;
409    // strict groups: XOR → ≤1, REQ_ONE → ≥1 (env/defaults count)
410    check_groups(cmd, &gcounts)?;
411    // validate positionals against schema
412    validate_positionals(cmd, &pos)?;
413    // run command
414    if let Some(run) = cmd.run {
415        return run(&pos, context).map_err(Error::Callback);
416    }
417    if env.auto_help {
418        print_help_to(env, cmd, &chain, out);
419    }
420    Err(Error::Exit(1))
421}
422
423/// Default dispatch that prints auto help/version/author to **stdout**.
424/// # Errors
425/// See [`Error`]
426pub fn dispatch<Ctx>(
427    env: &Env<'_>,
428    root: &CmdSpec<'_, Ctx>,
429    argv: &[&str],
430    context: &mut Ctx,
431) -> Result<()> {
432    let mut out = io::stdout();
433    dispatch_to(env, root, argv, context, &mut out)
434}
435
436/* ================================ Suggestions ===================================== */
437#[inline]
438fn format_alternates(items: &[String]) -> String {
439    match items.len() {
440        0 => String::new(),
441        1 => format!("'{}'", items[0]),
442        2 => format!("'{}' or '{}'", items[0], items[1]),
443        _ => {
444            // 'a', 'b', or 'c'
445            let mut s = String::new();
446            for (i, it) in items.iter().enumerate() {
447                if i > 0 {
448                    s.push_str(if i + 1 == items.len() { ", or " } else { ", " });
449                }
450                s.push('\'');
451                s.push_str(it);
452                s.push('\'');
453            }
454            s
455        }
456    }
457}
458
459#[inline]
460const fn max_distance_for(len: usize) -> usize {
461    match len {
462        0..=3 => 1,
463        4..=6 => 2,
464        _ => 3,
465    }
466}
467
468// Simple Levenshtein (O(n*m)); inputs are tiny (flags/commands), so it's fine.
469fn lev(a: &str, b: &str) -> usize {
470    let (na, nb) = (a.len(), b.len());
471    if na == 0 {
472        return nb;
473    }
474    if nb == 0 {
475        return na;
476    }
477    let mut prev: Vec<usize> = (0..=nb).collect();
478    let mut curr = vec![0; nb + 1];
479    for (i, ca) in a.chars().enumerate() {
480        curr[0] = i + 1;
481        for (j, cb) in b.chars().enumerate() {
482            let cost = usize::from(ca != cb);
483            curr[j + 1] = (prev[j + 1] + 1).min(curr[j] + 1).min(prev[j] + cost);
484        }
485        std::mem::swap(&mut prev, &mut curr);
486    }
487    prev[nb]
488}
489
490fn collect_long_candidates<Ctx: ?Sized>(env: &Env<'_>, cmd: &CmdSpec<'_, Ctx>) -> Vec<String> {
491    let mut v = Vec::with_capacity(cmd.opts.len() + 3);
492    if env.auto_help {
493        v.push("help".to_string());
494    }
495    if cmd.name.is_none() {
496        v.push("version".to_string());
497        v.push("author".to_string());
498    }
499    for o in &cmd.opts {
500        v.push(o.name.to_string());
501    }
502    v
503}
504
505fn collect_short_candidates<Ctx: ?Sized>(env: &Env<'_>, cmd: &CmdSpec<'_, Ctx>) -> Vec<char> {
506    let mut v = Vec::with_capacity(cmd.opts.len() + 3);
507    if env.auto_help {
508        v.push('h');
509    }
510    if cmd.name.is_none() {
511        v.push('V');
512        v.push('A');
513    }
514    for o in &cmd.opts {
515        if let Some(ch) = o.short {
516            v.push(ch);
517        }
518    }
519    v
520}
521
522fn collect_cmd_candidates<'a, Ctx: ?Sized>(cmd: &'a CmdSpec<'a, Ctx>) -> Vec<&'a str> {
523    let mut v = Vec::with_capacity(cmd.subs.len());
524    for s in &cmd.subs {
525        if let Some(n) = s.name {
526            v.push(n);
527        }
528        for &al in &s.aliases {
529            v.push(al);
530        }
531    }
532    v
533}
534
535fn suggest_longs<Ctx: ?Sized>(env: &Env<'_>, cmd: &CmdSpec<'_, Ctx>, name: &str) -> Vec<String> {
536    let thr = max_distance_for(name.len());
537    let mut scored: Vec<(usize, String)> = collect_long_candidates(env, cmd)
538        .into_iter()
539        .map(|cand| (lev(name, &cand), format!("--{cand}")))
540        .collect();
541    scored.sort_by_key(|(d, _)| *d);
542    scored.into_iter().take_while(|(d, _)| *d <= thr).take(3).map(|(_, s)| s).collect()
543}
544
545fn suggest_shorts<Ctx: ?Sized>(env: &Env<'_>, cmd: &CmdSpec<'_, Ctx>, ch: char) -> Vec<String> {
546    let mut v: Vec<(usize, String)> = collect_short_candidates(env, cmd)
547        .into_iter()
548        .map(|c| (usize::from(c != ch), format!("-{c}")))
549        .collect();
550    v.sort_by_key(|(d, _)| *d);
551    v.into_iter().take_while(|(d, _)| *d <= 1).take(3).map(|(_, s)| s).collect()
552}
553
554fn suggest_cmds<Ctx: ?Sized>(cmd: &CmdSpec<'_, Ctx>, tok: &str) -> Vec<String> {
555    let thr = max_distance_for(tok.len());
556    let mut v: Vec<(usize, String)> = collect_cmd_candidates(cmd)
557        .into_iter()
558        .map(|cand| (lev(tok, cand), cand.to_string()))
559        .collect();
560    v.sort_by_key(|(d, _)| *d);
561    v.into_iter().take_while(|(d, _)| *d <= thr).take(3).map(|(_, s)| s).collect()
562}
563
564/* ================================ Errors ===================================== */
565#[non_exhaustive]
566#[derive(Debug)]
567/// Error type
568pub enum Error {
569    /// Missing value
570    MissingValue(String),
571    /// Unexpected argument
572    UnexpectedArgument(String),
573    /// Unknown option
574    UnknownOption {
575        /// Token
576        token: String,
577        /// Suggestions
578        suggestions: Vec<String>,
579    },
580    /// Unknown command
581    UnknownCommand {
582        /// Token
583        token: String,
584        /// Suggestions
585        suggestions: Vec<String>,
586    },
587    /// Group violation
588    GroupViolation(String),
589    /// Missing positional
590    MissingPositional(String),
591    /// Too many positionals
592    TooManyPositional(String),
593    /// Callback error
594    Callback(BoxError),
595    /// Exit with code
596    Exit(i32),
597    /// User error
598    User(&'static str),
599}
600impl fmt::Display for Error {
601    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
602        match self {
603            Self::UnknownOption { token, suggestions } => {
604                write!(f, "unknown option: '{token}'")?;
605                if !suggestions.is_empty() {
606                    write!(f, ". Did you mean {}?", format_alternates(suggestions))?;
607                }
608                Ok(())
609            }
610            Self::MissingValue(n) => write!(f, "missing value for --{n}"),
611            Self::UnexpectedArgument(s) => write!(f, "unexpected argument: {s}"),
612            Self::UnknownCommand { token, suggestions } => {
613                write!(f, "unknown command: {token}")?;
614                if !suggestions.is_empty() {
615                    write!(f, ". Did you mean {}?", format_alternates(suggestions))?;
616                }
617                Ok(())
618            }
619            Self::GroupViolation(s) => write!(f, "{s}"),
620            Self::MissingPositional(n) => write!(f, "missing positional: {n}"),
621            Self::TooManyPositional(n) => write!(f, "too many values for: {n}"),
622            Self::Callback(e) => write!(f, "{e}"),
623            Self::Exit(code) => write!(f, "exit {code}"),
624            Self::User(s) => write!(f, "{s}"),
625        }
626    }
627}
628impl std::error::Error for Error {}
629
630/* ================================ Parsing ==================================== */
631fn find_sub<'a, Ctx: ?Sized>(
632    cmd: &'a CmdSpec<'a, Ctx>,
633    name: &str,
634) -> Option<&'a CmdSpec<'a, Ctx>> {
635    for c in &cmd.subs {
636        if let Some(n) = c.name {
637            if n == name {
638                return Some(c);
639            }
640        }
641        if c.aliases.contains(&name) {
642            return Some(c);
643        }
644    }
645    None
646}
647fn apply_env_and_defaults<Ctx: ?Sized>(
648    cmd: &CmdSpec<'_, Ctx>,
649    context: &mut Ctx,
650    counts: &mut [u8],
651) -> Result<()> {
652    if cmd.opts.is_empty() {
653        return Ok(());
654    }
655    if !any_env_or_default(cmd) {
656        return Ok(());
657    }
658
659    // env first
660    for (i, o) in cmd.opts.iter().enumerate() {
661        if let Some(key) = o.env {
662            if let Ok(val) = std::env::var(key) {
663                counts[i] = counts[i].saturating_add(1);
664                (o.cb)(Some(val.as_str()), context).map_err(Error::Callback)?;
665            }
666        }
667    }
668    // defaults next; skip if anything in the same group is already present
669    for (i, o) in cmd.opts.iter().enumerate() {
670        if counts[i] != 0 {
671            continue;
672        }
673        let Some(def) = o.default else { continue };
674        if o.group_id != 0 {
675            let gid = o.group_id;
676            let mut taken = false;
677            for (j, p) in cmd.opts.iter().enumerate() {
678                if p.group_id == gid && counts[j] != 0 {
679                    taken = true;
680                    break;
681                }
682            }
683            if taken {
684                continue;
685            }
686        }
687        counts[i] = counts[i].saturating_add(1);
688        (o.cb)(Some(def), context).map_err(Error::Callback)?;
689    }
690    Ok(())
691}
692
693#[allow(clippy::too_many_arguments)]
694fn parse_long<Ctx: ?Sized, W: std::io::Write>(
695    env: &Env<'_>,
696    cmd: &CmdSpec<'_, Ctx>,
697    tok: &str,
698    idx: &mut usize,
699    argv: &[&str],
700    context: &mut Ctx,
701    counts: &mut [u8],
702    out: &mut W,
703    chain: &[&str],
704) -> Result<()> {
705    // formats: --name, --name=value, --name value
706    let s = &tok[2..];
707    let (name, attached) = s
708        .as_bytes()
709        .iter()
710        .position(|&b| b == b'=')
711        .map_or((s, None), |eq| (&s[..eq], Some(&s[eq + 1..])));
712    // built‑ins
713    if env.auto_help && name == "help" {
714        print_help_to(env, cmd, chain, out);
715        return Err(Error::Exit(0));
716    }
717    if cmd.name.is_none() {
718        if env.version.is_some() && name == "version" {
719            print_version_to(env, out);
720            return Err(Error::Exit(0));
721        }
722        if env.author.is_some() && name == "author" {
723            print_author_to(env, out);
724            return Err(Error::Exit(0));
725        }
726    }
727    let (i, spec) = match cmd.opts.iter().enumerate().find(|(_, o)| o.name == name) {
728        Some(x) => x,
729        None => return Err(unknown_long_error(env, cmd, name)),
730    };
731    counts[i] = counts[i].saturating_add(1);
732    match spec.arg {
733        ArgKind::None => {
734            (spec.cb)(None, context).map_err(Error::Callback)?;
735        }
736        ArgKind::Required => {
737            let v = if let Some(a) = attached {
738                if a.is_empty() {
739                    return Err(Error::MissingValue(spec.name.to_string()));
740                }
741                a
742            } else {
743                take_next(idx, argv).ok_or_else(|| Error::MissingValue(spec.name.to_string()))?
744            };
745            (spec.cb)(Some(v), context).map_err(Error::Callback)?;
746        }
747        ArgKind::Optional => {
748            let v = match (attached, argv.get(*idx).copied()) {
749                (Some(a), _) => Some(a),
750                (None, Some("-")) => {
751                    *idx += 1; // consume standalone "-" but treat as none
752                    None
753                }
754                (None, Some(n))
755                    if matches!(spec.value_hint, ValueHint::Number) && is_dash_number(n) =>
756                {
757                    *idx += 1;
758                    Some(n)
759                }
760                (None, Some(n)) if looks_value_like(n) => {
761                    *idx += 1;
762                    Some(n)
763                }
764                _ => None,
765            };
766            (spec.cb)(v, context).map_err(Error::Callback)?;
767        }
768    }
769    Ok(())
770}
771
772#[allow(clippy::too_many_arguments)]
773fn parse_short_cluster<Ctx: ?Sized, W: std::io::Write>(
774    env: &Env<'_>,
775    cmd: &CmdSpec<'_, Ctx>,
776    tok: &str,
777    idx: &mut usize,
778    argv: &[&str],
779    context: &mut Ctx,
780    counts: &mut [u8],
781    out: &mut W,
782    chain: &[&str],
783) -> Result<()> {
784    // formats: -abc, -j10, -j 10, -j -12  (no -j=10 by design)
785    let short_idx = build_short_idx(cmd);
786    let s = &tok[1..];
787    let bytes = s.as_bytes();
788    let mut i = 0usize;
789    while i < bytes.len() {
790        // Fast ASCII path for common cases; fall back to UTF‑8 char boundary when needed.
791        let (ch, adv) = if bytes[i] < 128 {
792            (bytes[i] as char, 1)
793        } else {
794            let c = s[i..].chars().next().unwrap();
795            (c, c.len_utf8())
796        };
797        i += adv;
798
799        // built‑ins
800        if env.auto_help && ch == 'h' {
801            print_help_to(env, cmd, chain, out);
802            return Err(Error::Exit(0));
803        }
804        if cmd.name.is_none() {
805            if env.version.is_some() && ch == 'V' {
806                print_version_to(env, out);
807                return Err(Error::Exit(0));
808            }
809            if env.author.is_some() && ch == 'A' {
810                print_author_to(env, out);
811                return Err(Error::Exit(0));
812            }
813        }
814        let (oi, spec) = match lookup_short(cmd, &short_idx, ch) {
815            Some(x) => x,
816            None => return Err(unknown_short_error(env, cmd, ch)),
817        };
818        counts[oi] = counts[oi].saturating_add(1);
819        match spec.arg {
820            ArgKind::None => {
821                (spec.cb)(None, context).map_err(Error::Callback)?;
822            }
823            ArgKind::Required => {
824                if i < s.len() {
825                    let rem = &s[i..];
826                    (spec.cb)(Some(rem), context).map_err(Error::Callback)?;
827                    return Ok(());
828                }
829                let v = take_next(idx, argv)
830                    .ok_or_else(|| Error::MissingValue(spec.name.to_string()))?;
831                (spec.cb)(Some(v), context).map_err(Error::Callback)?;
832                return Ok(());
833            }
834            ArgKind::Optional => {
835                if i < s.len() {
836                    let rem = &s[i..];
837                    (spec.cb)(Some(rem), context).map_err(Error::Callback)?;
838                    return Ok(());
839                }
840                // SPECIAL: if next token is exactly "-", CONSUME it but treat as "no value".
841                let v = match argv.get(*idx) {
842                    Some(&"-") => {
843                        *idx += 1;
844                        None
845                    }
846                    // If hint is Number, allow a directly following numeric like `-j -1.25`.
847                    Some(n)
848                        if matches!(spec.value_hint, ValueHint::Number) && is_dash_number(n) =>
849                    {
850                        *idx += 1;
851                        Some(n)
852                    }
853                    // Otherwise consume if it looks like a plausible value (incl. -1, -0.5, 1e3…)
854                    Some(n) if looks_value_like(n) => {
855                        *idx += 1;
856                        Some(n)
857                    }
858                    _ => None,
859                };
860                (spec.cb)(v.map(|v| &**v), context).map_err(Error::Callback)?;
861                return Ok(());
862            }
863        }
864    }
865    Ok(())
866}
867
868#[inline]
869fn any_env_or_default<Ctx: ?Sized>(cmd: &CmdSpec<'_, Ctx>) -> bool {
870    cmd.opts.iter().any(|o| o.env.is_some() || o.default.is_some())
871}
872#[inline]
873fn take_next<'a>(idx: &mut usize, argv: &'a [&'a str]) -> Option<&'a str> {
874    let i = *idx;
875    if i < argv.len() {
876        *idx = i + 1;
877        Some(argv[i])
878    } else {
879        None
880    }
881}
882#[inline]
883fn is_short_like(s: &str) -> bool {
884    let b = s.as_bytes();
885    b.len() >= 2 && b[0] == b'-' && b[1] != b'-'
886}
887#[inline]
888fn is_dash_number(s: &str) -> bool {
889    let b = s.as_bytes();
890    if b.is_empty() || b[0] != b'-' {
891        return false;
892    }
893    // "-" alone is not a number
894    if b.len() == 1 {
895        return false;
896    }
897    is_numeric_like(&b[1..])
898}
899#[inline]
900fn looks_value_like(s: &str) -> bool {
901    if !s.starts_with('-') {
902        return true;
903    }
904    if s == "-" {
905        return false;
906    }
907    is_numeric_like(&s.as_bytes()[1..])
908}
909#[inline]
910fn is_numeric_like(b: &[u8]) -> bool {
911    // digits, optional dot, optional exponent part
912    let mut i = 0;
913    let n = b.len();
914    // optional leading dot: .5
915    if i < n && b[i] == b'.' {
916        i += 1;
917    }
918    // at least one digit
919    let mut nd = 0;
920    while i < n && (b[i] as char).is_ascii_digit() {
921        i += 1;
922        nd += 1;
923    }
924    if nd == 0 {
925        return false;
926    }
927    // optional fractional part .ddd
928    if i < n && b[i] == b'.' {
929        i += 1;
930        while i < n && (b[i] as char).is_ascii_digit() {
931            i += 1;
932        }
933    }
934    // optional exponent e[+/-]ddd
935    if i < n && (b[i] == b'e' || b[i] == b'E') {
936        i += 1;
937        if i < n && (b[i] == b'+' || b[i] == b'-') {
938            i += 1;
939        }
940        let mut ed = 0;
941        while i < n && (b[i] as char).is_ascii_digit() {
942            i += 1;
943            ed += 1;
944        }
945        if ed == 0 {
946            return false;
947        }
948    }
949    i == n
950}
951
952fn check_groups<Ctx: ?Sized>(cmd: &CmdSpec<'_, Ctx>, counts: &[u8]) -> Result<()> {
953    let opts = &cmd.opts;
954    let opts_len = opts.len();
955    let mut index = 0usize;
956    while index < opts_len {
957        let id = opts[index].group_id;
958        if id != 0 {
959            // ensure we only process each id once
960            let mut seen = false;
961            let mut k = 0usize;
962            while k < index {
963                if opts[k].group_id == id {
964                    seen = true;
965                    break;
966                }
967                k += 1;
968            }
969            if !seen {
970                let mut total = 0u32;
971                let mut xor = false;
972                let mut req = false;
973                let mut j = 0usize;
974                while j < opts_len {
975                    let o = &opts[j];
976                    if o.group_id == id {
977                        total += u32::from(counts[j]);
978                        match o.group_mode {
979                            GroupMode::Xor => xor = true,
980                            GroupMode::ReqOne => req = true,
981                            GroupMode::None => {}
982                        }
983                        if xor && total > 1 {
984                            return Err(Error::GroupViolation(group_msg(opts, id, true)));
985                        }
986                    }
987                    j += 1;
988                }
989                if req && total == 0 {
990                    return Err(Error::GroupViolation(group_msg(opts, id, false)));
991                }
992            }
993        }
994        index += 1;
995    }
996    Ok(())
997}
998
999#[cold]
1000#[inline(never)]
1001fn group_msg<Ctx: ?Sized>(opts: &[OptSpec<'_, Ctx>], id: u16, xor: bool) -> String {
1002    let mut names = String::new();
1003    for o in opts.iter().filter(|o| o.group_id == id) {
1004        if !names.is_empty() {
1005            names.push_str(" | ");
1006        }
1007        names.push_str(o.name);
1008    }
1009    if xor {
1010        format!("at most one of the following options may be used: {names}")
1011    } else {
1012        format!("one of the following options is required: {names}")
1013    }
1014}
1015
1016fn validate_positionals<Ctx: ?Sized>(cmd: &CmdSpec<'_, Ctx>, pos: &[&str]) -> Result<()> {
1017    if cmd.pos.is_empty() {
1018        return Ok(());
1019    }
1020    let total = pos.len();
1021    let mut min_sum: usize = 0;
1022    let mut max_sum: Option<usize> = Some(0);
1023    for p in &cmd.pos {
1024        min_sum = min_sum.saturating_add(p.min);
1025        if let Some(ms) = max_sum {
1026            if p.max == usize::MAX {
1027                max_sum = None;
1028            } else {
1029                max_sum = Some(ms.saturating_add(p.max));
1030            }
1031        }
1032    }
1033    // Not enough arguments: find the first positional whose minimum cannot be met
1034    if total < min_sum {
1035        let mut need = 0usize;
1036        for p in &cmd.pos {
1037            need = need.saturating_add(p.min);
1038            if total < need {
1039                return Err(Error::MissingPositional(p.name.to_string()));
1040            }
1041        }
1042        // Fallback (should be unreachable)
1043        return Err(Error::MissingPositional(
1044            cmd.pos.first().map_or("<args>", |p| p.name).to_string(),
1045        ));
1046    }
1047    // Too many arguments (only when all maxima are finite)
1048    if let Some(ms) = max_sum {
1049        if total > ms {
1050            let last = cmd.pos.last().map_or("<args>", |p| p.name);
1051            return Err(Error::TooManyPositional(last.to_string()));
1052        }
1053    }
1054    Ok(())
1055}
1056#[inline]
1057const fn plain_opt_label_len<Ctx: ?Sized>(o: &OptSpec<'_, Ctx>) -> usize {
1058    let mut len = if o.short.is_some() { 4 } else { 0 }; // "-x, "
1059    len += 2 + o.name.len(); // "--" + name
1060    if let Some(m) = o.metavar {
1061        len += 1 + m.len();
1062    }
1063    len
1064}
1065#[inline]
1066fn make_opt_label<Ctx: ?Sized>(o: &OptSpec<'_, Ctx>) -> String {
1067    let mut s = String::new();
1068    if let Some(ch) = o.short {
1069        s.push('-');
1070        s.push(ch);
1071        s.push(',');
1072        s.push(' ');
1073    }
1074    s.push_str("--");
1075    s.push_str(o.name);
1076    if let Some(m) = o.metavar {
1077        s.push(' ');
1078        s.push_str(m);
1079    }
1080    s
1081}
1082
1083/* ================================ Help ======================================= */
1084const C_BOLD: &str = "\u{001b}[1m";
1085const C_UNDERLINE: &str = "\u{001b}[4m";
1086const C_BRIGHT_WHITE: &str = "\u{001b}[97m";
1087const C_CYAN: &str = "\u{001b}[36m";
1088const C_MAGENTA: &str = "\u{001b}[35m";
1089const C_YELLOW: &str = "\u{001b}[33m";
1090const C_RESET: &str = "\u{001b}[0m";
1091#[inline]
1092fn colorize(s: &str, color: &str, env: &Env) -> String {
1093    if !env.color || color.is_empty() {
1094        s.to_string()
1095    } else {
1096        format!("{color}{s}{C_RESET}")
1097    }
1098}
1099#[inline]
1100fn help_text_for_opt<Ctx: ?Sized>(o: &OptSpec<'_, Ctx>) -> String {
1101    match (o.env, o.default) {
1102        (Some(k), Some(d)) => format!("{} (env {k}, default={d})", o.help),
1103        (Some(k), None) => format!("{} (env {k})", o.help),
1104        (None, Some(d)) => format!("{} (default={d})", o.help),
1105        (None, None) => o.help.to_string(),
1106    }
1107}
1108#[inline]
1109fn print_header(buf: &mut String, text: &str, env: &Env) {
1110    let _ = writeln!(buf, "\n{}:", colorize(text, &[C_BOLD, C_UNDERLINE].concat(), env).as_str());
1111}
1112#[inline]
1113fn lookup_short<'a, Ctx: ?Sized>(
1114    cmd: &'a CmdSpec<'a, Ctx>,
1115    table: &[u16; 128],
1116    ch: char,
1117) -> Option<(usize, &'a OptSpec<'a, Ctx>)> {
1118    let c = ch as u32;
1119    if c < 128 {
1120        let i = table[c as usize];
1121        if i != u16::MAX {
1122            let idx = i as usize;
1123            return Some((idx, &cmd.opts[idx]));
1124        }
1125        return None;
1126    }
1127    cmd.opts.iter().enumerate().find(|(_, o)| o.short == Some(ch))
1128}
1129fn build_short_idx<Ctx: ?Sized>(cmd: &CmdSpec<'_, Ctx>) -> [u16; 128] {
1130    let mut map = [u16::MAX; 128];
1131    let mut i = 0usize;
1132    let len = cmd.opts.len();
1133    while i < len {
1134        let o = &cmd.opts[i];
1135        if let Some(ch) = o.short {
1136            let cu = ch as usize;
1137            if cu < 128 {
1138                debug_assert!(u16::try_from(i).is_ok());
1139                map[cu] = u16::try_from(i).unwrap_or(0); // safe due to debug assert
1140            }
1141        }
1142        i += 1;
1143    }
1144    map
1145}
1146#[inline]
1147fn write_wrapped(buf: &mut String, text: &str, indent_cols: usize, wrap_cols: usize) {
1148    if wrap_cols == 0 {
1149        let _ = writeln!(buf, "{text}");
1150        return;
1151    }
1152    let mut col = indent_cols;
1153    let mut first = true;
1154    for word in text.split_whitespace() {
1155        let wlen = word.len();
1156        if first {
1157            buf.push_str(word);
1158            col = indent_cols + wlen;
1159            first = false;
1160            continue;
1161        }
1162        if col + 1 + wlen > wrap_cols {
1163            buf.push('\n');
1164            for _ in 0..indent_cols {
1165                buf.push(' ');
1166            }
1167            buf.push_str(word);
1168            col = indent_cols + wlen;
1169        } else {
1170            buf.push(' ');
1171            buf.push_str(word);
1172            col += 1 + wlen;
1173        }
1174    }
1175    buf.push('\n');
1176}
1177
1178fn write_row(
1179    buf: &mut String,
1180    env: &Env,
1181    color: &str,
1182    plain_label: &str,
1183    help: &str,
1184    label_col: usize,
1185) {
1186    let _ = write!(buf, "  {}", colorize(plain_label, color, env));
1187    let pad = label_col.saturating_sub(plain_label.len());
1188    for _ in 0..pad {
1189        buf.push(' ');
1190    }
1191    buf.push(' ');
1192    buf.push(' ');
1193    let indent = 4 + label_col;
1194    write_wrapped(buf, help, indent, env.wrap_cols);
1195}
1196
1197/// Print help to the provided writer.
1198#[cold]
1199#[inline(never)]
1200pub fn print_help_to<Ctx: ?Sized, W: Write>(
1201    env: &Env<'_>,
1202    cmd: &CmdSpec<'_, Ctx>,
1203    path: &[&str],
1204    mut out: W,
1205) {
1206    let mut buf = String::new();
1207    let _ = write!(
1208        buf,
1209        "Usage: {}",
1210        colorize(env.name, [C_BOLD, C_BRIGHT_WHITE].concat().as_str(), env)
1211    );
1212    for tok in path {
1213        let _ = write!(buf, " {}", colorize(tok, C_MAGENTA, env));
1214    }
1215    let has_subs = !cmd.subs.is_empty();
1216    let has_opts = !cmd.opts.is_empty()
1217        || env.auto_help
1218        || (path.is_empty() && (env.version.is_some() || env.author.is_some()));
1219    if has_opts && has_subs {
1220        let _ = write!(buf, " {}", colorize("[options]", C_CYAN, env));
1221        let _ = write!(buf, " {}", colorize("<command>", C_MAGENTA, env));
1222    } else if !has_opts && has_subs {
1223        let _ = write!(buf, " {}", colorize("<command>", C_MAGENTA, env));
1224    } else if has_opts && !has_subs {
1225        let _ = write!(buf, " {}", colorize("[options]", C_CYAN, env));
1226    }
1227    for p in &cmd.pos {
1228        if p.min == 0 {
1229            let _ = write!(buf, " [{}]", colorize(p.name, C_YELLOW, env));
1230        } else if p.min == 1 && p.max == 1 {
1231            let _ = write!(buf, " {}", colorize(p.name, C_YELLOW, env));
1232        } else if p.max > 1 {
1233            let _ = write!(buf, " {}...", colorize(p.name, C_YELLOW, env));
1234        }
1235    }
1236    let _ = writeln!(buf);
1237    if let Some(desc) = cmd.desc {
1238        let _ = writeln!(buf, "\n{desc}");
1239    }
1240    if !cmd.opts.is_empty()
1241        || env.auto_help
1242        || (cmd.name.is_none() && (env.version.is_some() || env.author.is_some()))
1243    {
1244        print_header(&mut buf, "Options", env);
1245        let mut width = 0usize;
1246        if env.auto_help {
1247            width = width.max("-h, --help".len());
1248        }
1249        // if cmd is a root command
1250        if cmd.name.is_none() {
1251            if env.version.is_some() {
1252                width = width.max("-V, --version".len());
1253            }
1254            if env.author.is_some() {
1255                width = width.max("-A, --author".len());
1256            }
1257        }
1258        for o in &cmd.opts {
1259            width = width.max(plain_opt_label_len(o));
1260        }
1261        if env.auto_help {
1262            write_row(&mut buf, env, C_CYAN, "-h, --help", "Show this help and exit", width);
1263        }
1264        // if cmd is a root command
1265        if cmd.name.is_none() {
1266            if env.version.is_some() {
1267                write_row(&mut buf, env, C_CYAN, "-V, --version", "Show version and exit", width);
1268            }
1269            if env.author.is_some() {
1270                write_row(&mut buf, env, C_CYAN, "--author", "Show author and exit", width);
1271            }
1272        }
1273        for o in &cmd.opts {
1274            let label = make_opt_label(o);
1275            let help = help_text_for_opt(o);
1276            write_row(&mut buf, env, C_CYAN, &label, &help, width);
1277        }
1278    }
1279    // Commands
1280    if !cmd.subs.is_empty() {
1281        print_header(&mut buf, "Commands", env);
1282        let width = cmd.subs.iter().map(|s| s.name.unwrap_or("<root>").len()).max().unwrap_or(0);
1283        for s in &cmd.subs {
1284            let name = s.name.unwrap_or("<root>");
1285            write_row(&mut buf, env, C_MAGENTA, name, s.desc.unwrap_or(""), width);
1286        }
1287    }
1288    // Positionals
1289    if !cmd.pos.is_empty() {
1290        print_header(&mut buf, "Positionals", env);
1291        let width = cmd.pos.iter().map(|p| p.name.len()).max().unwrap_or(0);
1292        for p in &cmd.pos {
1293            let help = help_for_pos(p);
1294            write_row(&mut buf, env, C_YELLOW, p.name, &help, width);
1295        }
1296    }
1297    let _ = out.write_all(buf.as_bytes());
1298}
1299fn help_for_pos(p: &PosSpec) -> String {
1300    if let Some(d) = p.desc {
1301        return d.to_string();
1302    }
1303    if p.min == 0 {
1304        return "(optional)".to_string();
1305    }
1306    if p.min == 1 && p.max == 1 {
1307        return "(required)".to_string();
1308    }
1309    if p.min == 1 {
1310        return "(at least one required)".to_string();
1311    }
1312    format!("min={} max={}", p.min, p.max)
1313}
1314/// Prints the version number
1315#[cold]
1316#[inline(never)]
1317pub fn print_version_to<W: Write>(env: &Env<'_>, mut out: W) {
1318    if let Some(v) = env.version {
1319        let _ = writeln!(out, "{v}");
1320    }
1321}
1322/// Prints the author
1323#[cold]
1324#[inline(never)]
1325pub fn print_author_to<W: Write>(env: &Env<'_>, mut out: W) {
1326    if let Some(a) = env.author {
1327        let _ = writeln!(out, "{a}");
1328    }
1329}
1330#[cold]
1331#[inline(never)]
1332fn unknown_long_error<Ctx: ?Sized>(env: &Env<'_>, cmd: &CmdSpec<'_, Ctx>, name: &str) -> Error {
1333    let token = {
1334        let mut s = String::with_capacity(2 + name.len());
1335        s.push_str("--");
1336        s.push_str(name);
1337        s
1338    };
1339    let suggestions = suggest_longs(env, cmd, name);
1340    Error::UnknownOption { token, suggestions }
1341}
1342
1343#[cold]
1344#[inline(never)]
1345fn unknown_short_error<Ctx: ?Sized>(env: &Env<'_>, cmd: &CmdSpec<'_, Ctx>, ch: char) -> Error {
1346    let mut token = String::with_capacity(2);
1347    token.push('-');
1348    token.push(ch);
1349    let suggestions = suggest_shorts(env, cmd, ch);
1350    Error::UnknownOption { token, suggestions }
1351}
1352
1353#[cold]
1354#[inline(never)]
1355fn unknown_command_error<Ctx: ?Sized>(cmd: &CmdSpec<'_, Ctx>, tok: &str) -> Error {
1356    Error::UnknownCommand { token: tok.to_string(), suggestions: suggest_cmds(cmd, tok) }
1357}