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