Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions context/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions context/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion context/task-libraries.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions crates/macros/src/expansion_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>) {}",
"fn example(#[bake(positional)] values: Vec<String>, name: String) {}",
"fn example(#[bake(positional)] first: Vec<String>, #[bake(positional)] second: Vec<String>) {}",
"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(),
Expand Down Expand Up @@ -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<String>) -> Result<Vec<String>> {
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(
Expand Down
42 changes: 40 additions & 2 deletions crates/macros/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ fn expand_or_compile_error(
struct ParameterOptions {
default: Option<Expr>,
named: bool,
positional: bool,
context: bool,
input: bool,
help: Option<LitStr>,
Expand All @@ -49,14 +50,18 @@ fn options(attributes: &mut Vec<Attribute>) -> syn::Result<ParameterOptions> {
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") {
options.input = true;
} 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(())
})?;
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -203,6 +209,7 @@ fn expand(
|| has_input
|| settings.default.is_some()
|| settings.named
|| settings.positional
|| settings.context
|| settings.help.is_some()
{
Expand All @@ -224,6 +231,7 @@ fn expand(
if has_context
|| settings.default.is_some()
|| settings.named
|| settings.positional
|| settings.help.is_some()
|| settings.input
{
Expand All @@ -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<T> 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,
Expand All @@ -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();
Expand Down
14 changes: 13 additions & 1 deletion readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<PathBuf>` | Optional named argument, defaults to `None` |
| `labels: Vec<String>` | Repeatable named argument, defaults to an empty vector |
| `#[bake(positional)] paths: Vec<PathBuf>` | 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 |
Expand All @@ -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<T>` parameters are repeatable named options. Add
`#[bake(positional)]` to make a `Vec<T>` 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<Output, Error>` 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`
Expand All @@ -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<T>` 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
Expand Down
5 changes: 5 additions & 0 deletions releases.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Releases

## Unreleased

- Support unbounded positional `Vec<T>` task arguments with
`#[bake(positional)]`. Require `::` before chaining another task.

## v0.18.0

- Derive default task namespaces from `bake_*` library crate names, stripping
Expand Down
10 changes: 9 additions & 1 deletion src/arguments.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>) -> Self {
self.default = Some(value.into());
self.required = false;
Expand Down Expand Up @@ -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(&parameter.name)
parameter.positional
&& (parameter.repeated || !arguments.0.contains_key(&parameter.name))
}) {
arguments.insert(parameter, token)?;
consumed += 1;
Expand Down
28 changes: 27 additions & 1 deletion src/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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('_', "-"))
};
Expand Down Expand Up @@ -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
Expand All @@ -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(())
}
Expand Down Expand Up @@ -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::<String>("paths").variadic(),
crate::Parameter::new::<String>("path"),
],
);

assert!(validate_task(&task).is_err());
}

#[test]
fn discovers_tasks_and_includes_namespaces() {
let discovered = Registry::discover().unwrap();
Expand Down
69 changes: 69 additions & 0 deletions tests/tasks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
#[bake(default = false)] recursive: bool,
) -> Result<Value> {
bake::value((files, recursive))
}

#[bake::task]
fn add(left: i64, right: i64) -> Result<i64> {
Ok(left + right)
Expand Down Expand Up @@ -107,6 +115,7 @@ fn registry() -> Registry {
for task in [
greet_task(),
options_task(),
collect_files_task(),
add_task(),
previous_task(),
remember_task(),
Expand Down Expand Up @@ -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::<Value>(&run(&["collect_files"]).unwrap()).unwrap(),
serde_json::json!([[], false])
);

assert_eq!(
serde_json::from_str::<Value>(&run(&["collect_files", "one.md", "two.md"]).unwrap())
.unwrap(),
serde_json::json!([["one.md", "two.md"], false])
);

assert_eq!(
serde_json::from_str::<Value>(
&run(&["collect_files", "one.md", "two.md", "::", "previous",]).unwrap()
)
.unwrap(),
serde_json::json!([["one.md", "two.md"], false])
);

assert_eq!(
serde_json::from_str::<Value>(&run(&["collect_files", "one.md", "previous"]).unwrap())
.unwrap(),
serde_json::json!([["one.md", "previous"], false])
);

assert_eq!(
serde_json::from_str::<Value>(
&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::<Value>(&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");
Expand Down Expand Up @@ -474,6 +529,20 @@ fn descriptor_validation() {
))
.is_err()
);

assert!(
Registry::new()
.register(Task::new(
"invalid_positional_variadic",
"",
vec![
Parameter::new::<String>("paths").variadic(),
Parameter::new::<String>("path"),
],
handler,
))
.is_err()
);
}

#[test]
Expand Down
Loading