From 5eab8ec7ef3cbc2e0fa70804f7968a151d166336 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Mon, 5 Oct 2026 21:43:45 +1300 Subject: [PATCH] Support positional variadic task arguments. --- context/design.md | 8 ++-- context/development.md | 3 ++ context/task-libraries.md | 4 +- crates/macros/src/expansion_tests.rs | 24 ++++++++++ crates/macros/src/lib.rs | 42 ++++++++++++++++- readme.md | 14 +++++- releases.md | 5 ++ src/arguments.rs | 10 +++- src/registry.rs | 28 ++++++++++- tests/tasks.rs | 69 ++++++++++++++++++++++++++++ 10 files changed, 198 insertions(+), 9 deletions(-) diff --git a/context/design.md b/context/design.md index 0c116fe..f29fbe3 100644 --- a/context/design.md +++ b/context/design.md @@ -16,9 +16,11 @@ existing custom commands and metadata; a native Cargo tasks table is unnecessary The task attribute generates a descriptor and argument adapter next to the original function. The function remains directly callable. Required scalars are positional; -defaults, Option, and Vec make named arguments. Function documentation becomes -task help. The macro checks unsupported signatures and lets Rust check argument -traits, output serialization, and the original function body. +defaults, Option, and Vec make named arguments. A Vec can opt into unbounded +positional arguments with `#[bake(positional)]`; it consumes bare values until +`::` or the end of the task invocation. Function documentation becomes task help. +The macro checks unsupported signatures and lets Rust check argument traits, +output serialization, and the original function body. Each task macro adds a descriptor to a linkme distributed slice. The executable uses `Registry::discover()` to collect tasks from itself and linked dependencies. diff --git a/context/development.md b/context/development.md index ea04b08..5130fb0 100644 --- a/context/development.md +++ b/context/development.md @@ -55,6 +55,9 @@ registered tasks are linked into the binary. output task returns that result after writing it, so later tasks can reuse it. - Supplied values are validated before execution, then parsed by generated adapters. Default expressions run at invocation time. +- Required scalars are positional; `Option` and `Vec` are named by default. The + opt-in positional `Vec` is unbounded and ends only at `::` or the end of the + invocation, so it must be the last positional argument. - Release documents use unindented ATX headings and fenced code blocks. This is a deliberate narrow document format, not a complete CommonMark parser. - The release updater replaces the document through a temporary file in the same diff --git a/context/task-libraries.md b/context/task-libraries.md index 97fd558..83be40e 100644 --- a/context/task-libraries.md +++ b/context/task-libraries.md @@ -227,7 +227,9 @@ validates their metadata, and detects collisions across linked crates. - Use typed parameters. Required scalars are positional by default; defaults, `Option`, and `Vec` produce named options. Use `#[bake(named)]` for a required - named option and document the purpose of arguments with `help`. + named option. A `Vec` can opt into positional variadic arguments with + `#[bake(positional)]`; it must be the last positional parameter, and callers + must use `::` before a following task. Document arguments with `help`. - Resolve project-relative paths against `context.root()`. Use `context.command(...)` for project subprocesses, or pass an explicit working directory to an operation acting on another checkout. Avoid changing the diff --git a/crates/macros/src/expansion_tests.rs b/crates/macros/src/expansion_tests.rs index 8a0b09c..726b60d 100644 --- a/crates/macros/src/expansion_tests.rs +++ b/crates/macros/src/expansion_tests.rs @@ -30,6 +30,12 @@ fn rejects_unsupported_signatures() { "fn example(#[bake(input)] value: String) {}", "fn example(#[bake(input)] first: Value, #[bake(input)] second: Value) {}", "fn example(#[bake(input, named)] value: Value) {}", + "fn example(#[bake(positional)] value: String) {}", + "fn example(#[bake(named, positional)] values: Vec) {}", + "fn example(#[bake(positional)] values: Vec, name: String) {}", + "fn example(#[bake(positional)] first: Vec, #[bake(positional)] second: Vec) {}", + "fn example(#[bake(input, positional)] value: Value) {}", + "fn example(#[bake(context, positional)] context: &mut Context) {}", ] { assert!( expand(quote!(), syn::parse_str(source).unwrap()).is_err(), @@ -200,6 +206,24 @@ fn expands_optional_repeated_context_and_named_parameters() { assert!(output.contains("Describe the generated task.")); } +#[test] +fn expands_positional_variadic_arguments() { + let output = expand( + quote!(), + syn::parse_quote! { + fn example(#[bake(positional, help = "Input files.")] paths: Vec) -> Result> { + Ok(paths) + } + }, + ) + .unwrap() + .to_string(); + + assert!(output.contains("variadic ()")); + assert!(output.contains("repeated :: < String >")); + assert!(output.contains("Input files.")); +} + #[test] fn expands_context_parameters_by_convention() { let output = expand( diff --git a/crates/macros/src/lib.rs b/crates/macros/src/lib.rs index a61c487..d158c76 100644 --- a/crates/macros/src/lib.rs +++ b/crates/macros/src/lib.rs @@ -28,6 +28,7 @@ fn expand_or_compile_error( struct ParameterOptions { default: Option, named: bool, + positional: bool, context: bool, input: bool, help: Option, @@ -49,6 +50,8 @@ fn options(attributes: &mut Vec) -> syn::Result { options.default = Some(meta.value()?.parse()?); } else if meta.path.is_ident("named") { options.named = true; + } else if meta.path.is_ident("positional") { + options.positional = true; } else if meta.path.is_ident("context") { options.context = true; } else if meta.path.is_ident("input") { @@ -56,7 +59,9 @@ fn options(attributes: &mut Vec) -> syn::Result { } else if meta.path.is_ident("help") { options.help = Some(meta.value()?.parse()?); } else { - return Err(meta.error("expected default, named, context, input, or help")); + return Err( + meta.error("expected default, named, positional, context, input, or help") + ); } Ok(()) })?; @@ -170,6 +175,7 @@ fn expand( let resolved_name = format_ident!("__BAKE_TASK_NAME", span = proc_macro2::Span::mixed_site()); let mut has_context = false; let mut has_input = false; + let mut has_positional_variadic = false; for input in &mut function.sig.inputs { let FnArg::Typed(argument) = input else { return Err(syn::Error::new_spanned( @@ -203,6 +209,7 @@ fn expand( || has_input || settings.default.is_some() || settings.named + || settings.positional || settings.context || settings.help.is_some() { @@ -224,6 +231,7 @@ fn expand( if has_context || settings.default.is_some() || settings.named + || settings.positional || settings.help.is_some() || settings.input { @@ -244,6 +252,32 @@ fn expand( } let optional_type = inner_type("Option", parameter_type); let repeated_type = inner_type("Vec", parameter_type); + if settings.positional && repeated_type.is_none() { + return Err(syn::Error::new_spanned( + parameter_type, + "#[bake(positional)] requires a Vec parameter", + )); + } + if settings.positional && settings.named { + return Err(syn::Error::new_spanned( + argument, + "a positional variadic parameter cannot also be named", + )); + } + let is_positional_parameter = settings.positional + || (!settings.named + && settings.default.is_none() + && optional_type.is_none() + && repeated_type.is_none()); + if has_positional_variadic && is_positional_parameter { + return Err(syn::Error::new_spanned( + argument, + "a positional variadic parameter must be the last positional argument", + )); + } + if settings.positional { + has_positional_variadic = true; + } if settings.default.is_some() && (optional_type.is_some() || repeated_type.is_some()) { return Err(syn::Error::new_spanned( parameter_type, @@ -256,7 +290,11 @@ fn expand( parameter = quote!(#parameter.named().optional()); quote!(#invocation_arguments.optional::<#parsed_type>(#parameter_name)?) } else if repeated_type.is_some() { - parameter = quote!(#parameter.repeated()); + parameter = if settings.positional { + quote!(#parameter.variadic()) + } else { + quote!(#parameter.repeated()) + }; quote!(#invocation_arguments.repeated::<#parsed_type>(#parameter_name)?) } else if let Some(default) = &settings.default { let description = quote!(#default).to_string(); diff --git a/readme.md b/readme.md index c045803..6c3dbc2 100644 --- a/readme.md +++ b/readme.md @@ -107,6 +107,7 @@ final names whether registered manually or collected by `Registry::discover()`. | `#[bake(default = "releases.md")] path: PathBuf` | String literal converted to the parameter type | | `output: Option` | Optional named argument, defaults to `None` | | `labels: Vec` | Repeatable named argument, defaults to an empty vector | +| `#[bake(positional)] paths: Vec` | Variadic positional arguments, consumed through `::` or the end of the command | | `#[bake(default = false)] verbose: bool` | `--verbose true` or `--verbose false` | | `context: &mut Context` | Injected execution context, omitted from command-line arguments | | `#[bake(input)] input: Value` | Injected result from the preceding task in a chain | @@ -123,6 +124,15 @@ accept hyphens in place of underscores. Use `--` before positional values that look like options. `::` is reserved as a task separator. UTF-8 task arguments are required. +By default, `Vec` parameters are repeatable named options. Add +`#[bake(positional)]` to make a `Vec` consume bare positional values instead. +It must be the last positional parameter in its task. Since it cannot infer +where a following task starts, use `::` before another task: + +```sh +cargo bake files:normalize path/one.md path/two.md :: output +``` + Task functions return `Result` where `Output` implements `serde::Serialize` and the error implements `Display`. `bake::Result` is a convenience alias. After the final task, Bake invokes its registered `output` @@ -149,7 +159,9 @@ with `registry.replace("output", custom_output_task())`. ## Composition and hooks Chain tasks with `::`. Bare task names also start a new task once the preceding -task's positional arguments are filled. Explicit separators make intent clearer: +task's positional arguments are filled. An unbounded positional `Vec` consumes +all bare arguments through the end of its invocation, so an explicit `::` is +required before another task. Explicit separators make intent clearer: ```sh cargo bake add 20 22 :: result diff --git a/releases.md b/releases.md index 4d84725..1cd9f5f 100644 --- a/releases.md +++ b/releases.md @@ -1,5 +1,10 @@ # Releases +## Unreleased + +- Support unbounded positional `Vec` task arguments with + `#[bake(positional)]`. Require `::` before chaining another task. + ## v0.18.0 - Derive default task namespaces from `bake_*` library crate names, stripping diff --git a/src/arguments.rs b/src/arguments.rs index 265b966..8775792 100644 --- a/src/arguments.rs +++ b/src/arguments.rs @@ -49,6 +49,13 @@ impl Parameter { self.required = false; self } + /// Consume repeated bare values until the task chain separator. + pub fn variadic(mut self) -> Self { + self.repeated = true; + self.positional = true; + self.required = false; + self + } pub fn default(mut self, value: impl Into) -> Self { self.default = Some(value.into()); self.required = false; @@ -163,7 +170,8 @@ impl Arguments { consumed += 1; arguments.insert(parameter, value)?; } else if let Some(parameter) = parameters.iter().find(|parameter| { - parameter.positional && !arguments.0.contains_key(¶meter.name) + parameter.positional + && (parameter.repeated || !arguments.0.contains_key(¶meter.name)) }) { arguments.insert(parameter, token)?; consumed += 1; diff --git a/src/registry.rs b/src/registry.rs index ffe9fa2..3326695 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -184,7 +184,11 @@ impl Registry { "named" }; let display_name = if parameter.positional { - parameter.name.clone() + if parameter.repeated { + format!("{}...", parameter.name) + } else { + parameter.name.clone() + } } else { format!("--{} value", parameter.name.replace('_', "-")) }; @@ -311,6 +315,7 @@ fn validate_task(task: &Task) -> Result<()> { return Err(Error::new(format!("invalid task name {:?}", task.name))); } let mut parameters = BTreeSet::new(); + let mut positional_variadic_seen = false; for parameter in &task.parameters { if parameter.name.is_empty() || !parameter @@ -329,6 +334,14 @@ fn validate_task(task: &Task) -> Result<()> { parameter.name ))); } + if positional_variadic_seen && parameter.positional { + return Err(Error::new( + "a positional variadic parameter must be the last positional argument", + )); + } + if parameter.positional && parameter.repeated { + positional_variadic_seen = true; + } } Ok(()) } @@ -439,6 +452,19 @@ mod tests { assert!(registry.register(task("duplicate", vec![])).is_err()); } + #[test] + fn positional_variadic_must_be_the_last_positional_parameter() { + let task = task( + "invalid_positional_variadic", + vec![ + crate::Parameter::new::("paths").variadic(), + crate::Parameter::new::("path"), + ], + ); + + assert!(validate_task(&task).is_err()); + } + #[test] fn discovers_tasks_and_includes_namespaces() { let discovered = Registry::discover().unwrap(); diff --git a/tests/tasks.rs b/tests/tasks.rs index 7d5949b..c67e0cb 100644 --- a/tests/tasks.rs +++ b/tests/tasks.rs @@ -23,6 +23,14 @@ fn options( bake::value((output, labels, release_path)) } +#[bake::task] +fn collect_files( + #[bake(positional, help = "Files to collect.")] files: Vec, + #[bake(default = false)] recursive: bool, +) -> Result { + bake::value((files, recursive)) +} + #[bake::task] fn add(left: i64, right: i64) -> Result { Ok(left + right) @@ -107,6 +115,7 @@ fn registry() -> Registry { for task in [ greet_task(), options_task(), + collect_files_task(), add_task(), previous_task(), remember_task(), @@ -186,6 +195,52 @@ fn optional_and_repeated_values_and_hyphenated_names() { ); } +#[test] +fn positional_variadic_arguments_consume_values_until_the_chain_separator() { + assert_eq!( + serde_json::from_str::(&run(&["collect_files"]).unwrap()).unwrap(), + serde_json::json!([[], false]) + ); + + assert_eq!( + serde_json::from_str::(&run(&["collect_files", "one.md", "two.md"]).unwrap()) + .unwrap(), + serde_json::json!([["one.md", "two.md"], false]) + ); + + assert_eq!( + serde_json::from_str::( + &run(&["collect_files", "one.md", "two.md", "::", "previous",]).unwrap() + ) + .unwrap(), + serde_json::json!([["one.md", "two.md"], false]) + ); + + assert_eq!( + serde_json::from_str::(&run(&["collect_files", "one.md", "previous"]).unwrap()) + .unwrap(), + serde_json::json!([["one.md", "previous"], false]) + ); + + assert_eq!( + serde_json::from_str::( + &run(&["collect_files", "one.md", "two.md", "--recursive", "true",]).unwrap() + ) + .unwrap(), + serde_json::json!([["one.md", "two.md"], true]) + ); + + assert_eq!( + serde_json::from_str::(&run(&["collect_files", "--", "--input.md"]).unwrap()) + .unwrap(), + serde_json::json!([["--input.md"], false]) + ); + + let help = registry().help(Some("collect_files")).unwrap(); + assert!(help.contains("files...")); + assert!(help.contains("Files to collect.")); +} + #[test] fn negative_numbers_and_literal_positional_arguments() { assert_eq!(run(&["add", "-2", "5"]).unwrap(), "3\n"); @@ -474,6 +529,20 @@ fn descriptor_validation() { )) .is_err() ); + + assert!( + Registry::new() + .register(Task::new( + "invalid_positional_variadic", + "", + vec![ + Parameter::new::("paths").variadic(), + Parameter::new::("path"), + ], + handler, + )) + .is_err() + ); } #[test]