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            if !cmd.opts.is_empty() {
341                let mut tmp = vec![0u8; cmd.opts.len()];
342                apply_env_and_defaults(cmd, context, &mut tmp)?;
343                check_groups(cmd, &tmp)?;
344            }
345            chain.push(argv[idx]);
346            cmd = next;
347            idx += 1;
348        } else {
349            break;
350        }
351    }
352    // If this command defines subcommands but no positional schema,
353    // the next bare token must be a known subcommand; otherwise it's an error.
354    if !cmd.subs.is_empty() && cmd.pos.is_empty() && idx < argv.len() {
355        let tok = argv[idx];
356        if !tok.starts_with('-') && tok != "--" && find_sub(cmd, tok).is_none() {
357            return Err(unknown_command_error(cmd, tok));
358        }
359    }
360    // small counter array parallel to opts
361    let mut gcounts: Vec<u8> = vec![0; cmd.opts.len()];
362    // parse options and collect positionals
363    let mut pos: Vec<&str> = Vec::with_capacity(argv.len().saturating_sub(idx));
364    let mut stop_opts = false;
365    while idx < argv.len() {
366        let tok = argv[idx];
367        if !stop_opts {
368            if tok == "--" {
369                stop_opts = true;
370                idx += 1;
371                continue;
372            }
373            if tok.starts_with("--") {
374                idx += 1;
375                parse_long(env, cmd, tok, &mut idx, argv, context, &mut gcounts, out, &chain)?;
376                continue;
377            }
378            if is_short_like(tok) {
379                idx += 1;
380                parse_short_cluster(
381                    env,
382                    cmd,
383                    tok,
384                    &mut idx,
385                    argv,
386                    context,
387                    &mut gcounts,
388                    out,
389                    &chain,
390                )?;
391                continue;
392            }
393            if !tok.starts_with('-') && tok != "--" && cmd.pos.is_empty() {
394                if let Some(next) = find_sub(cmd, tok) {
395                    apply_env_and_defaults(cmd, context, &mut gcounts)?;
396                    check_groups(cmd, &gcounts)?;
397                    chain.push(tok);
398                    cmd = next;
399                    idx += 1;
400                    gcounts = vec![0; cmd.opts.len()];
401                    pos.clear();
402                    continue;
403                }
404            }
405        }
406        pos.push(tok);
407        idx += 1;
408    }
409    // When no positional schema is declared, any leftover bare token is unexpected.
410    if cmd.pos.is_empty() && !pos.is_empty() {
411        return Err(Error::UnexpectedArgument(pos[0].to_string()));
412    }
413    apply_env_and_defaults(cmd, context, &mut gcounts)?;
414    // strict groups: XOR → ≤1, REQ_ONE → ≥1 (env/defaults count)
415    check_groups(cmd, &gcounts)?;
416    // validate positionals against schema
417    validate_positionals(cmd, &pos)?;
418    // run command
419    if let Some(run) = cmd.run {
420        return run(&pos, context).map_err(Error::Callback);
421    }
422    if env.auto_help {
423        print_help_to(env, cmd, &chain, out);
424    }
425    Err(Error::Exit(1))
426}
427
428/// Default dispatch that prints auto help/version/author to **stdout**.
429/// # Errors
430/// See [`Error`]
431pub fn dispatch<Ctx>(
432    env: &Env<'_>,
433    root: &CmdSpec<'_, Ctx>,
434    argv: &[&str],
435    context: &mut Ctx,
436) -> Result<()> {
437    let mut out = io::stdout();
438    dispatch_to(env, root, argv, context, &mut out)
439}
440
441/* ================================ Suggestions ===================================== */
442#[inline]
443fn format_alternates(items: &[String]) -> String {
444    match items.len() {
445        0 => String::new(),
446        1 => format!("'{}'", items[0]),
447        2 => format!("'{}' or '{}'", items[0], items[1]),
448        _ => {
449            // 'a', 'b', or 'c'
450            let mut s = String::new();
451            for (i, it) in items.iter().enumerate() {
452                if i > 0 {
453                    s.push_str(if i + 1 == items.len() { ", or " } else { ", " });
454                }
455                s.push('\'');
456                s.push_str(it);
457                s.push('\'');
458            }
459            s
460        }
461    }
462}
463
464#[inline]
465const fn max_distance_for(len: usize) -> usize {
466    match len {
467        0..=3 => 1,
468        4..=6 => 2,
469        _ => 3,
470    }
471}
472
473// Simple Levenshtein (O(n*m)); inputs are tiny (flags/commands), so it's fine.
474fn lev(a: &str, b: &str) -> usize {
475    let (na, nb) = (a.len(), b.len());
476    if na == 0 {
477        return nb;
478    }
479    if nb == 0 {
480        return na;
481    }
482    let mut prev: Vec<usize> = (0..=nb).collect();
483    let mut curr = vec![0; nb + 1];
484    for (i, ca) in a.chars().enumerate() {
485        curr[0] = i + 1;
486        for (j, cb) in b.chars().enumerate() {
487            let cost = usize::from(ca != cb);
488            curr[j + 1] = (prev[j + 1] + 1).min(curr[j] + 1).min(prev[j] + cost);
489        }
490        std::mem::swap(&mut prev, &mut curr);
491    }
492    prev[nb]
493}
494
495fn collect_long_candidates<Ctx: ?Sized>(env: &Env<'_>, cmd: &CmdSpec<'_, Ctx>) -> Vec<String> {
496    let mut v = Vec::with_capacity(cmd.opts.len() + 3);
497    if env.auto_help {
498        v.push("help".to_string());
499    }
500    if cmd.name.is_none() {
501        v.push("version".to_string());
502        v.push("author".to_string());
503    }
504    for o in &cmd.opts {
505        v.push(o.name.to_string());
506    }
507    v
508}
509
510fn collect_short_candidates<Ctx: ?Sized>(env: &Env<'_>, cmd: &CmdSpec<'_, Ctx>) -> Vec<char> {
511    let mut v = Vec::with_capacity(cmd.opts.len() + 3);
512    if env.auto_help {
513        v.push('h');
514    }
515    if cmd.name.is_none() {
516        v.push('V');
517        v.push('A');
518    }
519    for o in &cmd.opts {
520        if let Some(ch) = o.short {
521            v.push(ch);
522        }
523    }
524    v
525}
526
527fn collect_cmd_candidates<'a, Ctx: ?Sized>(cmd: &'a CmdSpec<'a, Ctx>) -> Vec<&'a str> {
528    let mut v = Vec::with_capacity(cmd.subs.len());
529    for s in &cmd.subs {
530        if let Some(n) = s.name {
531            v.push(n);
532        }
533        for &al in &s.aliases {
534            v.push(al);
535        }
536    }
537    v
538}
539
540fn suggest_longs<Ctx: ?Sized>(env: &Env<'_>, cmd: &CmdSpec<'_, Ctx>, name: &str) -> Vec<String> {
541    let thr = max_distance_for(name.len());
542    let mut scored: Vec<(usize, String)> = collect_long_candidates(env, cmd)
543        .into_iter()
544        .map(|cand| (lev(name, &cand), format!("--{cand}")))
545        .collect();
546    scored.sort_by_key(|(d, _)| *d);
547    scored.into_iter().take_while(|(d, _)| *d <= thr).take(3).map(|(_, s)| s).collect()
548}
549
550fn suggest_shorts<Ctx: ?Sized>(env: &Env<'_>, cmd: &CmdSpec<'_, Ctx>, ch: char) -> Vec<String> {
551    let mut v: Vec<(usize, String)> = collect_short_candidates(env, cmd)
552        .into_iter()
553        .map(|c| (usize::from(c != ch), format!("-{c}")))
554        .collect();
555    v.sort_by_key(|(d, _)| *d);
556    v.into_iter().take_while(|(d, _)| *d <= 1).take(3).map(|(_, s)| s).collect()
557}
558
559fn suggest_cmds<Ctx: ?Sized>(cmd: &CmdSpec<'_, Ctx>, tok: &str) -> Vec<String> {
560    let thr = max_distance_for(tok.len());
561    let mut v: Vec<(usize, String)> = collect_cmd_candidates(cmd)
562        .into_iter()
563        .map(|cand| (lev(tok, cand), cand.to_string()))
564        .collect();
565    v.sort_by_key(|(d, _)| *d);
566    v.into_iter().take_while(|(d, _)| *d <= thr).take(3).map(|(_, s)| s).collect()
567}
568
569/* ================================ Errors ===================================== */
570#[non_exhaustive]
571#[derive(Debug)]
572/// Error type
573pub enum Error {
574    /// Missing value
575    MissingValue(String),
576    /// Unexpected argument
577    UnexpectedArgument(String),
578    /// Unknown option
579    UnknownOption {
580        /// Token
581        token: String,
582        /// Suggestions
583        suggestions: Vec<String>,
584    },
585    /// Unknown command
586    UnknownCommand {
587        /// Token
588        token: String,
589        /// Suggestions
590        suggestions: Vec<String>,
591    },
592    /// Group violation
593    GroupViolation(String),
594    /// Missing positional
595    MissingPositional(String),
596    /// Too many positionals
597    TooManyPositional(String),
598    /// Callback error
599    Callback(BoxError),
600    /// Exit with code
601    Exit(i32),
602    /// User error
603    User(&'static str),
604}
605impl fmt::Display for Error {
606    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
607        match self {
608            Self::UnknownOption { token, suggestions } => {
609                write!(f, "unknown option: '{token}'")?;
610                if !suggestions.is_empty() {
611                    write!(f, ". Did you mean {}?", format_alternates(suggestions))?;
612                }
613                Ok(())
614            }
615            Self::MissingValue(n) => write!(f, "missing value for --{n}"),
616            Self::UnexpectedArgument(s) => write!(f, "unexpected argument: {s}"),
617            Self::UnknownCommand { token, suggestions } => {
618                write!(f, "unknown command: {token}")?;
619                if !suggestions.is_empty() {
620                    write!(f, ". Did you mean {}?", format_alternates(suggestions))?;
621                }
622                Ok(())
623            }
624            Self::GroupViolation(s) => write!(f, "{s}"),
625            Self::MissingPositional(n) => write!(f, "missing positional: {n}"),
626            Self::TooManyPositional(n) => write!(f, "too many values for: {n}"),
627            Self::Callback(e) => write!(f, "{e}"),
628            Self::Exit(code) => write!(f, "exit {code}"),
629            Self::User(s) => write!(f, "{s}"),
630        }
631    }
632}
633impl std::error::Error for Error {}
634
635/* ================================ Parsing ==================================== */
636fn find_sub<'a, Ctx: ?Sized>(
637    cmd: &'a CmdSpec<'a, Ctx>,
638    name: &str,
639) -> Option<&'a CmdSpec<'a, Ctx>> {
640    for c in &cmd.subs {
641        if let Some(n) = c.name {
642            if n == name {
643                return Some(c);
644            }
645        }
646        if c.aliases.contains(&name) {
647            return Some(c);
648        }
649    }
650    None
651}
652fn apply_env_and_defaults<Ctx: ?Sized>(
653    cmd: &CmdSpec<'_, Ctx>,
654    context: &mut Ctx,
655    counts: &mut [u8],
656) -> Result<()> {
657    if cmd.opts.is_empty() {
658        return Ok(());
659    }
660    if !any_env_or_default(cmd) {
661        return Ok(());
662    }
663
664    // env first
665    for (i, o) in cmd.opts.iter().enumerate() {
666        if let Some(key) = o.env {
667            if let Ok(val) = std::env::var(key) {
668                counts[i] = counts[i].saturating_add(1);
669                (o.cb)(Some(val.as_str()), context).map_err(Error::Callback)?;
670            }
671        }
672    }
673    // defaults next; skip if anything in the same group is already present
674    for (i, o) in cmd.opts.iter().enumerate() {
675        if counts[i] != 0 {
676            continue;
677        }
678        let Some(def) = o.default else { continue };
679        if o.group_id != 0 {
680            let gid = o.group_id;
681            let mut taken = false;
682            for (j, p) in cmd.opts.iter().enumerate() {
683                if p.group_id == gid && counts[j] != 0 {
684                    taken = true;
685                    break;
686                }
687            }
688            if taken {
689                continue;
690            }
691        }
692        counts[i] = counts[i].saturating_add(1);
693        (o.cb)(Some(def), context).map_err(Error::Callback)?;
694    }
695    Ok(())
696}
697
698#[allow(clippy::too_many_arguments)]
699fn parse_long<Ctx: ?Sized, W: std::io::Write>(
700    env: &Env<'_>,
701    cmd: &CmdSpec<'_, Ctx>,
702    tok: &str,
703    idx: &mut usize,
704    argv: &[&str],
705    context: &mut Ctx,
706    counts: &mut [u8],
707    out: &mut W,
708    chain: &[&str],
709) -> Result<()> {
710    // formats: --name, --name=value, --name value
711    let s = &tok[2..];
712    let (name, attached) = s
713        .as_bytes()
714        .iter()
715        .position(|&b| b == b'=')
716        .map_or((s, None), |eq| (&s[..eq], Some(&s[eq + 1..])));
717    // built‑ins
718    if env.auto_help && name == "help" {
719        print_help_to(env, cmd, chain, out);
720        return Err(Error::Exit(0));
721    }
722    if cmd.name.is_none() {
723        if env.version.is_some() && name == "version" {
724            print_version_to(env, out);
725            return Err(Error::Exit(0));
726        }
727        if env.author.is_some() && name == "author" {
728            print_author_to(env, out);
729            return Err(Error::Exit(0));
730        }
731    }
732    let (i, spec) = match cmd.opts.iter().enumerate().find(|(_, o)| o.name == name) {
733        Some(x) => x,
734        None => return Err(unknown_long_error(env, cmd, name)),
735    };
736    counts[i] = counts[i].saturating_add(1);
737    match spec.arg {
738        ArgKind::None => {
739            (spec.cb)(None, context).map_err(Error::Callback)?;
740        }
741        ArgKind::Required => {
742            let v = if let Some(a) = attached {
743                if a.is_empty() {
744                    return Err(Error::MissingValue(spec.name.to_string()));
745                }
746                a
747            } else {
748                take_next(idx, argv).ok_or_else(|| Error::MissingValue(spec.name.to_string()))?
749            };
750            (spec.cb)(Some(v), context).map_err(Error::Callback)?;
751        }
752        ArgKind::Optional => {
753            let v = match (attached, argv.get(*idx).copied()) {
754                (Some(a), _) => Some(a),
755                (None, Some("-")) => {
756                    *idx += 1; // consume standalone "-" but treat as none
757                    None
758                }
759                (None, Some(n))
760                    if matches!(spec.value_hint, ValueHint::Number) && is_dash_number(n) =>
761                {
762                    *idx += 1;
763                    Some(n)
764                }
765                (None, Some(n)) if looks_value_like(n) => {
766                    *idx += 1;
767                    Some(n)
768                }
769                _ => None,
770            };
771            (spec.cb)(v, context).map_err(Error::Callback)?;
772        }
773    }
774    Ok(())
775}
776
777#[allow(clippy::too_many_arguments)]
778fn parse_short_cluster<Ctx: ?Sized, W: std::io::Write>(
779    env: &Env<'_>,
780    cmd: &CmdSpec<'_, Ctx>,
781    tok: &str,
782    idx: &mut usize,
783    argv: &[&str],
784    context: &mut Ctx,
785    counts: &mut [u8],
786    out: &mut W,
787    chain: &[&str],
788) -> Result<()> {
789    // formats: -abc, -j10, -j 10, -j -12  (no -j=10 by design)
790    let short_idx = build_short_idx(cmd);
791    let s = &tok[1..];
792    let bytes = s.as_bytes();
793    let mut i = 0usize;
794    while i < bytes.len() {
795        // Fast ASCII path for common cases; fall back to UTF‑8 char boundary when needed.
796        let (ch, adv) = if bytes[i] < 128 {
797            (bytes[i] as char, 1)
798        } else {
799            let c = s[i..].chars().next().unwrap();
800            (c, c.len_utf8())
801        };
802        i += adv;
803
804        // built‑ins
805        if env.auto_help && ch == 'h' {
806            print_help_to(env, cmd, chain, out);
807            return Err(Error::Exit(0));
808        }
809        if cmd.name.is_none() {
810            if env.version.is_some() && ch == 'V' {
811                print_version_to(env, out);
812                return Err(Error::Exit(0));
813            }
814            if env.author.is_some() && ch == 'A' {
815                print_author_to(env, out);
816                return Err(Error::Exit(0));
817            }
818        }
819        let (oi, spec) = match lookup_short(cmd, &short_idx, ch) {
820            Some(x) => x,
821            None => return Err(unknown_short_error(env, cmd, ch)),
822        };
823        counts[oi] = counts[oi].saturating_add(1);
824        match spec.arg {
825            ArgKind::None => {
826                (spec.cb)(None, context).map_err(Error::Callback)?;
827            }
828            ArgKind::Required => {
829                if i < s.len() {
830                    let rem = &s[i..];
831                    (spec.cb)(Some(rem), context).map_err(Error::Callback)?;
832                    return Ok(());
833                }
834                let v = take_next(idx, argv)
835                    .ok_or_else(|| Error::MissingValue(spec.name.to_string()))?;
836                (spec.cb)(Some(v), context).map_err(Error::Callback)?;
837                return Ok(());
838            }
839            ArgKind::Optional => {
840                if i < s.len() {
841                    let rem = &s[i..];
842                    (spec.cb)(Some(rem), context).map_err(Error::Callback)?;
843                    return Ok(());
844                }
845                // SPECIAL: if next token is exactly "-", CONSUME it but treat as "no value".
846                let v = match argv.get(*idx) {
847                    Some(&"-") => {
848                        *idx += 1;
849                        None
850                    }
851                    // If hint is Number, allow a directly following numeric like `-j -1.25`.
852                    Some(n)
853                        if matches!(spec.value_hint, ValueHint::Number) && is_dash_number(n) =>
854                    {
855                        *idx += 1;
856                        Some(n)
857                    }
858                    // Otherwise consume if it looks like a plausible value (incl. -1, -0.5, 1e3…)
859                    Some(n) if looks_value_like(n) => {
860                        *idx += 1;
861                        Some(n)
862                    }
863                    _ => None,
864                };
865                (spec.cb)(v.map(|v| &**v), context).map_err(Error::Callback)?;
866                return Ok(());
867            }
868        }
869    }
870    Ok(())
871}
872
873#[inline]
874fn any_env_or_default<Ctx: ?Sized>(cmd: &CmdSpec<'_, Ctx>) -> bool {
875    cmd.opts.iter().any(|o| o.env.is_some() || o.default.is_some())
876}
877#[inline]
878fn take_next<'a>(idx: &mut usize, argv: &'a [&'a str]) -> Option<&'a str> {
879    let i = *idx;
880    if i < argv.len() {
881        *idx = i + 1;
882        Some(argv[i])
883    } else {
884        None
885    }
886}
887#[inline]
888fn is_short_like(s: &str) -> bool {
889    let b = s.as_bytes();
890    b.len() >= 2 && b[0] == b'-' && b[1] != b'-'
891}
892#[inline]
893fn is_dash_number(s: &str) -> bool {
894    let b = s.as_bytes();
895    if b.is_empty() || b[0] != b'-' {
896        return false;
897    }
898    // "-" alone is not a number
899    if b.len() == 1 {
900        return false;
901    }
902    is_numeric_like(&b[1..])
903}
904#[inline]
905fn looks_value_like(s: &str) -> bool {
906    if !s.starts_with('-') {
907        return true;
908    }
909    if s == "-" {
910        return false;
911    }
912    is_numeric_like(&s.as_bytes()[1..])
913}
914#[inline]
915fn is_numeric_like(b: &[u8]) -> bool {
916    // digits, optional dot, optional exponent part
917    let mut i = 0;
918    let n = b.len();
919    // optional leading dot: .5
920    if i < n && b[i] == b'.' {
921        i += 1;
922    }
923    // at least one digit
924    let mut nd = 0;
925    while i < n && (b[i] as char).is_ascii_digit() {
926        i += 1;
927        nd += 1;
928    }
929    if nd == 0 {
930        return false;
931    }
932    // optional fractional part .ddd
933    if i < n && b[i] == b'.' {
934        i += 1;
935        while i < n && (b[i] as char).is_ascii_digit() {
936            i += 1;
937        }
938    }
939    // optional exponent e[+/-]ddd
940    if i < n && (b[i] == b'e' || b[i] == b'E') {
941        i += 1;
942        if i < n && (b[i] == b'+' || b[i] == b'-') {
943            i += 1;
944        }
945        let mut ed = 0;
946        while i < n && (b[i] as char).is_ascii_digit() {
947            i += 1;
948            ed += 1;
949        }
950        if ed == 0 {
951            return false;
952        }
953    }
954    i == n
955}
956
957fn check_groups<Ctx: ?Sized>(cmd: &CmdSpec<'_, Ctx>, counts: &[u8]) -> Result<()> {
958    let opts = &cmd.opts;
959    let opts_len = opts.len();
960    let mut index = 0usize;
961    while index < opts_len {
962        let id = opts[index].group_id;
963        if id != 0 {
964            // ensure we only process each id once
965            let mut seen = false;
966            let mut k = 0usize;
967            while k < index {
968                if opts[k].group_id == id {
969                    seen = true;
970                    break;
971                }
972                k += 1;
973            }
974            if !seen {
975                let mut total = 0u32;
976                let mut xor = false;
977                let mut req = false;
978                let mut j = 0usize;
979                while j < opts_len {
980                    let o = &opts[j];
981                    if o.group_id == id {
982                        total += u32::from(counts[j]);
983                        match o.group_mode {
984                            GroupMode::Xor => xor = true,
985                            GroupMode::ReqOne => req = true,
986                            GroupMode::None => {}
987                        }
988                        if xor && total > 1 {
989                            return Err(Error::GroupViolation(group_msg(opts, id, true)));
990                        }
991                    }
992                    j += 1;
993                }
994                if req && total == 0 {
995                    return Err(Error::GroupViolation(group_msg(opts, id, false)));
996                }
997            }
998        }
999        index += 1;
1000    }
1001    Ok(())
1002}
1003
1004#[cold]
1005#[inline(never)]
1006fn group_msg<Ctx: ?Sized>(opts: &[OptSpec<'_, Ctx>], id: u16, xor: bool) -> String {
1007    let mut names = String::new();
1008    for o in opts.iter().filter(|o| o.group_id == id) {
1009        if !names.is_empty() {
1010            names.push_str(" | ");
1011        }
1012        names.push_str(o.name);
1013    }
1014    if xor {
1015        format!("at most one of the following options may be used: {names}")
1016    } else {
1017        format!("one of the following options is required: {names}")
1018    }
1019}
1020
1021fn validate_positionals<Ctx: ?Sized>(cmd: &CmdSpec<'_, Ctx>, pos: &[&str]) -> Result<()> {
1022    if cmd.pos.is_empty() {
1023        return Ok(());
1024    }
1025    let total = pos.len();
1026    let mut min_sum: usize = 0;
1027    let mut max_sum: Option<usize> = Some(0);
1028    for p in &cmd.pos {
1029        min_sum = min_sum.saturating_add(p.min);
1030        if let Some(ms) = max_sum {
1031            if p.max == usize::MAX {
1032                max_sum = None;
1033            } else {
1034                max_sum = Some(ms.saturating_add(p.max));
1035            }
1036        }
1037    }
1038    // Not enough arguments: find the first positional whose minimum cannot be met
1039    if total < min_sum {
1040        let mut need = 0usize;
1041        for p in &cmd.pos {
1042            need = need.saturating_add(p.min);
1043            if total < need {
1044                return Err(Error::MissingPositional(p.name.to_string()));
1045            }
1046        }
1047        // Fallback (should be unreachable)
1048        return Err(Error::MissingPositional(
1049            cmd.pos.first().map_or("<args>", |p| p.name).to_string(),
1050        ));
1051    }
1052    // Too many arguments (only when all maxima are finite)
1053    if let Some(ms) = max_sum {
1054        if total > ms {
1055            let last = cmd.pos.last().map_or("<args>", |p| p.name);
1056            return Err(Error::TooManyPositional(last.to_string()));
1057        }
1058    }
1059    Ok(())
1060}
1061#[inline]
1062const fn plain_opt_label_len<Ctx: ?Sized>(o: &OptSpec<'_, Ctx>) -> usize {
1063    let mut len = if o.short.is_some() { 4 } else { 0 }; // "-x, "
1064    len += 2 + o.name.len(); // "--" + name
1065    if let Some(m) = o.metavar {
1066        len += 1 + m.len();
1067    }
1068    len
1069}
1070#[inline]
1071fn make_opt_label<Ctx: ?Sized>(o: &OptSpec<'_, Ctx>) -> String {
1072    let mut s = String::new();
1073    if let Some(ch) = o.short {
1074        s.push('-');
1075        s.push(ch);
1076        s.push(',');
1077        s.push(' ');
1078    }
1079    s.push_str("--");
1080    s.push_str(o.name);
1081    if let Some(m) = o.metavar {
1082        s.push(' ');
1083        s.push_str(m);
1084    }
1085    s
1086}
1087
1088/* ================================ Help ======================================= */
1089const C_BOLD: &str = "\u{001b}[1m";
1090const C_UNDERLINE: &str = "\u{001b}[4m";
1091const C_BRIGHT_WHITE: &str = "\u{001b}[97m";
1092const C_CYAN: &str = "\u{001b}[36m";
1093const C_MAGENTA: &str = "\u{001b}[35m";
1094const C_YELLOW: &str = "\u{001b}[33m";
1095const C_RESET: &str = "\u{001b}[0m";
1096#[inline]
1097fn colorize(s: &str, color: &str, env: &Env) -> String {
1098    if !env.color || color.is_empty() {
1099        s.to_string()
1100    } else {
1101        format!("{color}{s}{C_RESET}")
1102    }
1103}
1104#[inline]
1105fn help_text_for_opt<Ctx: ?Sized>(o: &OptSpec<'_, Ctx>) -> String {
1106    match (o.env, o.default) {
1107        (Some(k), Some(d)) => format!("{} (env {k}, default={d})", o.help),
1108        (Some(k), None) => format!("{} (env {k})", o.help),
1109        (None, Some(d)) => format!("{} (default={d})", o.help),
1110        (None, None) => o.help.to_string(),
1111    }
1112}
1113#[inline]
1114fn print_header(buf: &mut String, text: &str, env: &Env) {
1115    let _ = writeln!(buf, "\n{}:", colorize(text, &[C_BOLD, C_UNDERLINE].concat(), env).as_str());
1116}
1117#[inline]
1118fn lookup_short<'a, Ctx: ?Sized>(
1119    cmd: &'a CmdSpec<'a, Ctx>,
1120    table: &[u16; 128],
1121    ch: char,
1122) -> Option<(usize, &'a OptSpec<'a, Ctx>)> {
1123    let c = ch as u32;
1124    if c < 128 {
1125        let i = table[c as usize];
1126        if i != u16::MAX {
1127            let idx = i as usize;
1128            return Some((idx, &cmd.opts[idx]));
1129        }
1130        return None;
1131    }
1132    cmd.opts.iter().enumerate().find(|(_, o)| o.short == Some(ch))
1133}
1134fn build_short_idx<Ctx: ?Sized>(cmd: &CmdSpec<'_, Ctx>) -> [u16; 128] {
1135    let mut map = [u16::MAX; 128];
1136    let mut i = 0usize;
1137    let len = cmd.opts.len();
1138    while i < len {
1139        let o = &cmd.opts[i];
1140        if let Some(ch) = o.short {
1141            let cu = ch as usize;
1142            if cu < 128 {
1143                debug_assert!(u16::try_from(i).is_ok());
1144                map[cu] = u16::try_from(i).unwrap_or(0); // safe due to debug assert
1145            }
1146        }
1147        i += 1;
1148    }
1149    map
1150}
1151#[inline]
1152fn write_wrapped(buf: &mut String, text: &str, indent_cols: usize, wrap_cols: usize) {
1153    if wrap_cols == 0 {
1154        let _ = writeln!(buf, "{text}");
1155        return;
1156    }
1157    let mut col = indent_cols;
1158    let mut first = true;
1159    for word in text.split_whitespace() {
1160        let wlen = word.len();
1161        if first {
1162            buf.push_str(word);
1163            col = indent_cols + wlen;
1164            first = false;
1165            continue;
1166        }
1167        if col + 1 + wlen > wrap_cols {
1168            buf.push('\n');
1169            for _ in 0..indent_cols {
1170                buf.push(' ');
1171            }
1172            buf.push_str(word);
1173            col = indent_cols + wlen;
1174        } else {
1175            buf.push(' ');
1176            buf.push_str(word);
1177            col += 1 + wlen;
1178        }
1179    }
1180    buf.push('\n');
1181}
1182
1183fn write_row(
1184    buf: &mut String,
1185    env: &Env,
1186    color: &str,
1187    plain_label: &str,
1188    help: &str,
1189    label_col: usize,
1190) {
1191    let _ = write!(buf, "  {}", colorize(plain_label, color, env));
1192    let pad = label_col.saturating_sub(plain_label.len());
1193    for _ in 0..pad {
1194        buf.push(' ');
1195    }
1196    buf.push(' ');
1197    buf.push(' ');
1198    let indent = 4 + label_col;
1199    write_wrapped(buf, help, indent, env.wrap_cols);
1200}
1201
1202/// Print help to the provided writer.
1203#[cold]
1204#[inline(never)]
1205pub fn print_help_to<Ctx: ?Sized, W: Write>(
1206    env: &Env<'_>,
1207    cmd: &CmdSpec<'_, Ctx>,
1208    path: &[&str],
1209    mut out: W,
1210) {
1211    let mut buf = String::new();
1212    let _ = write!(
1213        buf,
1214        "Usage: {}",
1215        colorize(env.name, [C_BOLD, C_BRIGHT_WHITE].concat().as_str(), env)
1216    );
1217    for tok in path {
1218        let _ = write!(buf, " {}", colorize(tok, C_MAGENTA, env));
1219    }
1220    let has_subs = !cmd.subs.is_empty();
1221    let has_opts = !cmd.opts.is_empty()
1222        || env.auto_help
1223        || (path.is_empty() && (env.version.is_some() || env.author.is_some()));
1224    if has_opts && has_subs {
1225        let _ = write!(buf, " {}", colorize("[options]", C_CYAN, env));
1226        let _ = write!(buf, " {}", colorize("<command>", C_MAGENTA, env));
1227    } else if !has_opts && has_subs {
1228        let _ = write!(buf, " {}", colorize("<command>", C_MAGENTA, env));
1229    } else if has_opts && !has_subs {
1230        let _ = write!(buf, " {}", colorize("[options]", C_CYAN, env));
1231    }
1232    for p in &cmd.pos {
1233        if p.min == 0 {
1234            let _ = write!(buf, " [{}]", colorize(p.name, C_YELLOW, env));
1235        } else if p.min == 1 && p.max == 1 {
1236            let _ = write!(buf, " {}", colorize(p.name, C_YELLOW, env));
1237        } else if p.max > 1 {
1238            let _ = write!(buf, " {}...", colorize(p.name, C_YELLOW, env));
1239        }
1240    }
1241    let _ = writeln!(buf);
1242    if let Some(desc) = cmd.desc {
1243        let _ = writeln!(buf, "\n{desc}");
1244    }
1245    if !cmd.opts.is_empty()
1246        || env.auto_help
1247        || (cmd.name.is_none() && (env.version.is_some() || env.author.is_some()))
1248    {
1249        print_header(&mut buf, "Options", env);
1250        let mut width = 0usize;
1251        if env.auto_help {
1252            width = width.max("-h, --help".len());
1253        }
1254        // if cmd is a root command
1255        if cmd.name.is_none() {
1256            if env.version.is_some() {
1257                width = width.max("-V, --version".len());
1258            }
1259            if env.author.is_some() {
1260                width = width.max("-A, --author".len());
1261            }
1262        }
1263        for o in &cmd.opts {
1264            width = width.max(plain_opt_label_len(o));
1265        }
1266        if env.auto_help {
1267            write_row(&mut buf, env, C_CYAN, "-h, --help", "Show this help and exit", width);
1268        }
1269        // if cmd is a root command
1270        if cmd.name.is_none() {
1271            if env.version.is_some() {
1272                write_row(&mut buf, env, C_CYAN, "-V, --version", "Show version and exit", width);
1273            }
1274            if env.author.is_some() {
1275                write_row(&mut buf, env, C_CYAN, "--author", "Show author and exit", width);
1276            }
1277        }
1278        for o in &cmd.opts {
1279            let label = make_opt_label(o);
1280            let help = help_text_for_opt(o);
1281            write_row(&mut buf, env, C_CYAN, &label, &help, width);
1282        }
1283    }
1284    // Commands
1285    if !cmd.subs.is_empty() {
1286        print_header(&mut buf, "Commands", env);
1287        let width = cmd.subs.iter().map(|s| s.name.unwrap_or("<root>").len()).max().unwrap_or(0);
1288        for s in &cmd.subs {
1289            let name = s.name.unwrap_or("<root>");
1290            write_row(&mut buf, env, C_MAGENTA, name, s.desc.unwrap_or(""), width);
1291        }
1292    }
1293    // Positionals
1294    if !cmd.pos.is_empty() {
1295        print_header(&mut buf, "Positionals", env);
1296        let width = cmd.pos.iter().map(|p| p.name.len()).max().unwrap_or(0);
1297        for p in &cmd.pos {
1298            let help = help_for_pos(p);
1299            write_row(&mut buf, env, C_YELLOW, p.name, &help, width);
1300        }
1301    }
1302    let _ = out.write_all(buf.as_bytes());
1303}
1304fn help_for_pos(p: &PosSpec) -> String {
1305    if let Some(d) = p.desc {
1306        return d.to_string();
1307    }
1308    if p.min == 0 {
1309        return "(optional)".to_string();
1310    }
1311    if p.min == 1 && p.max == 1 {
1312        return "(required)".to_string();
1313    }
1314    if p.min == 1 {
1315        return "(at least one required)".to_string();
1316    }
1317    format!("min={} max={}", p.min, p.max)
1318}
1319/// Prints the version number
1320#[cold]
1321#[inline(never)]
1322pub fn print_version_to<W: Write>(env: &Env<'_>, mut out: W) {
1323    if let Some(v) = env.version {
1324        let _ = writeln!(out, "{v}");
1325    }
1326}
1327/// Prints the author
1328#[cold]
1329#[inline(never)]
1330pub fn print_author_to<W: Write>(env: &Env<'_>, mut out: W) {
1331    if let Some(a) = env.author {
1332        let _ = writeln!(out, "{a}");
1333    }
1334}
1335#[cold]
1336#[inline(never)]
1337fn unknown_long_error<Ctx: ?Sized>(env: &Env<'_>, cmd: &CmdSpec<'_, Ctx>, name: &str) -> Error {
1338    let token = {
1339        let mut s = String::with_capacity(2 + name.len());
1340        s.push_str("--");
1341        s.push_str(name);
1342        s
1343    };
1344    let suggestions = suggest_longs(env, cmd, name);
1345    Error::UnknownOption { token, suggestions }
1346}
1347
1348#[cold]
1349#[inline(never)]
1350fn unknown_short_error<Ctx: ?Sized>(env: &Env<'_>, cmd: &CmdSpec<'_, Ctx>, ch: char) -> Error {
1351    let mut token = String::with_capacity(2);
1352    token.push('-');
1353    token.push(ch);
1354    let suggestions = suggest_shorts(env, cmd, ch);
1355    Error::UnknownOption { token, suggestions }
1356}
1357
1358#[cold]
1359#[inline(never)]
1360fn unknown_command_error<Ctx: ?Sized>(cmd: &CmdSpec<'_, Ctx>, tok: &str) -> Error {
1361    Error::UnknownCommand { token: tok.to_string(), suggestions: suggest_cmds(cmd, tok) }
1362}