Skip to content

Commit fed9793

Browse files
authored
Support Jinja control flow in manifests (#93)
* Add fixtures for control flow tests * Enforce string keys in vars and expand Jinja tests * Add yamllint ignore for Jinja fixtures
1 parent 760adff commit fed9793

File tree

12 files changed

+275
-78
lines changed

12 files changed

+275
-78
lines changed

.yamllint

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
extends: default
2+
ignore: |
3+
tests/data/jinja_for*.yml

docs/netsuke-design.md

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -161,9 +161,9 @@ level keys.
161161
future evolution of the schema while maintaining backward compatibility. This
162162
version string should be parsed and validated using the `semver` crate.[^4]
163163

164-
- `vars`: A mapping of global key-value string pairs. These variables are
165-
available for substitution in rule commands and target definitions and are
166-
exposed to the Jinja templating context.
164+
- `vars`: A mapping of global key-value pairs. Keys must be strings. Values may
165+
be strings, numbers, booleans, or sequences. These variables seed the Jinja
166+
templating context and drive control flow within the manifest.
167167

168168
- `macros`: An optional list of Jinja macro definitions. Each item provides a
169169
`signature` string using standard Jinja syntax and a `body` declared with the
@@ -414,7 +414,7 @@ pub struct NetsukeManifest {
414414
pub netsuke_version: Version,
415415
416416
#[serde(default)]
417-
pub vars: HashMap<String, String>,
417+
pub vars: HashMap<String, serde_yml::Value>,
418418
419419
#[serde(default)]
420420
pub rules: Vec<Rule>,
@@ -466,7 +466,7 @@ pub struct Target {
466466
pub order_only_deps: StringOrList,
467467
468468
#[serde(default)]
469-
pub vars: HashMap<String, String>,
469+
pub vars: HashMap<String, serde_yml::Value>,
470470
471471
/// Run this target when requested even if a file with the same name exists.
472472
#[serde(default)]
@@ -585,15 +585,14 @@ Unknown fields are rejected to surface user errors early. `StringOrList`
585585
provides a default `Empty` variant, so optional lists are trivial to represent.
586586
The manifest version is parsed using the `semver` crate to validate that it
587587
follows semantic versioning rules. Global and target variable maps now share
588-
the `HashMap<String, String>` type for consistency. This keeps YAML manifests
589-
concise while ensuring forward compatibility. Targets also accept optional
590-
`phony` and `always` booleans. They default to `false`, making it explicit when
591-
an action should run regardless of file timestamps. Targets listed in the
592-
`actions` section are deserialised using a custom helper so they are always
593-
treated as `phony` tasks. This ensures preparation actions never generate build
594-
artefacts. Convenience functions in `src/manifest.rs` load a manifest from a
595-
string or a file path, returning `anyhow::Result` for straightforward error
596-
handling.
588+
the `HashMap<String, serde_yml::Value>` type so booleans and sequences are
589+
preserved for Jinja control flow. Targets also accept optional `phony` and
590+
`always` booleans. They default to `false`, making it explicit when an action
591+
should run regardless of file timestamps. Targets listed in the `actions`
592+
section are deserialised using a custom helper so they are always treated as
593+
`phony` tasks. This ensures preparation actions never generate build artefacts.
594+
Convenience functions in `src/manifest.rs` load a manifest from a string or a
595+
file path, returning `anyhow::Result` for straightforward error handling.
597596

598597
### 3.5 Testing
599598

@@ -666,6 +665,13 @@ lenient undefined behaviour. The resulting YAML is parsed to obtain the global
666665
variables, which are then injected into the environment before a second, strict
667666
render pass produces the final manifest for deserialisation.
668667

668+
The parser copies `vars` values into the environment using
669+
`Value::from_serializable`. This preserves native YAML types so Jinja's
670+
`{% if %}` and `{% for %}` constructs can branch on booleans or iterate over
671+
sequences. Keys must be strings; any non-string key causes manifest parsing to
672+
fail. Attempting to iterate over a non-sequence results in a render error
673+
surfaced during manifest loading.
674+
669675
### 4.3 User-Defined Macros
670676

671677
Netsuke allows users to declare reusable Jinja macros directly in the manifest.

docs/roadmap.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ configurations with variables, control flow, and custom functions.
8888

8989
- [ ] **Dynamic Features and Custom Functions:**
9090

91-
- [ ] Implement support for basic Jinja control structures (`{% if %}` and
91+
- [x] Implement support for basic Jinja control structures (`{% if %}` and
9292
`{% for %}`)
9393

9494
- [ ] Implement the foreach key for target generation.

src/ast.rs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@
1919
2020
use semver::Version;
2121
use serde::{Deserialize, Serialize, de::Deserializer};
22+
use std::collections::HashMap;
23+
24+
/// Map type for `vars` blocks, preserving YAML values.
25+
pub type Vars = HashMap<String, serde_yml::Value>;
2226

2327
fn deserialize_actions<'de, D>(deserializer: D) -> Result<Vec<Target>, D::Error>
2428
where
@@ -30,7 +34,6 @@ where
3034
}
3135
Ok(actions)
3236
}
33-
use std::collections::HashMap;
3437

3538
/// Top-level manifest structure parsed from a `Netsukefile`.
3639
///
@@ -61,7 +64,7 @@ pub struct NetsukeManifest {
6164

6265
/// Global key/value pairs available to recipes.
6366
#[serde(default)]
64-
pub vars: HashMap<String, String>,
67+
pub vars: Vars,
6568

6669
/// Named rule templates that can be referenced by targets.
6770
#[serde(default)]
@@ -187,7 +190,7 @@ pub struct Target {
187190

188191
/// Target-scoped variables available during command execution.
189192
#[serde(default)]
190-
pub vars: HashMap<String, String>,
193+
pub vars: Vars,
191194

192195
/// Declares that the target does not correspond to a real file.
193196
#[serde(default)]

src/manifest.rs

Lines changed: 12 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@
55
//! They wrap `serde_yml` and add basic file handling.
66
77
use crate::ast::NetsukeManifest;
8-
use anyhow::{Context, Result};
8+
use anyhow::{Context, Result, bail};
99
use minijinja::{Environment, UndefinedBehavior, context, value::Value};
10-
use std::{collections::HashMap, fs, path::Path};
10+
use std::{fs, path::Path};
1111

1212
/// Parse a manifest string using Jinja for templating.
1313
///
@@ -48,22 +48,16 @@ pub fn from_str(yaml: &str) -> Result<NetsukeManifest> {
4848

4949
let doc: serde_yml::Value =
5050
serde_yml::from_str(&rendered).context("first-pass YAML parse error")?;
51-
let vars = doc
52-
.get("vars")
53-
.and_then(|v| v.as_mapping())
54-
.map(|m| {
55-
m.iter()
56-
.filter_map(|(k, v)| k.as_str().and_then(|key| v.as_str().map(|val| (key, val))))
57-
.map(|(k, v)| (k.to_string(), v.to_string()))
58-
.collect::<HashMap<_, _>>()
59-
})
60-
.unwrap_or_default();
61-
62-
// Populate the environment with the extracted variables for subsequent
63-
// rendering. Undefined variables now trigger errors to surface template
64-
// mistakes early.
65-
for (key, value) in vars {
66-
env.add_global(key, Value::from(value));
51+
if let Some(vars) = doc.get("vars").and_then(|v| v.as_mapping()) {
52+
// Copy each key-value pair into the environment, preserving native YAML
53+
// types so control structures like `{% if %}` and `{% for %}` can operate
54+
// on booleans and sequences.
55+
for (k, v) in vars {
56+
let Some(key) = k.as_str() else {
57+
bail!("non-string key in 'vars' mapping: {k:?}");
58+
};
59+
env.add_global(key, Value::from_serialize(v.clone()));
60+
}
6761
}
6862

6963
env.set_undefined_behavior(UndefinedBehavior::Strict);

tests/data/jinja_for.yml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
netsuke_version: 1.0.0
2+
vars:
3+
items:
4+
- foo
5+
- bar
6+
targets:
7+
{% for item in items %}
8+
- name: "{{ item }}"
9+
command: "echo {{ item }}"
10+
{% endfor %}
11+

tests/data/jinja_for_invalid.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
netsuke_version: 1.0.0
2+
vars:
3+
items: 1
4+
targets:
5+
{% for item in items %}
6+
- name: "{{ item }}"
7+
command: "echo {{ item }}"
8+
{% endfor %}
9+

tests/data/jinja_if.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
netsuke_version: 1.0.0
2+
vars:
3+
enable: true
4+
targets:
5+
- name: hello
6+
command: "{% if enable %}echo on{% else %}echo off{% endif %}"

tests/data/jinja_if_disabled.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
netsuke_version: 1.0.0
2+
vars:
3+
enable: false
4+
targets:
5+
- name: hello
6+
command: "{% if enable %}echo on{% else %}echo off{% endif %}"

tests/features/manifest.feature

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,29 @@ Feature: Manifest Parsing
5555
Given the manifest file "tests/data/jinja_undefined.yml" is parsed
5656
When the parsing result is checked
5757
Then parsing the manifest fails
58+
59+
Scenario: Rendering Jinja conditionals in a manifest
60+
Given the manifest file "tests/data/jinja_if.yml" is parsed
61+
When the manifest is checked
62+
Then the first target name is "hello"
63+
And the first target command is "echo on"
64+
65+
Scenario: Rendering Jinja conditionals in a manifest (disabled)
66+
Given the manifest file "tests/data/jinja_if_disabled.yml" is parsed
67+
When the manifest is checked
68+
Then the first target name is "hello"
69+
And the first target command is "echo off"
70+
71+
Scenario: Rendering Jinja loops in a manifest
72+
Given the manifest file "tests/data/jinja_for.yml" is parsed
73+
When the manifest is checked
74+
Then the manifest has 2 targets
75+
And the target 1 name is "foo"
76+
And the target 1 command is "echo foo"
77+
And the target 2 name is "bar"
78+
And the target 2 command is "echo bar"
79+
80+
Scenario: Parsing fails when a Jinja loop iterates over a non-list
81+
Given the manifest file "tests/data/jinja_for_invalid.yml" is parsed
82+
When the parsing result is checked
83+
Then parsing the manifest fails

0 commit comments

Comments
 (0)