diff --git a/cli/src/cli/exec.rs b/cli/src/cli/exec.rs index 82766c5b..22ab6393 100644 --- a/cli/src/cli/exec.rs +++ b/cli/src/cli/exec.rs @@ -78,6 +78,12 @@ impl Exec { for (key, val) in &parsed.as_env() { cmd.env(key, val); } + if let Some(trailing) = parsed.trailing_varargs_count() { + cmd.env( + "usage__varargs_idx", + self.args.len().saturating_sub(trailing).to_string(), + ); + } let result = cmd.spawn().into_diagnostic()?.wait().into_diagnostic()?; diff --git a/cli/src/cli/shell.rs b/cli/src/cli/shell.rs index 670c7bd9..7520241f 100644 --- a/cli/src/cli/shell.rs +++ b/cli/src/cli/shell.rs @@ -63,6 +63,12 @@ impl Shell { for (key, val) in &parsed.as_env() { cmd.env(key, val); } + if let Some(trailing) = parsed.trailing_varargs_count() { + cmd.env( + "usage__varargs_idx", + self.args.len().saturating_sub(trailing).to_string(), + ); + } let result = cmd.spawn().into_diagnostic()?.wait().into_diagnostic()?; diff --git a/docs/cli/scripts.md b/docs/cli/scripts.md index ec707c65..f9a6da84 100644 --- a/docs/cli/scripts.md +++ b/docs/cli/scripts.md @@ -98,3 +98,30 @@ by spaces. If an arg itself has a space, then it will have quotes around it. Thi by [`shell_words::join()`](https://docs.rs/shell-words/latest/shell_words/fn.join.html). For now, this is not customizable behavior. It would be possible to support [alternatives](https://github.com/jdx/usage/issues/189) though. + +## Trailing Varargs + +Usage always sets the `usage__varargs_idx` environment variable on the subprocess. Its value is the +number of parsed args in `$@` that precede the trailing varargs. A script can `shift` by this amount +to isolate the varargs in `$@`. When there are no trailing varargs, the value equals the total number +of args, so `shift $usage__varargs_idx` empties `$@`: + +```bash +#!/usr/bin/env -S usage bash +#USAGE flag "-v --verbose" help="Enable verbose output" +#USAGE arg "" help="Input file" +#USAGE arg "[extra...]" help="Extra arguments passed through" + +echo "file: $usage_file" +echo "extra: $usage_extra" + +shift "$usage__varargs_idx" +echo "raw varargs in \$@: $@" +``` + +```bash +$ ./mycli --verbose input.txt foo bar baz +file: input.txt +extra: foo bar baz +raw varargs in $@: foo bar baz +``` diff --git a/lib/src/parse.rs b/lib/src/parse.rs index c2411e25..fb6c885d 100644 --- a/lib/src/parse.rs +++ b/lib/src/parse.rs @@ -809,6 +809,23 @@ impl ParseOutput { } env } + + pub fn trailing_varargs_count(&self) -> Option { + if !self.errors.is_empty() { + return None; + } + Some( + self.args + .iter() + .rev() + .take_while(|(a, _)| a.var) + .filter_map(|(_, v)| match v { + ParseValue::MultiString(s) => Some(s.len()), + _ => None, + }) + .sum(), + ) + } } impl Display for ParseValue { @@ -2146,4 +2163,187 @@ mod tests { _ => panic!("Expected MultiString, got {:?}", args_val), } } + + #[test] + fn test_trailing_varargs_count_basic() { + let spec: Spec = r#" + flag "--verbose" + arg "" + arg "[extra...]" + "# + .parse() + .unwrap(); + let input: Vec = vec!["test", "--verbose", "myfile", "extra1", "extra2"] + .into_iter() + .map(String::from) + .collect(); + let parsed = parse(&spec, &input).unwrap(); + assert_eq!(parsed.trailing_varargs_count(), Some(2)); + } + + #[test] + fn test_trailing_varargs_count_no_varargs() { + let spec: Spec = r#" + flag "--verbose" + arg "" + "# + .parse() + .unwrap(); + let input: Vec = vec!["test", "--verbose", "myfile"] + .into_iter() + .map(String::from) + .collect(); + let parsed = parse(&spec, &input).unwrap(); + assert_eq!(parsed.trailing_varargs_count(), Some(0)); + } + + #[test] + fn test_trailing_varargs_count_with_double_dash() { + let spec: Spec = r#" + flag "--verbose" + arg "" + arg "[extra...]" + "# + .parse() + .unwrap(); + let input: Vec = vec!["test", "--verbose", "myfile", "--", "extra1", "extra2"] + .into_iter() + .map(String::from) + .collect(); + let parsed = parse(&spec, &input).unwrap(); + assert_eq!(parsed.trailing_varargs_count(), Some(2)); + } + + #[test] + fn test_trailing_varargs_count_with_subcommand() { + let cmd = SpecCommand::builder() + .name("test") + .subcommand( + SpecCommand::builder() + .name("run") + .arg(SpecArg::builder().name("task").build()) + .arg( + SpecArg::builder() + .name("args") + .required(false) + .var(true) + .build(), + ) + .build(), + ) + .build(); + let spec = Spec { + name: "test".to_string(), + bin: "test".to_string(), + cmd, + ..Default::default() + }; + let input: Vec = vec!["test", "run", "build", "arg1", "arg2"] + .into_iter() + .map(String::from) + .collect(); + let parsed = parse(&spec, &input).unwrap(); + let env = parsed.as_env(); + assert_eq!(env.get("usage_task").unwrap(), "build"); + assert_eq!(env.get("usage_args").unwrap(), "arg1 arg2"); + assert_eq!(parsed.trailing_varargs_count(), Some(2)); + } + + #[test] + fn test_trailing_varargs_count_multiple_groups() { + let cmd = SpecCommand::builder() + .name("test") + .arg( + SpecArg::builder() + .name("files") + .required(false) + .var(true) + .var_max(2) + .build(), + ) + .arg( + SpecArg::builder() + .name("dirs") + .required(false) + .var(true) + .build(), + ) + .build(); + let spec = Spec { + name: "test".to_string(), + bin: "test".to_string(), + cmd, + ..Default::default() + }; + let input: Vec = vec!["test", "f1", "f2", "d1", "d2", "d3"] + .into_iter() + .map(String::from) + .collect(); + let parsed = parse(&spec, &input).unwrap(); + let env = parsed.as_env(); + assert_eq!(env.get("usage_files").unwrap(), "f1 f2"); + assert_eq!(env.get("usage_dirs").unwrap(), "d1 d2 d3"); + assert_eq!(parsed.trailing_varargs_count(), Some(5)); + } + + #[test] + fn test_trailing_varargs_count_non_trailing_excluded() { + let cmd = SpecCommand::builder() + .name("test") + .arg( + SpecArg::builder() + .name("files") + .required(false) + .var(true) + .var_max(2) + .build(), + ) + .arg(SpecArg::builder().name("output").build()) + .arg( + SpecArg::builder() + .name("extra") + .required(false) + .var(true) + .build(), + ) + .build(); + let spec = Spec { + name: "test".to_string(), + bin: "test".to_string(), + cmd, + ..Default::default() + }; + let input: Vec = vec!["test", "f1", "f2", "out", "e1", "e2"] + .into_iter() + .map(String::from) + .collect(); + let parsed = parse(&spec, &input).unwrap(); + let env = parsed.as_env(); + assert_eq!(env.get("usage_files").unwrap(), "f1 f2"); + assert_eq!(env.get("usage_output").unwrap(), "out"); + assert_eq!(env.get("usage_extra").unwrap(), "e1 e2"); + assert_eq!(parsed.trailing_varargs_count(), Some(2)); + } + + #[test] + fn test_trailing_varargs_count_non_multi_string() { + let cmd = SpecCommand::builder() + .name("test") + .arg(SpecArg::builder().name("extra").var(true).build()) + .build(); + let mut out = ParseOutput { + cmd: cmd.clone(), + cmds: vec![cmd], + args: IndexMap::new(), + flags: IndexMap::new(), + available_flags: BTreeMap::new(), + flag_awaiting_value: vec![], + errors: vec![], + }; + out.args.insert( + Arc::new(SpecArg::builder().name("extra").var(true).build()), + ParseValue::String("single".to_string()), + ); + assert_eq!(out.trailing_varargs_count(), Some(0)); + } }