diff --git a/e2e/tasks/test_task_dep_args b/e2e/tasks/test_task_dep_args index 872632aa72..7e56a5173c 100644 --- a/e2e/tasks/test_task_dep_args +++ b/e2e/tasks/test_task_dep_args @@ -92,3 +92,28 @@ output=$(mise run step3 hello 2>&1) assert_contains "echo \"$output\"" "step1 hello" assert_contains "echo \"$output\"" "step2 hello" assert_contains "echo \"$output\"" "step3 hello" + +# Test Tera statement tags and boolean usage values in dependency templates +cat <<'EOF' >mise.toml +[tasks.noop] +run = 'echo "noop"' + +[tasks."postlint:check"] +run = 'echo "postlint ran"' + +[tasks.lint] +usage = 'flag "--run-post" default=#false' +depends_post = [''' + {%- if usage.run_post -%} + postlint:** + {%- else -%} + noop + {%- endif -%} +'''] +run = 'echo "lint ran"' +EOF + +assert_contains "mise run lint" "noop" +assert_not_contains "mise run lint" "postlint ran" +assert_contains "mise run lint --run-post" "postlint ran" +assert_not_contains "mise run lint --run-post" "noop" diff --git a/src/task/mod.rs b/src/task/mod.rs index 28784baa69..e331ebbeb7 100644 --- a/src/task/mod.rs +++ b/src/task/mod.rs @@ -12,6 +12,7 @@ use eyre::{Result, bail, eyre}; use fuzzy_matcher::FuzzyMatcher; use fuzzy_matcher::skim::SkimMatcherV2; use globset::GlobBuilder; +use heck::ToSnakeCase; use indexmap::IndexMap; use itertools::Itertools; use petgraph::prelude::*; @@ -1397,7 +1398,7 @@ impl Task { pub async fn render_depends_with_usage( &mut self, config: &Arc, - usage_values: &IndexMap, + usage_values: &IndexMap, ) -> Result<()> { if usage_values.is_empty() { return Ok(()); @@ -2020,25 +2021,49 @@ where } } -/// Check if a TaskDep contains {{usage.*}} references that need deferred rendering. -/// Strips whitespace before matching to handle Tera's `{{ usage.foo }}` syntax. +/// Check if a TaskDep contains Tera `usage` references that need deferred rendering. pub(crate) fn dep_has_usage_ref(dep: &TaskDep) -> bool { - let has_ref = |s: &str| { - let s = s.replace(' ', ""); - s.contains("{{usage.") - }; - has_ref(&dep.task) - || dep.args.iter().any(|a| has_ref(a)) - || dep.env.values().any(|v| has_ref(v)) + tera_template_has_usage_ref(&dep.task) + || dep.args.iter().any(|a| tera_template_has_usage_ref(a)) + || dep.env.values().any(|v| tera_template_has_usage_ref(v)) } -/// Parse a task's usage spec against its current args and return a map -/// of named arg/flag values (e.g., {"app": "myapp", "verbose": "true"}). +fn tera_template_has_usage_ref(s: &str) -> bool { + const TAGS: [(&str, &str); 2] = [("{{", "}}"), ("{%", "%}")]; + for (open, close) in TAGS { + let mut rest = s; + while let Some(start) = rest.find(open) { + rest = &rest[start + open.len()..]; + let Some(end) = rest.find(close) else { + break; + }; + if tera_tag_has_usage_ref(&rest[..end]) { + return true; + } + rest = &rest[end + close.len()..]; + } + } + false +} + +fn tera_tag_has_usage_ref(tag: &str) -> bool { + ["usage.", "usage["].iter().any(|needle| { + tag.match_indices(needle).any(|(idx, _)| { + tag[..idx] + .chars() + .next_back() + .is_none_or(|c| !c.is_ascii_alphanumeric() && c != '_' && c != '.') + }) + }) +} + +/// Parse a task's usage spec against its current args and return a map of named +/// arg/flag values preserving their Tera types (e.g., strings and booleans). /// Used to provide `{{usage.*}}` context when rendering dependency templates. pub async fn parse_usage_values_from_task( config: &Arc, task: &Task, -) -> Result> { +) -> Result> { let ts = config.get_toolset().await?; let env = ts.full_env(config).await?; let (spec, _) = task @@ -2059,11 +2084,25 @@ pub async fn parse_usage_values_from_task( } }; let mut values = IndexMap::new(); - for (k, v) in po.as_env() { - // Strip "usage_" prefix to get the bare arg/flag name - if let Some(name) = k.strip_prefix("usage_") { - values.insert(name.to_string(), v); + let to_tera_value = |val: &usage::parse::ParseValue| -> tera::Value { + use tera::Value; + use usage::parse::ParseValue::*; + match val { + MultiBool(v) => Value::Number(serde_json::Number::from(v.len())), + MultiString(v) => Value::Array(v.iter().map(|s| Value::String(s.clone())).collect()), + Bool(v) => Value::Bool(*v), + String(v) => Value::String(v.clone()), } + }; + for (arg, val) in &po.args { + values.insert(arg.name.to_snake_case(), to_tera_value(val)); + } + for (flag, val) in &po.flags { + values.insert(flag.name.to_snake_case(), to_tera_value(val)); + } + if !spec.cmd.subcommands.is_empty() { + let cmd = po.cmds.iter().skip(1).map(|c| c.name.clone()).join(" "); + values.insert("cmd".to_string(), tera::Value::String(cmd)); } Ok(values) } @@ -2079,7 +2118,7 @@ mod tests { use crate::{config::Config, dirs}; use pretty_assertions::assert_eq; - use super::{TaskConfirm, name_from_path}; + use super::{TaskConfirm, name_from_path, tera_tag_has_usage_ref, tera_template_has_usage_ref}; // Thread-local storage to capture parser state during tests thread_local! { @@ -2096,6 +2135,22 @@ mod tests { CAPTURED_PARSER_FIELDS.with(|captured| captured.lock().unwrap().take()) } + #[test] + fn test_tera_template_has_usage_ref() { + assert!(tera_template_has_usage_ref("{{ usage.app }}")); + assert!(tera_template_has_usage_ref( + "{%- if usage.run_post -%}post{%- endif -%}" + )); + assert!(tera_template_has_usage_ref("{{ usage['app'] }}")); + assert!(!tera_template_has_usage_ref( + "{{ env.DEPLOY_ENV }} usage.docs" + )); + assert!(!tera_template_has_usage_ref("{{ config.usage.something }}")); + assert!(!tera_template_has_usage_ref("{# usage.app #}")); + assert!(tera_tag_has_usage_ref("if(usage.run_post)")); + assert!(!tera_tag_has_usage_ref("ifusage.run_post")); + } + #[tokio::test] async fn test_from_path() { let test_cases = [(".mise/tasks/filetask", "filetask", vec!["ft"])];