Skip to content

Commit

Permalink
feat: task argument declarations
Browse files Browse the repository at this point in the history
  • Loading branch information
jdx committed Sep 19, 2024
1 parent 1c0bc95 commit 60da9ce
Show file tree
Hide file tree
Showing 5 changed files with 333 additions and 8 deletions.
48 changes: 48 additions & 0 deletions docs/tasks/toml-tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,51 @@ depends = ['build', 'lint', 'test']
description = 'Cut a new release'
file = 'scripts/release.sh' # execute an external script
```

## Arguments

By default, arguments are passed to the last script in the `run` array. So if a task was defined as:

```toml
[tasks.test]
run = ['cargo test', './scripts/test-e2e.sh']
```

Then running `mise run test foo bar` will pass `foo bar` to `./scripts/test-e2e.sh` but not to `cargo test`.

You can also define arguments using templates:

```toml
[tasks.test]
run = [
'cargo test {{arg(name="cargo_test_args", var=true)}}',
'./scripts/test-e2e.sh {{option(name="e2e_args")}}',
]
```

Then running `mise run test foo bar` will pass `foo bar` to `cargo test`. `mise run test --e2e-args baz` will pass `baz` to `./scripts/test-e2e.sh`.

### Positional Arguments

These are defined in scripts with `{{arg()}}`. They are used for positional arguments where the order matters.

- `i`: The index of the argument. This can be used to specify the order of arguments. Defaults to the order they're defined in the scripts.
- `name`: The name of the argument. This is used for help/error messages.
- `var`: If `true`, multiple arguments can be passed.
- `default`: The default value if the argument is not provided.

### Options

These are defined in scripts with `{{option()}}`. They are used for named arguments where the order doesn't matter.

- `name`: The name of the argument. This is used for help/error messages.
- `var`: If `true`, multiple values can be passed.
- `default`: The default value if the option is not provided.

### Flags

Flags are like options except they don't take values. They are defined in scripts with `{{flag()}}`.

- `name`: The name of the flag. This is used for help/error messages.

The value will be `true` if the flag is passed, and `false` otherwise.
2 changes: 2 additions & 0 deletions mise.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[tasks.cowsay]
run = "cowsay '{{arg(i=0)}} {{option(name='message')}}'"
23 changes: 15 additions & 8 deletions src/cli/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,18 @@ use glob::glob;
use itertools::Itertools;
use once_cell::sync::Lazy;

use super::args::ToolArg;
use crate::cmd::CmdLineRunner;
use crate::config::{Config, Settings};
use crate::errors::Error;
use crate::errors::Error::ScriptFailed;
use crate::file::display_path;
use crate::task::{Deps, GetMatchingExt, Task};
use crate::task_parser::TaskParser;
use crate::toolset::{InstallOptions, ToolsetBuilder};
use crate::ui::{ctrlc, style};
use crate::{env, file, ui};

use super::args::ToolArg;

/// [experimental] Run a tasks
///
/// This command will run a tasks, or multiple tasks in parallel.
Expand Down Expand Up @@ -252,12 +252,19 @@ impl Run {
if let Some(file) = &task.file {
self.exec_file(file, task, &env, &prefix)?;
} else {
for (i, cmd) in task.run.iter().enumerate() {
let args = match i == task.run.len() - 1 {
true => task.args.iter().cloned().collect_vec(),
false => vec![],
};
self.exec_script(cmd, &args, task, &env, &prefix)?;
let parser = TaskParser::new(self.cd.clone()).parse_run_scripts(&task.run)?;
if parser.has_any_args_defined() {
for script in parser.render(&self.args) {
self.exec_script(&script, &[], task, &env, &prefix)?;
}
} else {
for (i, script) in task.run.iter().enumerate() {
let args = match parser.has_any_args_defined() && i == task.run.len() - 1 {
true => task.args.iter().cloned().collect_vec(),
false => vec![],
};
self.exec_script(script, &args, task, &env, &prefix)?;
}
}
}

Expand Down
1 change: 1 addition & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ mod shell;
mod shims;
mod shorthands;
mod task;
mod task_parser;
pub(crate) mod tera;
pub(crate) mod timeout;
mod toml;
Expand Down
267 changes: 267 additions & 0 deletions src/task_parser.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
use crate::tera::{get_tera, BASE_CONTEXT};
use clap::Arg;
use eyre::Result;
use itertools::Itertools;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};

#[derive(Default, Clone)]
pub struct TaskParseArg {
i: usize,
name: String,
required: bool,
var: bool,
// default: Option<String>,
// var_min: Option<usize>,
// var_max: Option<usize>,
// choices: Vec<String>,
}

#[derive(Default)]
pub struct TaskParseResults {
scripts: Vec<String>,
args: Vec<TaskParseArg>,
flags: HashMap<String, TaskParseArg>,
options: HashMap<String, TaskParseArg>,
}

impl TaskParseResults {
pub fn render(&self, args: &[String]) -> Vec<String> {
let mut cmd = clap::Command::new("mise-task");
for arg in &self.args {
cmd = cmd.arg(
Arg::new(arg.name.clone())
.required(arg.required)
.action(if arg.var {
clap::ArgAction::Append
} else {
clap::ArgAction::Set
}),
);
}
for flag in self.flags.values() {
cmd = cmd.arg(
Arg::new(flag.name.clone())
.long(flag.name.clone())
.action(clap::ArgAction::SetTrue),
);
}
for option in self.options.values() {
cmd = cmd.arg(
Arg::new(option.name.clone())
.long(option.name.clone())
.action(if option.var {
clap::ArgAction::Append
} else {
clap::ArgAction::Set
}),
);
}
let matches = cmd.get_matches_from(["mise-task".to_string()].iter().chain(args.iter()));
let mut out = vec![];
for script in &self.scripts {
let mut script = script.clone();
for id in matches.ids() {
let value = if self.flags.contains_key(id.as_str()) {
matches.get_one::<bool>(id.as_str()).unwrap().to_string()
} else {
matches.get_one::<String>(id.as_str()).unwrap().to_string()
};
script = script.replace(&format!("MISE_TASK_ARG:{id}:MISE_TASK_ARG"), &value);
}
out.push(script);
}
out
}

pub fn has_any_args_defined(&self) -> bool {
!self.args.is_empty() || !self.flags.is_empty() || !self.options.is_empty()
}
}

pub struct TaskParser {
dir: Option<PathBuf>,
ctx: tera::Context,
}

impl TaskParser {
pub fn new(dir: Option<PathBuf>) -> Self {
TaskParser {
dir,
ctx: BASE_CONTEXT.clone(),
}
}

fn get_tera(&self) -> tera::Tera {
get_tera(self.dir.as_deref())
}

pub fn parse_run_scripts(&self, scripts: &[String]) -> Result<TaskParseResults> {
let mut tera = self.get_tera();
let input_args = Arc::new(Mutex::new(vec![]));
let template_key = |name| format!("MISE_TASK_ARG:{name}:MISE_TASK_ARG");
tera.register_function("arg", {
{
let input_args = input_args.clone();
move |args: &HashMap<String, tera::Value>| -> tera::Result<tera::Value> {
let i = args
.get("i")
.map(|i| i.as_i64().unwrap() as usize)
.unwrap_or_else(|| input_args.lock().unwrap().len());
let required = args
.get("required")
.map(|r| r.as_bool().unwrap())
.unwrap_or(true);
let var = args
.get("var")
.map(|r| r.as_bool().unwrap())
.unwrap_or(false);
let name = args
.get("name")
.map(|n| n.as_str().unwrap().to_string())
.unwrap_or(i.to_string());
// let default = args.get("default").map(|d| d.as_str().unwrap().to_string());
let arg = TaskParseArg {
i,
name: name.clone(),
required,
var,
// default,
};
input_args.lock().unwrap().push(arg);
Ok(tera::Value::String(template_key(name)))
}
}
});
let input_options = Arc::new(Mutex::new(HashMap::new()));
tera.register_function("option", {
{
let input_options = input_options.clone();
move |args: &HashMap<String, tera::Value>| -> tera::Result<tera::Value> {
let name = args
.get("name")
.map(|n| n.as_str().unwrap().to_string())
.unwrap();
let var = args
.get("var")
.map(|r| r.as_bool().unwrap())
.unwrap_or(false);
// let default = args.get("default").map(|d| d.as_str().unwrap().to_string());
let flag = TaskParseArg {
name: name.clone(),
var,
// default,
..Default::default()
};
input_options.lock().unwrap().insert(name.clone(), flag);
Ok(tera::Value::String(template_key(name)))
}
}
});
let input_flags = Arc::new(Mutex::new(HashMap::new()));
tera.register_function("flag", {
{
let input_flags = input_flags.clone();
move |args: &HashMap<String, tera::Value>| -> tera::Result<tera::Value> {
let name = args
.get("name")
.map(|n| n.as_str().unwrap().to_string())
.unwrap();
// let default = args.get("default").map(|d| d.as_str().unwrap().to_string());
let flag = TaskParseArg {
name: name.clone(),
// default,
..Default::default()
};
input_flags.lock().unwrap().insert(name.clone(), flag);
Ok(tera::Value::String(template_key(name)))
}
}
});
let out = TaskParseResults {
scripts: scripts
.iter()
.map(|s| tera.render_str(s, &self.ctx).unwrap())
.collect(),
args: input_args
.lock()
.unwrap()
.iter()
.cloned()
.sorted_by_key(|a| a.i)
.collect(),
flags: input_flags.lock().unwrap().clone(),
options: input_options.lock().unwrap().clone(),
};
// TODO: ensure no gaps in args, e.g.: 1,2,3,4,5

Ok(out)
}
}

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

#[test]
fn test_task_parse_arg() {
let parser = TaskParser::new(None);
let scripts = vec!["echo {{ arg(i=0, name='foo') }}".to_string()];
let results = parser.parse_run_scripts(&scripts).unwrap();
assert_eq!(
results.scripts,
vec!["echo MISE_TASK_ARG:foo:MISE_TASK_ARG"]
);
let arg0 = results.args.first().unwrap();
assert_eq!(arg0.name, "0");

let scripts = results.render(&["abc".to_string()]);
assert_eq!(scripts, vec!["echo abc"]);
}

#[test]
fn test_task_parse_arg_var() {
let parser = TaskParser::new(None);
let scripts = vec!["echo {{ arg(var=true) }}".to_string()];
let results = parser.parse_run_scripts(&scripts).unwrap();
assert_eq!(results.scripts, vec!["echo MISE_TASK_ARG:0:MISE_TASK_ARG"]);
let arg0 = results.args.first().unwrap();
assert_eq!(arg0.name, "foo");

let scripts = results.render(&["abc".to_string(), "def".to_string()]);
assert_eq!(scripts, vec!["echo abc def"]);
}

#[test]
fn test_task_parse_flag() {
let parser = TaskParser::new(None);
let scripts = vec!["echo {{ flag(name='foo') }}".to_string()];
let results = parser.parse_run_scripts(&scripts).unwrap();
assert_eq!(
results.scripts,
vec!["echo MISE_TASK_ARG:foo:MISE_TASK_ARG"]
);
let flag = results.flags.get("foo").unwrap();
assert_eq!(flag.name, "foo");

let scripts = results.render(&["--foo".to_string()]);
assert_eq!(scripts, vec!["echo true"]);
}

#[test]
fn test_task_parse_option() {
let parser = TaskParser::new(None);
let scripts = vec!["echo {{ option(name='foo') }}".to_string()];
let results = parser.parse_run_scripts(&scripts).unwrap();
assert_eq!(
results.scripts,
vec!["echo MISE_TASK_ARG:foo:MISE_TASK_ARG"]
);
let option = results.options.get("foo").unwrap();
assert_eq!(option.name, "foo");

let scripts = results.render(&["--foo".to_string(), "abc".to_string()]);
assert_eq!(scripts, vec!["echo abc"]);
}
}

0 comments on commit 60da9ce

Please sign in to comment.