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
10 changes: 10 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
78 changes: 42 additions & 36 deletions docs/netsuke-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -485,7 +485,7 @@ pub enum StringOrList {
String(String),
List(Vec<String>),
}
```rust
```

*Note: The* `StringOrList` *enum with* `#[serde(untagged)]` *provides the
flexibility for users to specify single sources, dependencies, and rule names
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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<PathBuf>, },
#[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<PathBuf>,
},

```
#[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
Expand Down Expand Up @@ -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;
Comment thread
leynos marked this conversation as resolved.

#[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,

Expand All @@ -1371,38 +1386,29 @@ struct Cli {
verbose: bool,

#[command(subcommand)]
command: Option<Commands>,
}
command: Option<Commands>, }

#[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<PathBuf>,

/// A list of specific targets to build.
targets: Vec<String>,
}
/// A list of specific targets to build. targets: Vec<String>, }
```

*Note: The* `Build` *command is wrapped in an* `Option<Commands>` *and will be
Expand Down
8 changes: 4 additions & 4 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**
Expand Down
61 changes: 55 additions & 6 deletions src/manifest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
///
Expand All @@ -17,19 +22,63 @@ 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);
/// ```
///
/// # 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<NetsukeManifest> {
serde_yml::from_str::<NetsukeManifest>(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::<HashMap<_, _>>()
})
.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::<NetsukeManifest>(&rendered).context("manifest parse error")
}

/// Render a Jinja template with contextual error reporting.
fn render(env: &Environment, tpl: &str, pass: &str) -> Result<String> {
env.render_str(tpl, context! {})
.with_context(|| format!("{pass} render error"))
}

/// Load a [`NetsukeManifest`] from the given file path.
Expand Down
4 changes: 4 additions & 0 deletions tests/data/jinja_undefined.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
netsuke_version: "1.0.0"
targets:
- name: hello
command: "echo {{ missing }}"
6 changes: 6 additions & 0 deletions tests/data/jinja_vars.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
netsuke_version: "1.0.0"
vars:
who: world
targets:
- name: hello
command: "echo {{ who }}"
10 changes: 10 additions & 0 deletions tests/features/manifest.feature
Original file line number Diff line number Diff line change
Expand Up @@ -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
48 changes: 48 additions & 0 deletions tests/manifest_jinja_tests.rs
Original file line number Diff line number Diff line change
@@ -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());
}
16 changes: 15 additions & 1 deletion tests/steps/manifest_steps.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
}
}
Loading