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