argx 0.2.1

Expressive command-line parsing and configuration for Rust.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
//! Raw command-line token binding against static command metadata.
//!
//! This layer implements only lexical command-line grammar: scope selection, option spellings,
//! short bundles, attached versus detached values, positional routing, and `--`. It deliberately
//! does not enforce typed occurrence counts, consult environment variables, apply defaults, or
//! convert values. Keeping those concerns out of the raw parser gives syntax errors deterministic
//! precedence over later binding failures.
//!
//! Events borrow both the generated static command tables and the caller's `argv`; owned values are
//! created only by the typed binding layer once the lexical parse has succeeded.

use std::ffi::OsStr;

use crate::__private::{
    Action, Arg, Command, Flag, Named, SCHEMA_ACTION, resolve_long, resolve_short,
};

/// One token binding produced by the raw argument parser.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Event<'t, 'v> {
    /// A built-in parser action was selected.
    Action {
        /// Static metadata for the matched action.
        action: &'t Action<'t>,
        /// Whether the long spelling selected the action.
        long: bool,
    },
    /// A named flag was matched.
    Flag {
        /// Static metadata for the matched flag.
        flag: &'t Flag<'t>,
        /// Encoded value consumed by the flag, when it takes one.
        value: Option<&'v [u8]>,
    },
    /// A positional argument received one value.
    Arg {
        /// Static metadata for the matched positional argument.
        arg: &'t Arg<'t>,
        /// Encoded value bound to the positional argument.
        value: &'v [u8],
    },
    /// A nested command was selected.
    Command {
        /// Static metadata for the selected child command.
        command: &'t Command<'t>,
    },
}

/// A failure while binding command-line tokens to static argument metadata.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum Error<'t, 'v> {
    /// A value was attached to a built-in action that does not accept one.
    UnexpectedActionValue {
        /// Static metadata for the action receiving the value.
        action: &'t Action<'t>,
    },
    /// A flag-like token did not match any declared flag.
    UnknownFlag {
        /// Whole encoded token supplied by the caller.
        token: &'v [u8],
    },
    /// A flag that consumes a value did not receive one.
    MissingFlagValue {
        /// Static metadata for the flag missing its value.
        flag: &'t Flag<'t>,
    },
    /// A value was attached to a switch that does not accept one.
    UnexpectedFlagValue {
        /// Static metadata for the flag receiving the value.
        flag: &'t Flag<'t>,
    },
    /// A word could not be assigned to any positional argument.
    UnexpectedArg {
        /// Whole encoded token supplied by the caller.
        token: &'v [u8],
    },
    /// A word was encountered where one of the current command's child commands was expected.
    UnknownCommand {
        /// Whole encoded token supplied by the caller.
        token: &'v [u8],
    },
}

/// Single-pass parser over command-line arguments that exclude the program name.
///
/// The parser maintains the selected command path so local options can shadow inherited global
/// options. A terminal action or error stops iteration; callers must not treat earlier events as a
/// committed parse result until the complete token stream succeeds.
#[derive(Debug)]
pub struct ArgvParser<'t, 'a, 'v> {
    /// Static command definition used for token matching.
    command: &'t Command<'t>,
    /// Selected command ancestors from root to the parent of `command`.
    ancestors: Vec<&'t Command<'t>>,
    /// Command-line arguments after the program name.
    argv: &'a [&'v OsStr],
    /// Index of the next argument to inspect.
    position: usize,
    /// Index of the positional table entry currently receiving values.
    arg_position: usize,
    /// Bytes left in a short-flag bundle.
    bundle: &'v [u8],
    /// Whole encoded token from which `bundle` was taken.
    bundle_token: &'v [u8],
    /// Whether `--` stopped flag interpretation.
    flags_stopped: bool,
    /// Whether a fatal parse error has already been returned.
    done: bool,
    /// Whether the virtual `--schema` action is available in every selected scope.
    schema_enabled: bool,
}

