diff --git a/Cargo.lock b/Cargo.lock index bfdbdacf..80389185 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -769,6 +769,15 @@ version = "2.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" +[[package]] +name = "minijinja" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e60ac08614cc09062820e51d5d94c2fce16b94ea4e5003bb81b99a95f84e876" +dependencies = [ + "serde", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -806,6 +815,7 @@ dependencies = [ "insta", "itertools", "itoa", + "minijinja", "rstest", "semver", "serde", diff --git a/Cargo.toml b/Cargo.toml index 61aafb78..d85a516f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ edition = "2024" clap = { version = "4.5.0", features = ["derive"] } serde = { version = "1", features = ["derive"] } serde_yml = "0.0.12" +minijinja = "2.11.0" semver = { version = "1", features = ["serde"] } anyhow = "1" thiserror = "1" diff --git a/docs/netsuke-design.md b/docs/netsuke-design.md index 3b36d4fe..fe78e3d4 100644 --- a/docs/netsuke-design.md +++ b/docs/netsuke-design.md @@ -485,7 +485,7 @@ pub enum StringOrList { String(String), List(Vec), } -```rust +``` *Note: The* `StringOrList` *enum with* `#[serde(untagged)]` *provides the flexibility for users to specify single sources, dependencies, and rule names @@ -660,6 +660,12 @@ targets: #... ``` +To extract the `vars` mapping without triggering errors for undefined +placeholders elsewhere in the template, Netsuke first renders the manifest with +lenient undefined behaviour. The resulting YAML is parsed to obtain the global +variables, which are then injected into the environment before a second, strict +render pass produces the final manifest for deserialisation. + ### 4.3 User-Defined Macros Netsuke allows users to declare reusable Jinja macros directly in the manifest. @@ -1252,22 +1258,33 @@ libraries.[^27] error types. The `#[derive(Error)]` macro reduces boilerplate and allows for the creation of rich, semantic errors.[^29] - Rust - - ```rust // In src/ir.rs use thiserror::Error; use std::path::PathBuf; +Rust - # - pub enum IrGenError { - # - RuleNotFound { target_name: String, rule_name: String, }, +```rust +// In src/ir.rs +use thiserror::Error; +use std::path::PathBuf; - #[error("circular dependency detected: {cycle:?}")] - CircularDependency { cycle: Vec, }, +#[derive(Debug, Error)] +pub enum IrGenError { + #[error("rule not found: {rule_name} for target {target_name}")] + RuleNotFound { + target_name: String, + rule_name: String, + }, - # - DependencyNotFound { target_name: String, dependency_name: String, }, } + #[error("circular dependency detected: {cycle:?}")] + CircularDependency { + cycle: Vec, + }, - ``` + #[error("dependency not found: {dependency_name} for target {target_name}")] + DependencyNotFound { + target_name: String, + dependency_name: String, + }, +} +``` - `anyhow`: This crate will be used in the main application logic (`main.rs`) and at the boundaries between modules. `anyhow::Result` serves as a @@ -1348,13 +1365,11 @@ entire CLI specification. Rust ```rust -use clap::{Args, Parser, Subcommand}; -use std::path::PathBuf; +use clap::{Args, Parser, Subcommand}; use std::path::PathBuf; #[derive(Parser)] #[command(author, version, about, long_about = None)] -struct Cli { - /// Path to the Netsuke manifest file to use. +struct Cli { /// Path to the Netsuke manifest file to use. #[arg(short, long, value_name = "FILE", default_value = "Netsukefile")] file: PathBuf, @@ -1371,38 +1386,29 @@ struct Cli { verbose: bool, #[command(subcommand)] - command: Option, -} + command: Option, } #[derive(Subcommand)] -enum Commands { - /// Build specified targets (or default targets if none are given). - /// This is the default subcommand. - Build(BuildArgs), +enum Commands { /// Build specified targets (or default targets if none are +given). /// This is the default subcommand. Build(BuildArgs), - /// Remove build artefacts and intermediate files. - Clean, + /// Remove build artefacts and intermediate files. Clean, /// Display the build dependency graph in DOT format for visualisation. Graph, - /// Write the Ninja manifest to `FILE` without invoking Ninja. - Manifest { - /// Output path for the generated Ninja file. + /// Write the Ninja manifest to `FILE` without invoking Ninja. Manifest { + /// Output path for the generated Ninja file. #[arg(value_name = "FILE")] - file: PathBuf, - }, -} + file: PathBuf, }, } #[derive(Args)] -struct BuildArgs { - /// Write the generated Ninja manifest to this path and retain it. +struct BuildArgs { /// Write the generated Ninja manifest to this path and +retain it. #[arg(long, value_name = "FILE")] emit: Option, - /// A list of specific targets to build. - targets: Vec, -} + /// A list of specific targets to build. targets: Vec, } ``` *Note: The* `Build` *command is wrapped in an* `Option` *and will be diff --git a/docs/roadmap.md b/docs/roadmap.md index 0260dda1..bec3cd67 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -75,15 +75,15 @@ compilation pipeline from parsing to execution. Objective: To integrate the minijinja templating engine, enabling dynamic build configurations with variables, control flow, and custom functions. -- [ ] **Jinja Integration:** +- [x] **Jinja Integration:** - - [ ] Integrate the `minijinja` crate into the build pipeline. + - [x] Integrate the `minijinja` crate into the build pipeline. - - [ ] Implement the two-pass parsing mechanism: the first pass renders the + - [x] Implement the two-pass parsing mechanism: the first pass renders the manifest as a Jinja template, and the second pass parses the resulting pure YAML string with serde_yml. - - [ ] Create a minijinja::Environment and populate its initial context with + - [x] Create a minijinja::Environment and populate its initial context with the global vars defined in the manifest. - [ ] **Dynamic Features and Custom Functions:** diff --git a/src/manifest.rs b/src/manifest.rs index a22972d1..23edb6ca 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -6,9 +6,14 @@ use crate::ast::NetsukeManifest; use anyhow::{Context, Result}; -use std::{fs, path::Path}; +use minijinja::{Environment, UndefinedBehavior, context, value::Value}; +use std::{collections::HashMap, fs, path::Path}; -/// Parse a YAML string into a [`NetsukeManifest`]. +/// Parse a manifest string using Jinja for templating. +/// +/// The function renders the input YAML as a Jinja template, using any +/// top-level `vars` as the initial context, before parsing the expanded YAML +/// into a [`NetsukeManifest`]. /// /// # Examples /// @@ -17,9 +22,11 @@ use std::{fs, path::Path}; /// /// let yaml = r#" /// netsuke_version: 1.0.0 +/// vars: +/// who: world /// targets: -/// - name: a -/// command: echo hi +/// - name: hello +/// command: echo {{ who }} /// "#; /// let manifest = from_str(yaml).expect("parse"); /// assert_eq!(manifest.targets.len(), 1); @@ -27,9 +34,51 @@ use std::{fs, path::Path}; /// /// # Errors /// -/// Returns an error if the YAML is malformed or fails validation. +/// Returns an error if Jinja rendering or YAML parsing fails. pub fn from_str(yaml: &str) -> Result { - serde_yml::from_str::(yaml).context("YAML parse error") + // Bootstrap the template engine with lenient undefineds so we can extract + // the global `vars` block without errors from unresolved placeholders. + let mut env = Environment::new(); + env.set_undefined_behavior(UndefinedBehavior::Lenient); + + // First pass: render the raw template to plain YAML, ignoring unresolved + // expressions. This gives us access to the top-level `vars` mapping which + // seeds the real render pass. + let rendered = render(&env, yaml, "first-pass")?; + + let doc: serde_yml::Value = + serde_yml::from_str(&rendered).context("first-pass YAML parse error")?; + let vars = doc + .get("vars") + .and_then(|v| v.as_mapping()) + .map(|m| { + m.iter() + .filter_map(|(k, v)| k.as_str().and_then(|key| v.as_str().map(|val| (key, val)))) + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect::>() + }) + .unwrap_or_default(); + + // Populate the environment with the extracted variables for subsequent + // rendering. Undefined variables now trigger errors to surface template + // mistakes early. + for (key, value) in vars { + env.add_global(key, Value::from(value)); + } + + env.set_undefined_behavior(UndefinedBehavior::Strict); + + // Second pass: render the template again with the enriched context to + // obtain a pure YAML manifest ready for deserialisation. + let rendered = render(&env, yaml, "second-pass")?; + + serde_yml::from_str::(&rendered).context("manifest parse error") +} + +/// Render a Jinja template with contextual error reporting. +fn render(env: &Environment, tpl: &str, pass: &str) -> Result { + env.render_str(tpl, context! {}) + .with_context(|| format!("{pass} render error")) } /// Load a [`NetsukeManifest`] from the given file path. diff --git a/tests/data/jinja_undefined.yml b/tests/data/jinja_undefined.yml new file mode 100644 index 00000000..8131fcd1 --- /dev/null +++ b/tests/data/jinja_undefined.yml @@ -0,0 +1,4 @@ +netsuke_version: "1.0.0" +targets: + - name: hello + command: "echo {{ missing }}" diff --git a/tests/data/jinja_vars.yml b/tests/data/jinja_vars.yml new file mode 100644 index 00000000..63d8df59 --- /dev/null +++ b/tests/data/jinja_vars.yml @@ -0,0 +1,6 @@ +netsuke_version: "1.0.0" +vars: + who: world +targets: + - name: hello + command: "echo {{ who }}" diff --git a/tests/features/manifest.feature b/tests/features/manifest.feature index dcab19c3..042212a1 100644 --- a/tests/features/manifest.feature +++ b/tests/features/manifest.feature @@ -45,3 +45,13 @@ Feature: Manifest Parsing Given the manifest file "tests/data/action_invalid.yml" is parsed When the parsing result is checked Then parsing the manifest fails + + Scenario: Rendering Jinja variables in a manifest + Given the manifest file "tests/data/jinja_vars.yml" is parsed + When the manifest is checked + Then the first target command is "echo world" + + Scenario: Parsing fails when a Jinja variable is undefined + Given the manifest file "tests/data/jinja_undefined.yml" is parsed + When the parsing result is checked + Then parsing the manifest fails diff --git a/tests/manifest_jinja_tests.rs b/tests/manifest_jinja_tests.rs new file mode 100644 index 00000000..245b05f2 --- /dev/null +++ b/tests/manifest_jinja_tests.rs @@ -0,0 +1,48 @@ +//! Tests for Jinja-templated manifest parsing. + +use netsuke::{ast::Recipe, manifest}; +use rstest::rstest; + +#[rstest] +fn renders_global_vars() { + let yaml = r" +netsuke_version: 1.0.0 +vars: + who: world +targets: + - name: hello + command: echo {{ who }} +"; + + let manifest = manifest::from_str(yaml).expect("parse"); + let first = manifest.targets.first().expect("target"); + if let Recipe::Command { command } = &first.recipe { + assert_eq!(command, "echo world"); + } else { + panic!("Expected command recipe, got: {:?}", first.recipe); + } +} + +#[rstest] +fn undefined_variable_errors() { + let yaml = r" +netsuke_version: 1.0.0 +targets: + - name: hello + command: echo {{ missing }} +"; + + assert!(manifest::from_str(yaml).is_err()); +} + +#[rstest] +fn syntax_error_errors() { + let yaml = r" +netsuke_version: 1.0.0 +targets: + - name: hello + command: echo {{ who +"; + + assert!(manifest::from_str(yaml).is_err()); +} diff --git a/tests/steps/manifest_steps.rs b/tests/steps/manifest_steps.rs index 1494ebd2..af99bcb0 100644 --- a/tests/steps/manifest_steps.rs +++ b/tests/steps/manifest_steps.rs @@ -6,7 +6,10 @@ use crate::CliWorld; use cucumber::{given, then, when}; -use netsuke::{ast::StringOrList, manifest}; +use netsuke::{ + ast::{Recipe, StringOrList}, + manifest, +}; fn parse_manifest_inner(world: &mut CliWorld, path: &str) { match manifest::from_path(path) { @@ -102,3 +105,14 @@ fn first_rule_name(world: &mut CliWorld, name: String) { let rule = manifest.rules.first().expect("rules"); assert_eq!(rule.name, name); } + +#[then(expr = "the first target command is {string}")] +fn first_target_command(world: &mut CliWorld, command: String) { + let manifest = world.manifest.as_ref().expect("manifest"); + let first = manifest.targets.first().expect("targets"); + if let Recipe::Command { command: actual } = &first.recipe { + assert_eq!(actual, &command); + } else { + panic!("Expected command recipe, got: {:?}", first.recipe); + } +}