From c4d1c72e617bccb20ab64bf3a53cd956b4e6c044 Mon Sep 17 00:00:00 2001 From: harehare Date: Mon, 19 Oct 2026 22:05:14 +0900 Subject: [PATCH] feat: let the task file declare its runtimes and default task The document-wide meta block accepts runtimes (same form as [runtimes] in mq-task.toml) and default_task, so one Markdown file can carry everything it needs. Precedence: -r on the command line, then the languages set in mq-task.toml, then the task file, then the built-in defaults; the main file's runtimes beat an imported file's. check uses the runtimes in effect after loading and reports a default_task that names no task. --- docs/books/src/reference/meta.md | 2 +- docs/books/src/start/configuration.md | 20 ++++++ docs/books/src/start/migrating.md | 4 +- src/check.rs | 28 ++++++--- src/config.rs | 32 ++++++++-- src/main.rs | 53 ++++++++++++---- src/runner.rs | 83 +++++++++++++++++++++++- tests/document_meta_test.rs | 90 +++++++++++++++++++++++++++ tests/runner_test.rs | 73 ++++++++++++++++++++++ 9 files changed, 354 insertions(+), 31 deletions(-) create mode 100644 tests/document_meta_test.rs diff --git a/docs/books/src/reference/meta.md b/docs/books/src/reference/meta.md index 1d895bf..6fbb490 100644 --- a/docs/books/src/reference/meta.md +++ b/docs/books/src/reference/meta.md @@ -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 | |---|---|---| diff --git a/docs/books/src/start/configuration.md b/docs/books/src/start/configuration.md index baae35d..46f551c 100644 --- a/docs/books/src/start/configuration.md +++ b/docs/books/src/start/configuration.md @@ -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 diff --git a/docs/books/src/start/migrating.md b/docs/books/src/start/migrating.md index 2e50184..04b2312 100644 --- a/docs/books/src/start/migrating.md +++ b/docs/books/src/start/migrating.md @@ -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) | @@ -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 --no-deps` | | `just --quiet` | `mq-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. diff --git a/src/check.rs b/src/check.rs index 586d8d6..bb32120 100644 --- a/src/check.rs +++ b/src/check.rs @@ -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 = errors .iter() .map(|e| Diagnostic::error(None, plain_message(e))) .collect(); - diagnostics.extend(check_sections(§ions, &failed, config, reserved)); + // After loading, so that runtimes declared in the file count. + let config = runner.config().clone(); + diagnostics.extend(check_sections(§ions, &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 { diff --git a/src/config.rs b/src/config.rs index 475a028..be0a1a3 100644 --- a/src/config.rs +++ b/src/config.rs @@ -97,12 +97,7 @@ impl Config { /// The configuration beside `task_file` (`mq-task.toml`), or the /// defaults when there is none. pub fn discover>(task_file: P) -> Result { - 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 { @@ -110,6 +105,31 @@ impl Config { } } + /// 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>(path: P) -> Vec { + let Ok(content) = fs::read_to_string(path) else { + return Vec::new(); + }; + match toml::from_str::(&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>(path: P) -> Result { let content = fs::read_to_string(path)?; diff --git a/src/main.rs b/src/main.rs index 453461e..f96fc04 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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 }; @@ -514,14 +511,21 @@ fn build_runner( dir: Option, flags: &ExecFlags, ) -> Result { - 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); @@ -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, 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 = command .get_subcommands() @@ -789,7 +792,7 @@ fn check_tasks(file: &Path, config: Option, 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!({ @@ -853,7 +856,7 @@ fn show_graph( task: Option, 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)? @@ -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)?; @@ -1023,6 +1025,35 @@ fn init(output: Option, 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, task_file: &Path) -> Result> { + 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 { + 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, task_file: &Path) -> Result { + 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, task_file: &Path) -> Result { match config_path { Some(path) => Config::from_file(path), diff --git a/src/runner.rs b/src/runner.rs index 032682a..103bbdc 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -9,7 +9,7 @@ use colored::Colorize; use mq_lang::{DictMap, Engine, Ident, RuntimeValue, parse_markdown_input}; use serde::{Deserialize, Serialize}; -use crate::config::Config; +use crate::config::{Config, RuntimeConfig}; use crate::error::{Error, Result}; use crate::events::{ConsoleObserver, FailureTracker, OutputMode, RunObserver}; use crate::exec::Executor; @@ -183,6 +183,13 @@ struct GlobalMeta { /// Variables available to every task #[serde(default)] vars: Option, + /// Runtimes for this file's languages, in the form of `[runtimes]` in + /// `mq-task.toml` + #[serde(default)] + runtimes: Option, + /// Task run when no task is named + #[serde(default)] + default_task: Option, } /// Task metadata parsed from a `meta` code block within a section @@ -588,6 +595,8 @@ struct Globals { imports: Vec, dotenv: Vec, vars: Vec, + runtimes: Vec<(String, RuntimeConfig)>, + default_task: Option, } /// Options for [`Runner::run_task_with_options`]. @@ -685,6 +694,16 @@ pub struct Runner { exec: Executor, /// `imports` of the file most recently passed to `extract_sections`. imports: Vec, + /// `default_task` of the file most recently passed to `extract_sections`. + file_default_task: Option, + /// `default_task` of the main file of the last load. + default_task: Option, + /// Languages whose runtime the document's `runtimes` must not replace + /// (set by the configuration file or the command line). + pinned_runtimes: HashSet, + /// Languages already given a runtime by a document in this load, so the + /// main file's choice stands over an imported file's. + document_runtimes: HashSet, /// When set, loading records problems here and carries on (`check`). collected: Option, } @@ -707,6 +726,10 @@ impl Runner { engine, exec: Executor::new(config), imports: Vec::new(), + file_default_task: None, + default_task: None, + pinned_runtimes: HashSet::new(), + document_runtimes: HashSet::new(), collected: None, } } @@ -726,6 +749,24 @@ impl Runner { self.exec.use_cache = use_cache; } + /// Keep the runtimes of `languages` (set by the configuration file or the + /// command line) when the document declares its own `runtimes`. + pub fn pin_runtimes>(&mut self, languages: I) { + self.pinned_runtimes.extend(languages); + } + + /// The configuration in effect, including runtimes taken from the + /// `meta` block of the files loaded so far. + pub fn config(&self) -> &Config { + &self.exec.config + } + + /// `default_task` from the document-wide `meta` block of the file last + /// loaded with [`Self::load_sections`]. + pub fn document_default_task(&self) -> Option<&str> { + self.default_task.as_deref() + } + /// Show each code block before it runs (`-v`). pub fn set_verbose(&mut self, verbose: bool) { self.exec.verbose = verbose; @@ -785,6 +826,13 @@ impl Runner { self.exec.global_dotenv = globals.dotenv; self.exec.global_vars = globals.vars; self.imports = globals.imports; + self.file_default_task = globals.default_task; + for (lang, runtime) in globals.runtimes { + if !self.pinned_runtimes.contains(&lang) && self.document_runtimes.insert(lang.clone()) + { + self.exec.config.runtimes.insert(lang, runtime); + } + } let query = format!("{}\n | nodes | sections_with_code()", SECTIONS_QUERY); let result = self @@ -805,9 +853,13 @@ impl Runner { path: P, ) -> (Vec
, Vec, Vec) { self.collected = Some(Collected::default()); + self.document_runtimes.clear(); let mut stack = Vec::new(); let sections = match self.load_file(path.as_ref(), &mut stack) { - Ok((sections, _)) => sections, + Ok((sections, main)) => { + self.default_task = main.default_task; + sections + } Err(e) => { if let Some(collected) = self.collected.as_mut() { collected.errors.push(e); @@ -823,7 +875,9 @@ impl Runner { /// it `imports`. Imported tasks are named `:`. pub fn load_sections>(&mut self, path: P) -> Result> { let mut stack = Vec::new(); - let (sections, _) = self.load_file(path.as_ref(), &mut stack)?; + self.document_runtimes.clear(); + let (sections, main) = self.load_file(path.as_ref(), &mut stack)?; + self.default_task = main.default_task; // Loading imports overwrote the document defaults; the main file's // own defaults apply at run time, so restore them. let markdown = self.load_markdown(path.as_ref())?; @@ -868,6 +922,8 @@ impl Runner { imports: std::mem::take(&mut self.imports), dotenv: self.exec.global_dotenv.clone(), vars: self.exec.global_vars.clone(), + runtimes: Vec::new(), + default_task: self.file_default_task.take(), }; let base = path.parent().unwrap_or_else(|| Path::new(".")); @@ -966,6 +1022,25 @@ impl Runner { Some(table) => vars::parse_table("document `meta` block", table)?, None => Vec::new(), }, + runtimes: match meta.runtimes { + Some(table) => table + .into_iter() + .map(|(lang, value)| { + value + .try_into::() + .map(|runtime| (lang.clone(), runtime)) + .map_err(|e| { + Error::Invalid(format!( + "document `meta` block: `runtimes`: '{}' must be a command or {{ command, execution_mode }}: {}", + lang, + e.message() + )) + }) + }) + .collect::>()?, + None => Vec::new(), + }, + default_task: meta.default_task, }) } @@ -2403,6 +2478,7 @@ echo "deploying" imports: Vec::new(), dotenv: vec![".env".into()], vars: Vec::new(), + ..Default::default() }; let mut with_dir = named("a", &[], &[]); with_dir.env = vec![("K".into(), "task".into())]; @@ -2849,6 +2925,7 @@ true name: "FROM_FILE".into(), value: crate::vars::VarValue::Literal("1".into()), }], + ..Default::default() }; let mut own = named("a", &[], &[]); own.vars = vec![crate::vars::VarDef { diff --git a/tests/document_meta_test.rs b/tests/document_meta_test.rs new file mode 100644 index 0000000..cfa85b1 --- /dev/null +++ b/tests/document_meta_test.rs @@ -0,0 +1,90 @@ +//! `runtimes` and `default_task` in the document-wide `meta` block. + +use std::fs; +use std::path::Path; +use std::process::{Command, Output}; + +use tempfile::TempDir; + +const DOC: &str = r#"```meta +default_task = "hello" +runtimes = { shx = "sh" } +``` + +## hello + +```shx +echo from-shx +``` +"#; + +fn dir_with(doc: &str) -> TempDir { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("tasks.md"), doc).unwrap(); + dir +} + +fn cli(dir: &Path, args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_mq-task")) + .args(args) + .current_dir(dir) + .output() + .unwrap() +} + +fn out(o: &Output) -> String { + String::from_utf8_lossy(&o.stdout).into_owned() +} + +fn err(o: &Output) -> String { + String::from_utf8_lossy(&o.stderr).into_owned() +} + +#[test] +fn the_file_runs_alone_with_its_runtimes_and_default_task() { + let dir = dir_with(DOC); + let o = cli(dir.path(), &["-q"]); + assert!(o.status.success(), "{}", err(&o)); + assert_eq!(out(&o), "from-shx\n"); + assert!(cli(dir.path(), &["check"]).status.success()); +} + +#[test] +fn the_configuration_file_wins_over_the_document() { + let dir = dir_with(DOC); + // `shx` here is a command that does not exist; the document's `sh` loses. + fs::write( + dir.path().join("mq-task.toml"), + "default_task = \"other\"\n[runtimes]\nshx = \"definitely-not-a-command-xyz\"\nbash = \"bash\"\n", + ) + .unwrap(); + let o = cli(dir.path(), &["hello"]); + assert!(!o.status.success()); + // And its `default_task` beats the document's. + let o = cli(dir.path(), &[]); + assert!(err(&o).contains("other"), "{}{}", out(&o), err(&o)); +} + +#[test] +fn the_command_line_wins_over_the_document() { + let dir = dir_with(DOC); + let o = cli( + dir.path(), + &["hello", "-r", "shx:definitely-not-a-command-xyz"], + ); + assert!(!o.status.success()); +} + +#[test] +fn check_reports_an_unknown_default_task() { + let dir = + dir_with("```meta\ndefault_task = \"buidl\"\n```\n\n## build\n\n```bash\ntrue\n```\n"); + let o = cli(dir.path(), &["check"]); + assert_eq!(o.status.code(), Some(1)); + assert!( + out(&o).contains("`default_task` names unknown task 'buidl' (did you mean `build`?)"), + "{}{}", + out(&o), + err(&o) + ); +} diff --git a/tests/runner_test.rs b/tests/runner_test.rs index a806607..1a9e1f0 100644 --- a/tests/runner_test.rs +++ b/tests/runner_test.rs @@ -961,3 +961,76 @@ fn invalid_import_entries_are_explained(#[case] entry: &str, #[case] expected: & let err = imported_names(&main, &[]).unwrap_err().to_string(); assert!(err.contains(expected), "{err}"); } + +fn load_with( + config: Config, + pinned: &[&str], + main: &str, +) -> (Runner, Result, Error>) { + let dir = tempfile::tempdir().unwrap(); + let file = write(dir.path(), "main.md", main); + let mut r = Runner::new(config); + r.pin_runtimes(pinned.iter().map(|s| s.to_string())); + let sections = r.load_sections(&file); + (r, sections) +} + +#[test] +fn the_document_can_declare_runtimes_and_a_default_task() { + let main = "```meta\ndefault_task = \"hi\"\nruntimes = { python = \"python3.12\", jq = { command = \"jq -r\", execution_mode = \"arg\" } }\n```\n\n## hi\n\n```python\nprint(1)\n```\n"; + let (runner, sections) = load_with(Config::default(), &[], main); + sections.unwrap(); + let config = runner.config(); + assert_eq!(config.get_runtime("python"), Some("python3.12")); + assert_eq!(config.get_runtime("jq"), Some("jq -r")); + assert_eq!(config.get_runtime("bash"), Some("bash")); + assert_eq!(runner.document_default_task(), Some("hi")); +} + +#[test] +fn a_pinned_runtime_is_not_replaced_by_the_document() { + let main = "```meta\nruntimes = { python = \"python3.12\", node = \"bun\" }\n```\n\n## hi\n\n```python\nprint(1)\n```\n"; + let (runner, sections) = load_with(Config::default(), &["python"], main); + sections.unwrap(); + assert_eq!(runner.config().get_runtime("python"), Some("python3")); + assert_eq!(runner.config().get_runtime("node"), Some("bun")); +} + +#[test] +fn the_main_file_decides_over_an_imported_one() { + let dir = tempfile::tempdir().unwrap(); + write( + dir.path(), + "lib.md", + "```meta\nruntimes = { python = \"lib-python\", perl = \"lib-perl\" }\n```\n\n## x\n\n```bash\ntrue\n```\n", + ); + let file = write( + dir.path(), + "main.md", + "```meta\nimports = [\"lib.md\"]\nruntimes = { python = \"main-python\" }\n```\n\n## y\n\n```bash\ntrue\n```\n", + ); + let mut r = Runner::new(Config::default()); + r.load_sections(&file).unwrap(); + assert_eq!(r.config().get_runtime("python"), Some("main-python")); + assert_eq!(r.config().get_runtime("perl"), Some("lib-perl")); + // The default task is the main file's own. + assert_eq!(r.document_default_task(), None); +} + +#[test] +fn a_document_runtime_makes_a_data_block_runnable() { + let main = "```meta\nruntimes = { json = { command = \"jq .\", execution_mode = \"arg\" } }\n```\n\n## q\n\n```json\n{}\n```\n"; + let (_, sections) = load_with(Config::default(), &[], main); + assert_eq!(sections.unwrap()[0].codes.len(), 1); +} + +#[test] +fn an_invalid_document_runtime_is_explained() { + let main = "```meta\nruntimes = { python = 3 }\n```\n\n## hi\n\n```bash\ntrue\n```\n"; + let (_, sections) = load_with(Config::default(), &[], main); + let err = sections.unwrap_err().to_string(); + assert!( + err.contains("`runtimes`: 'python' must be a command"), + "{err}" + ); +}