impl<'t, 'a, 'v> ArgvParser<'t, 'a, 'v> {
    /// Creates a parser for `argv` against `command`.
    ///
    /// `argv` contains only the command-line arguments; the program name is not included.
    #[must_use]
    pub const fn new(command: &'t Command<'t>, argv: &'a [&'v OsStr]) -> Self {
        Self::new_with_schema(command, argv, false)
    }

    /// Creates a parser with optional schema discovery enabled in every command scope.
    #[must_use]
    pub const fn new_with_schema(
        command: &'t Command<'t>,
        argv: &'a [&'v OsStr],
        schema_enabled: bool,
    ) -> Self {
        Self {
            command,
            ancestors: Vec::new(),
            argv,
            position: 0,
            arg_position: 0,
            bundle: &[],
            bundle_token: &[],
            flags_stopped: false,
            done: false,
            schema_enabled,
        }
    }

    /// Produces the next token binding or terminal parser result.
    ///
    /// A built-in action or error is terminal: subsequent calls return `None`. Events emitted
    /// before a terminal result are a partial parse and must be discarded by callers. A short
    /// bundle is preflighted before its first event, so an unknown short rejects the whole token
    /// atomically.
    ///
    /// # Errors
    ///
    /// Returns a structured error when a token cannot be bound according to the static command
    /// metadata.
    pub fn next_event(&mut self) -> Option<Result<Event<'t, 'v>, Error<'t, 'v>>> {
        if self.done {
            return None;
        }

        let event = self.step();
        if matches!(event.as_ref(), Some(Err(_) | Ok(Event::Action { .. }))) {
            self.done = true;
        }
        event
    }

    /// Advances the state machine by one event.
    fn step(&mut self) -> Option<Result<Event<'t, 'v>, Error<'t, 'v>>> {
        if !self.bundle.is_empty() {
            return Some(self.short_flag());
        }

        let token = bytes(self.argv.get(self.position)?);
        self.position += 1;

        if self.flags_stopped {
            return Some(self.word(token));
        }

        if token == b"--" {
            self.flags_stopped = true;
            return self.step();
        }

        if routes_negative_number_to_arg(self.command, &self.ancestors, self.next_arg(), token) {
            return Some(self.word(token));
        }

        if is_flag_like(token) {
            if token.starts_with(b"--") {
                return Some(self.long_flag(token));
            }

            if let Err(error) = self.check_short_bundle(token) {
                return Some(Err(error));
            }
            self.bundle = &token[1..];
            self.bundle_token = token;
            return Some(self.short_flag());
        }

        Some(self.word(token))
    }

    /// Matches a long flag and consumes its value when required.
    fn long_flag(&mut self, token: &'v [u8]) -> Result<Event<'t, 'v>, Error<'t, 'v>> {
        let body = &token[2..];
        let (name, attached) = body
            .iter()
            .position(|byte| *byte == b'=')
            .map_or((body, None), |index| (&body[..index], Some(&body[index + 1..])));
        if self.schema_enabled && name == b"schema" {
            return if attached.is_some() {
                Err(Error::UnexpectedActionValue { action: &SCHEMA_ACTION })
            } else {
                Ok(Event::Action { action: &SCHEMA_ACTION, long: true })
            };
        }
        let flag = match resolve_long(self.command, &self.ancestors, name) {
            Some(Named::Action(action)) => {
                return if attached.is_some() {
                    Err(Error::UnexpectedActionValue { action })
                } else {
                    Ok(Event::Action { action, long: true })
                };
            }
            Some(Named::Flag { flag, .. }) => flag,
            None => return Err(Error::UnknownFlag { token }),
        };

        let value = if flag.takes_value {
            match attached {
                Some(value) => Some(value),
                None => Some(self.take_detached_value(flag)?),
            }
        } else if attached.is_some() {
            return Err(Error::UnexpectedFlagValue { flag });
        } else {
            None
        };

        Ok(Event::Flag { flag, value })
    }

    /// Verifies an entire short bundle before any event from it is emitted.
    ///
    /// A value-taking short ends the bundle because every following byte belongs to its value.
    fn check_short_bundle(&self, token: &'v [u8]) -> Result<(), Error<'t, 'v>> {
        let mut remaining = &token[1..];
        while let Some((&short, tail)) = remaining.split_first() {
            if self.schema_enabled && short == b'S' {
                remaining = tail;
                continue;
            }
            match resolve_short(self.command, &self.ancestors, short) {
                Some(Named::Flag { flag, .. }) if flag.takes_value => return Ok(()),
                Some(Named::Action(_) | Named::Flag { .. }) => remaining = tail,
                None => return Err(Error::UnknownFlag { token }),
            }
        }
        Ok(())
    }

    /// Emits one flag from the current short bundle.
    fn short_flag(&mut self) -> Result<Event<'t, 'v>, Error<'t, 'v>> {
        let Some((&short, rest)) = self.bundle.split_first() else {
            return Err(Error::UnknownFlag { token: self.bundle_token });
        };
        if self.schema_enabled && short == b'S' {
            self.bundle = &[];
            return Ok(Event::Action { action: &SCHEMA_ACTION, long: false });
        }
        let flag = match resolve_short(self.command, &self.ancestors, short) {
            Some(Named::Action(action)) => {
                self.bundle = &[];
                return Ok(Event::Action { action, long: false });
            }
            Some(Named::Flag { flag, .. }) => flag,
            None => {
                self.bundle = &[];
                return Err(Error::UnknownFlag { token: self.bundle_token });
            }
        };

        if !flag.takes_value {
            self.bundle = rest;
            return Ok(Event::Flag { flag, value: None });
        }

        self.bundle = &[];
        let value = if rest.is_empty() {
            self.take_detached_value(flag)?
        } else {
            rest.strip_prefix(b"=").unwrap_or(rest)
        };
        Ok(Event::Flag { flag, value: Some(value) })
    }

    /// Consumes one detached flag value according to the flag's hyphen policy.
    fn take_detached_value(&mut self, flag: &'t Flag<'t>) -> Result<&'v [u8], Error<'t, 'v>> {
        let Some(value) = self.argv.get(self.position).copied().map(bytes) else {
            return Err(Error::MissingFlagValue { flag });
        };
        if !accepts_detached_flag_value(flag, value) {
            return Err(Error::MissingFlagValue { flag });
        }
        self.position += 1;
        Ok(value)
    }

    /// Selects an exact child command or binds a word to the next positional in scope.
    fn word(&mut self, token: &'v [u8]) -> Result<Event<'t, 'v>, Error<'t, 'v>> {
        if !self.flags_stopped
            && let Some(command) = self.find_subcommand(token)
        {
            self.ancestors.push(self.command);
            self.command = command;
            self.arg_position = 0;
            return Ok(Event::Command { command });
        }

        let Some(arg) = self.next_arg() else {
            return if !self.flags_stopped && !self.command.subcommands.is_empty() {
                Err(Error::UnknownCommand { token })
            } else {
                Err(Error::UnexpectedArg { token })
            };
        };
        if !arg.variadic {
            self.arg_position += 1;
        }
        Ok(Event::Arg { arg, value: token })
    }

    /// Returns the positional argument that would receive the next word.
    pub(crate) fn next_arg(&self) -> Option<&'t Arg<'t>> {
        self.command.args.get(self.arg_position).copied()
    }

    /// Looks up one child command by exact command-line spelling.
    fn find_subcommand(&self, name: &[u8]) -> Option<&'t Command<'t>> {
        self.command.subcommands.iter().copied().find(|command| {
            command.name.as_bytes() == name
                || command.aliases.iter().any(|alias| alias.as_bytes() == name)
        })
    }

    /// Returns the currently selected command.
    pub(crate) const fn command(&self) -> &'t Command<'t> {
        self.command
    }

    /// Returns the selected command ancestors from root to current parent.
    pub(crate) fn ancestors(&self) -> &[&'t Command<'t>] {
        &self.ancestors
    }

    /// Reports whether `--` has stopped flag interpretation.
    pub(crate) const fn flags_stopped(&self) -> bool {
        self.flags_stopped
    }

    /// Reports whether every supplied argv token has been consumed.
    pub(crate) const fn at_end(&self) -> bool {
        self.position == self.argv.len() && self.bundle.is_empty()
    }

    /// Returns the unconsumed argv tokens after a terminal action.
    pub(crate) fn remaining_args(&self) -> &[&'v OsStr] {
        &self.argv[self.position..]
    }

    /// Returns the selected command chain from the root through the current command.
    pub(crate) fn command_path(&self) -> impl DoubleEndedIterator<Item = &'t Command<'t>> + '_ {
        self.ancestors.iter().copied().chain(std::iter::once(self.command))
    }
}

