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
2 changes: 1 addition & 1 deletion docs/books/src/reference/meta.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ A fenced block with the language `meta` (TOML) in a task section. Unknown keys a
Error: Invalid task file: task 'flaky': `meta` block: unknown key `retires` on line 1 (did you mean `retries`?)
```

`imports` and the other document-wide keys go in a `meta` block before the first heading.
`imports` and the other document-wide keys go in a `meta` block before the first heading. That block also accepts `runtimes` and `default_task` ([Configuration](../start/configuration.md#in-the-task-file)).

| Key | Type | Description |
|---|---|---|
Expand Down
20 changes: 20 additions & 0 deletions docs/books/src/start/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,26 @@ execution_mode = "arg"

Top-level key (outside `[runtimes]`). The task run when `mq-task` is invoked without a task name.

## In the task file

`runtimes` and `default_task` can also be written in the document-wide `meta` block (before the first heading), so that a single Markdown file carries everything it needs and no `mq-task.toml` has to sit next to it:

````markdown
```meta
default_task = "build"
runtimes = { python = "python3.12", go = { command = "go run", execution_mode = "file" } }
```
````

`runtimes` takes the same values as `[runtimes]` above. The file adds to the built-in runtimes and replaces those it names. Precedence, highest first:

1. `-r/--runtime` on the command line
2. the languages set in `mq-task.toml` (and its `default_task`)
3. the task file's own `meta` block
4. the built-in defaults

So a machine can still pin its own interpreter with `mq-task.toml`, and a file in the task file is only a default for everyone else. Imported files can add runtimes for languages the importing file leaves alone. `mq-task check` knows about them, and reports a `default_task` that names no task.

## Overriding from the CLI

```sh
Expand Down
4 changes: 2 additions & 2 deletions docs/books/src/start/migrating.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ mq-task covers what most `justfile`s and `Taskfile.yml`s do, but it is not a dro
| `version := "1.0"` | `vars = { version = "1.0" }` (referenced as `$version` or `${version}`) |
| ``v := `git describe` `` | `vars = { v = { sh = "git describe" } }` |
| `set dotenv-load` | `dotenv = [".env"]` |
| `set shell := ["zsh", "-c"]` | runtimes in `mq-task.toml`, e.g. `bash = "zsh"` |
| `set shell := ["zsh", "-c"]` | runtimes in `mq-task.toml` or in the task file's `meta` block, e.g. `runtimes = { bash = "zsh" }` |
| shebang recipe `#!/usr/bin/env python3` | a ```python block |
| `[private]` or a leading `_` | a leading `_` or `private = true` |
| `[confirm("Sure?")]` | `prompt = "Sure?"` (`--yes` skips it) |
Expand All @@ -37,7 +37,7 @@ mq-task covers what most `justfile`s and `Taskfile.yml`s do, but it is not a dro
| `just a b` | `mq-task a b` (arguments after `--` go to a single task only) |
| `just --no-deps` | `mq-task <task> --no-deps` |
| `just --quiet` | `mq-task <task> -q` |
| first recipe is the default | `default_task = "build"` in `mq-task.toml` |
| first recipe is the default | `default_task = "build"` in `mq-task.toml` or the task file's `meta` block |
| `just --fmt`, `--evaluate` | no equivalent (`mq-task check` validates the file) |

Not available: recipe groups (`[group]`), `{{ }}` interpolation of arguments or functions into the script text, `invocation_directory()` (a task always runs in the file's directory unless you set `dir`), and running a single line of a recipe.
Expand Down
28 changes: 20 additions & 8 deletions src/check.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,20 +79,32 @@ impl Report {
}
}

/// Check the task file at `path` (with its imports) against `config`.
/// Check the task file at `path` (with its imports) against the runner's
/// configuration.
/// `reserved` are names of commands that a task name would collide with.
pub fn check_file(
runner: &mut Runner,
config: &Config,
path: &Path,
reserved: &[String],
) -> Report {
pub fn check_file(runner: &mut Runner, path: &Path, reserved: &[String]) -> Report {
let (sections, errors, failed) = runner.load_sections_lenient(path);
let mut diagnostics: Vec<Diagnostic> = errors
.iter()
.map(|e| Diagnostic::error(None, plain_message(e)))
.collect();
diagnostics.extend(check_sections(&sections, &failed, config, reserved));
// After loading, so that runtimes declared in the file count.
let config = runner.config().clone();
diagnostics.extend(check_sections(&sections, &failed, &config, reserved));
if let Some(task) = runner.document_default_task() {
let known = sections
.iter()
.any(|s| s.title == task || s.aliases.iter().any(|a| a == task));
if !known && !failed.iter().any(|f| f == task) {
let hint = closest(task, sections.iter().map(|s| s.title.as_str()))
.map(|c| format!(" (did you mean `{}`?)", c))
.unwrap_or_default();
diagnostics.push(Diagnostic::error(
None,
format!("`default_task` names unknown task '{}'{}", task, hint),
));
}
}
// Errors first, each group in the order found.
diagnostics.sort_by_key(|d| d.severity != Severity::Error);
Report {
Expand Down
32 changes: 26 additions & 6 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,19 +97,39 @@ impl Config {
/// The configuration beside `task_file` (`mq-task.toml`), or the
/// defaults when there is none.
pub fn discover<P: AsRef<Path>>(task_file: P) -> Result<Self> {
let dir = task_file
.as_ref()
.parent()
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or(Path::new("."));
let path = dir.join(CONFIG_FILE);
let path = Self::path_beside(task_file.as_ref());
if path.is_file() {
Self::from_file(path)
} else {
Ok(Self::default())
}
}

/// Where `discover` looks: `mq-task.toml` in the directory of `task_file`.
pub fn path_beside(task_file: &Path) -> std::path::PathBuf {
task_file
.parent()
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or(Path::new("."))
.join(CONFIG_FILE)
}

/// The languages the configuration file at `path` sets a runtime for.
/// A file without a `[runtimes]` table sets none (the defaults apply),
/// and a missing or unreadable file sets none either.
pub fn explicit_languages<P: AsRef<Path>>(path: P) -> Vec<String> {
let Ok(content) = fs::read_to_string(path) else {
return Vec::new();
};
match toml::from_str::<toml::Table>(&content) {
Ok(table) => match table.get("runtimes") {
Some(toml::Value::Table(runtimes)) => runtimes.keys().cloned().collect(),
_ => Vec::new(),
},
Err(_) => Vec::new(),
}
}

/// Load configuration from a TOML file
pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
let content = fs::read_to_string(path)?;
Expand Down
53 changes: 42 additions & 11 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -469,10 +469,7 @@ fn dispatch(cli: Cli) -> Result<()> {
let file = locate_task_file(cli.file, &mut [&mut config, &mut dir], cli.run.quiet)?;
// No task given: fall back to the configured default task, if any.
let tasks = if cli.task.is_empty() {
load_config(config.clone(), &file)?
.default_task
.into_iter()
.collect()
default_task(config.clone(), &file)?.into_iter().collect()
} else {
cli.task
};
Expand Down Expand Up @@ -514,14 +511,21 @@ fn build_runner(
dir: Option<PathBuf>,
flags: &ExecFlags,
) -> Result<Runner> {
let mut config = load_config(config_path, task_file)?;
let mut config = load_config(config_path.clone(), task_file)?;
let mut pinned = explicit_languages(config_path.as_deref(), task_file);

let exec_mode = execution_mode.map(ExecutionMode::try_from).transpose()?;
if !runtime_overrides.is_empty() {
config.apply_runtime_overrides(runtime_overrides, exec_mode)?;
}

pinned.extend(
runtime_overrides
.iter()
.filter_map(|o| o.split_once(':').map(|(lang, _)| lang.to_string())),
);
let mut runner = Runner::new(config);
runner.pin_runtimes(pinned);
runner.set_dry_run(dry_run);
runner.set_env_overrides(Runner::parse_env_overrides(env)?);
runner.set_working_dir(dir);
Expand Down Expand Up @@ -779,8 +783,7 @@ fn run_tui(

/// Check the task file and print what is wrong with it.
fn check_tasks(file: &Path, config: Option<PathBuf>, strict: bool, json: bool) -> Result<()> {
let config = load_config(config, file)?;
let mut runner = Runner::new(config.clone());
let mut runner = runner_for(config, file)?;
let command = Cli::command();
let reserved: Vec<String> = command
.get_subcommands()
Expand All @@ -789,7 +792,7 @@ fn check_tasks(file: &Path, config: Option<PathBuf>, strict: bool, json: bool) -
})
.chain(std::iter::once("help".to_string()))
.collect();
let report = mq_task::check::check_file(&mut runner, &config, file, &reserved);
let report = mq_task::check::check_file(&mut runner, file, &reserved);

if json {
let value = serde_json::json!({
Expand Down Expand Up @@ -853,7 +856,7 @@ fn show_graph(
task: Option<String>,
format: GraphFormat,
) -> Result<()> {
let mut runner = Runner::new(load_config(config_path, &markdown_path)?);
let mut runner = runner_for(config_path, &markdown_path)?;
print!(
"{}",
runner.dependency_graph(&markdown_path, task.as_deref(), format)?
Expand All @@ -869,8 +872,7 @@ fn list_tasks(
show_all: bool,
json: bool,
) -> Result<()> {
let config = load_config(config_path, &markdown_path)?;
let mut runner = Runner::new(config);
let mut runner = runner_for(config_path, &markdown_path)?;

let sections = runner.list_task_sections(&markdown_path)?;

Expand Down Expand Up @@ -1023,6 +1025,35 @@ fn init(output: Option<PathBuf>, config: bool) -> Result<()> {

/// Load the configuration: `-c`, else `mq-task.toml` next to the task file,
/// else the defaults.
/// The task to run when none is named: `default_task` of the configuration
/// file, else of the task file's own `meta` block.
fn default_task(config_path: Option<PathBuf>, task_file: &Path) -> Result<Option<String>> {
if let Some(task) = load_config(config_path.clone(), task_file)?.default_task {
return Ok(Some(task));
}
let mut runner = runner_for(config_path, task_file)?;
runner.load_sections(task_file)?;
Ok(runner.document_default_task().map(str::to_string))
}

/// The languages the configuration file (given, or beside the task file)
/// sets a runtime for; the task file's own `runtimes` do not replace them.
fn explicit_languages(config_path: Option<&Path>, task_file: &Path) -> Vec<String> {
match config_path {
Some(path) => Config::explicit_languages(path),
None => Config::explicit_languages(Config::path_beside(task_file)),
}
}

/// A runner for reading the task file, with the configuration pinned like
/// `build_runner` does.
fn runner_for(config_path: Option<PathBuf>, task_file: &Path) -> Result<Runner> {
let config = load_config(config_path.clone(), task_file)?;
let mut runner = Runner::new(config);
runner.pin_runtimes(explicit_languages(config_path.as_deref(), task_file));
Ok(runner)
}

fn load_config(config_path: Option<PathBuf>, task_file: &Path) -> Result<Config> {
match config_path {
Some(path) => Config::from_file(path),
Expand Down
Loading
Loading