use crate::parse::Token;
use crate::policy::{FlagSet, check_flags};
use super::types::OwnedPolicy;
pub(super) fn check_owned(tokens: &[Token], policy: &OwnedPolicy) -> bool {
check_flags(tokens, policy.standalone.as_slice(), policy.valued.as_slice(), policy.bare, policy.max_positional, policy.tolerance)
}
pub(super) fn first_positional<'a>(tokens: &'a [Token], policy: &OwnedPolicy) -> Option<&'a str> {
first_positional_at(tokens, policy).map(|(_, s)| s)
}
pub(super) fn first_positional_at<'a>(tokens: &'a [Token], policy: &OwnedPolicy) -> Option<(usize, &'a str)> {
let standalone = policy.standalone.as_slice();
let valued = policy.valued.as_slice();
let mut i = 1;
while i < tokens.len() {
let t = &tokens[i];
let s = t.as_str();
if s == "--" {
return tokens.get(i + 1).map(|t| (i + 1, t.as_str()));
}
if s == "-" || !s.starts_with('-') {
return Some((i, s));
}
if standalone.contains_flag(s) {
i += 1;
continue;
}
if valued.contains_flag(s) {
i += 2;
continue;
}
if let Some((flag, _)) = s.split_once('=')
&& valued.contains_flag(flag)
{
i += 1;
continue;
}
return None;
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parse::Token;
use crate::policy::FlagTolerance;
fn toks(words: &[&str]) -> Vec<Token> {
words.iter().map(|s| Token::from_test(s)).collect()
}
fn policy(standalone: &[&str], valued: &[&str], max_positional: Option<usize>) -> OwnedPolicy {
OwnedPolicy {
standalone: standalone.iter().map(|s| s.to_string()).collect(),
valued: valued.iter().map(|s| s.to_string()).collect(),
bare: true,
max_positional,
tolerance: FlagTolerance::strict(),
}
}
#[test]
fn first_positional_finds_bare_arg() {
let p = policy(&[], &[], None);
let tokens = toks(&["cmd", "arg"]);
assert_eq!(first_positional(&tokens, &p), Some("arg"));
}
#[test]
fn first_positional_skips_standalone_flags() {
let p = policy(&["--verbose"], &[], None);
let tokens = toks(&["cmd", "--verbose", "file"]);
assert_eq!(first_positional(&tokens, &p), Some("file"));
}
#[test]
fn first_positional_skips_valued_flag_value() {
let p = policy(&[], &["--type"], None);
let tokens = toks(&["cmd", "--type", "erb", "file.erb"]);
assert_eq!(first_positional(&tokens, &p), Some("file.erb"));
}
#[test]
fn first_positional_skips_valued_flag_eq_form() {
let p = policy(&[], &["--type"], None);
let tokens = toks(&["cmd", "--type=erb", "file.erb"]);
assert_eq!(first_positional(&tokens, &p), Some("file.erb"));
}
#[test]
fn first_positional_treats_double_dash_as_terminator() {
let p = policy(&["--verbose"], &[], None);
let tokens = toks(&["cmd", "--", "--verbose"]);
assert_eq!(first_positional(&tokens, &p), Some("--verbose"));
}
#[test]
fn first_positional_treats_lone_dash_as_positional() {
let p = policy(&[], &[], None);
let tokens = toks(&["cmd", "-"]);
assert_eq!(first_positional(&tokens, &p), Some("-"));
}
#[test]
fn first_positional_returns_none_for_no_args() {
let p = policy(&[], &[], None);
let tokens = toks(&["cmd"]);
assert_eq!(first_positional(&tokens, &p), None);
}
#[test]
fn first_positional_returns_none_for_only_flags() {
let p = policy(&["--help"], &[], None);
let tokens = toks(&["cmd", "--help"]);
assert_eq!(first_positional(&tokens, &p), None);
}
#[test]
fn first_positional_returns_none_on_unknown_flag() {
let p = policy(&[], &[], None);
let tokens = toks(&["cmd", "--unknown", "file"]);
assert_eq!(first_positional(&tokens, &p), None);
}
mod prop {
use super::*;
use proptest::prelude::*;
const FLAG_RE: &str = "--?[a-zA-Z][a-zA-Z0-9._-]{0,7}(=[a-zA-Z0-9._-]{1,8})?";
const NON_FLAG_RE: &str = "[a-zA-Z][a-zA-Z0-9._]{0,7}";
fn toks_owned(words: &[String]) -> Vec<Token> {
words.iter().cloned().map(Token::from_raw).collect()
}
fn oracle_first_positional_at(tokens: &[Token]) -> Option<(usize, &str)> {
let (offset, t) = tokens.iter().enumerate().nth(1)?;
let s = t.as_str();
if s == "--" {
let i = offset + 1;
return tokens.get(i).map(|t| (i, t.as_str()));
}
if s == "-" || !s.starts_with('-') {
return Some((offset, s));
}
None
}
proptest! {
#[test]
fn first_positional_and_first_positional_at_agree_on_the_returned_string(
tail in proptest::collection::vec("(-|--|--?[a-zA-Z][a-zA-Z0-9._-]{0,7}(=[a-zA-Z0-9._-]{1,8})?|[a-zA-Z][a-zA-Z0-9._]{0,7})", 0..8),
standalone in proptest::collection::vec(FLAG_RE, 0..4),
valued in proptest::collection::vec(FLAG_RE, 0..4),
) {
let mut words = vec!["cmd".to_string()];
words.extend(tail);
let tokens = toks_owned(&words);
let p = policy(
&standalone.iter().map(String::as_str).collect::<Vec<_>>(),
&valued.iter().map(String::as_str).collect::<Vec<_>>(),
None,
);
let expected = first_positional_at(&tokens, &p).map(|(_, s)| s.to_string());
let actual = first_positional(&tokens, &p).map(|s| s.to_string());
proptest::prop_assert_eq!(actual, expected);
}
/// Index honesty: the index returned by `first_positional_at` must really point
/// at the token whose text it pairs with. The walk stores `(i, s)` as one
/// expression so they cannot disagree by construction, and this property pins
/// that coupling against any future change that recomputes one half from a
/// different loop variable.
#[test]
fn first_positional_at_index_points_at_the_returned_token(
standalone in proptest::collection::vec(FLAG_RE, 0..3),
valued in proptest::collection::vec(FLAG_RE, 0..3),
tail in proptest::collection::vec(
"(--|--?[a-zA-Z][a-zA-Z0-9._-]{0,7}(=[a-zA-Z0-9._-]{1,8})?|[a-zA-Z][a-zA-Z0-9._]{0,7})",
1..6,
),
) {
let mut words = vec!["cmd".to_string()];
words.extend(tail);
let tokens = toks_owned(&words);
let p = policy(
&standalone.iter().map(String::as_str).collect::<Vec<_>>(),
&valued.iter().map(String::as_str).collect::<Vec<_>>(),
None,
);
if let Some((i, s)) = first_positional_at(&tokens, &p) {
proptest::prop_assert!(i < tokens.len(), "index {} past tokens.len()={}", i, tokens.len());
proptest::prop_assert_eq!(tokens[i].as_str(), s);
}
}
/// Oracle / differential: with both `standalone` and `valued` empty the walk
/// collapses to a four-line scan. This property states that the contracted
/// implementation equals the inlined reference for EVERY generated shape, so
/// a refactor that introduced extra logic for the empty-grammar case would be
/// caught — the empty case is the one that has to match the docstring.
#[test]
fn first_positional_with_empty_grammars_matches_a_simple_scan(
tail in proptest::collection::vec(
"(-|--|--?[a-zA-Z][a-zA-Z0-9._-]{0,7}(=[a-zA-Z0-9._-]{1,8})?|[a-zA-Z][a-zA-Z0-9._]{0,7})",
0..6,
),
) {
let mut words = vec!["cmd".to_string()];
words.extend(tail);
let tokens = toks_owned(&words);
let p = policy(&[], &[], None);
let oracle = oracle_first_positional_at(&tokens).map(|(i, s)| (i, s.to_string()));
let actual = first_positional_at(&tokens, &p).map(|(i, s)| (i, s.to_string()));
proptest::prop_assert_eq!(actual, oracle);
}
/// Monotonicity: prepending a token that the policy declares as a STANDALONE flag
/// does not change the first positional found later. The walk consumes a standalone
/// in exactly one step (`i += 1`), so the rest of the scan is identical; the
/// property locks that "additive, not destructive" behavior in. The prepended flag
/// is taken from the SAME generated standalone list so we are asserting on real
/// acceptance, not on unknown-flag bailouts.
#[test]
fn prepending_a_known_standalone_flag_does_not_change_the_first_positional(
standalone in proptest::collection::vec(FLAG_RE, 1..4),
tail in proptest::collection::vec(NON_FLAG_RE, 0..5),
) {
let prefix_token = standalone[0].clone();
let mut base = vec!["cmd".to_string()];
base.extend(tail.clone());
let base_tokens = toks_owned(&base);
let p = policy(
&standalone.iter().map(String::as_str).collect::<Vec<_>>(),
&[],
None,
);
let before = first_positional(&base_tokens, &p).map(|s| s.to_string());
let mut prefixed = vec!["cmd".to_string(), prefix_token];
prefixed.extend(tail);
let prefixed_tokens = toks_owned(&prefixed);
let after = first_positional(&prefixed_tokens, &p).map(|s| s.to_string());
proptest::prop_assert_eq!(after, before);
}
}
}
}