/// Views an operating-system argument as native bytes.
fn bytes(value: &OsStr) -> &[u8] {
    use std::os::unix::ffi::OsStrExt as _;

    value.as_bytes()
}

/// Reports whether a token is syntactically flag-like.
///
/// A lone `-` remains a value, conventionally representing standard input.
const fn is_flag_like(token: &[u8]) -> bool {
    matches!(token, [b'-', rest @ ..] if !rest.is_empty())
}

/// Reports whether one detached token can be consumed as a value for `flag`.
///
/// Value completion shares this exact lexical policy so it never suggests a detached finite value
/// that ordinary parsing would reject as another flag.
pub(crate) fn accepts_detached_flag_value(flag: &Flag<'_>, value: &[u8]) -> bool {
    flag.allow_hyphen_values
        || !is_flag_like(value)
        || (flag.allow_negative_numbers && is_negative_number(value))
}

/// Reports whether one negative-number token routes to the next positional argument.
///
/// An exact declared numeric short flag takes precedence over positional negative-number routing.
/// Keeping this decision shared prevents completion and ordinary parsing from disagreeing about a
/// token such as `-1`.
pub(crate) fn routes_negative_number_to_arg<'t>(
    command: &'t Command<'t>,
    ancestors: &[&'t Command<'t>],
    next_arg: Option<&'t Arg<'t>>,
    token: &[u8],
) -> bool {
    let declared_numeric_short = matches!(token, [b'-', short]
    if short.is_ascii_digit()
        && matches!(
            resolve_short(command, ancestors, *short),
            Some(Named::Flag { .. })
        ));
    !declared_numeric_short
        && is_negative_number(token)
        && next_arg.is_some_and(|argument| argument.allow_negative_numbers)
}

/// Reports whether a flag-like token has the supported negative-number shape.
fn is_negative_number(token: &[u8]) -> bool {
    token.strip_prefix(b"-").is_some_and(is_number)
}

/// Recognizes decimal and scientific-notation number spellings without UTF-8 conversion.
fn is_number(token: &[u8]) -> bool {
    let (mantissa, exponent) = token
        .iter()
        .position(|byte| matches!(byte, b'e' | b'E'))
        .map_or((token, None), |index| (&token[..index], Some(&token[index + 1..])));

    let mut seen_digit = false;
    let mut seen_dot = false;
    for &byte in mantissa {
        match byte {
            b'0'..=b'9' => seen_digit = true,
            b'.' if !seen_dot => seen_dot = true,
            _ => return false,
        }
    }
    if !seen_digit {
        return false;
    }

    exponent.is_none_or(|exponent| {
        let digits =
            exponent.strip_prefix(b"+").or_else(|| exponent.strip_prefix(b"-")).unwrap_or(exponent);
        !digits.is_empty() && digits.iter().all(u8::is_ascii_digit)
    })
}

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

    #[test]
    fn recognizes_supported_number_shapes() {
        for value in [
            &b"1"[..],
            &b"1.5"[..],
            &b".5"[..],
            &b"1."[..],
            &b"1e5"[..],
            &b"1e-5"[..],
            &b"1E+5"[..],
        ] {
            assert!(is_number(value), "{value:?}");
        }
        for value in [&b""[..], &b"."[..], &b"e1"[..], &b"1e"[..], &b"1.2.3"[..], &b"1x"[..]] {
            assert!(!is_number(value), "{value:?}");
        }
        assert!(is_negative_number(b"-1"));
        assert!(is_negative_number(b"-1.5e2"));
        assert!(!is_negative_number(b"--1"));
        assert!(!is_negative_number(b"-inf"));
    }
}