Use Jinja safely

Updated Sep 02, 2026

Jinja expressions are allowed in renderable string fields, including variables, target fields, and rule recipes. Structural Jinja blocks cannot reshape the YAML document. Use the dedicated foreach and when keys for manifest-time expansion.

Generate targets with `foreach` and `when`

The next complete manifest creates two targets and excludes the disabled item:

netsuke_version: "1.0.0"

vars:
  reports:
    - daily
    - weekly
    - disabled

targets:
  - foreach: reports
    when: item != 'disabled'
    name: "{{ item }}.txt"
    command: "echo {{ index }} > {{ outs }}"

defaults:
  - daily.txt
  - weekly.txt

Each expansion receives item and a zero-based index. Target-local variables take precedence over global variables, and iteration values take precedence over both.

when is evaluated while Netsuke loads the manifest. It does not create a runtime branch. Runtime decisions belong in a command or script.

Top-level actions support the same foreach and when keys.

Diagnose manifest expansion

Pass --verbose to a normal manifest-loading command to inspect manifest-time filtering. Netsuke emits a debug event with the exact number of filtered targets and actions, including entries whose metadata is not retained. It also emits at most 64 per-entry events for each expansion. The aggregate event's omitted_filtered_entries field reports how many additional filtered entries were excluded from those bounded records.

Normal manifest loading also records these aggregate counters:

  • netsuke_manifest_filtered_targets_total
  • netsuke_manifest_filtered_actions_total
  • netsuke_manifest_omitted_filtered_entries_total

The counters have no labels and never contain raw manifest values.

Each retained event contains only the entry's section, an eight-character entry_name_hash, an optional zero-based iteration_index, and the byte length of its when_expression in when_expression_len. Raw entry names, foreach item values, and when expressions are never emitted. Manifest query loading, including netsuke help targets, remains side-effect-free and emits neither these expansion events nor expansion metrics.

Discover files with `glob`

glob(pattern) expands a shell-style pattern to the sorted list of matching files while Netsuke loads the manifest, so the results become part of the static build graph. It pairs naturally with foreach to generate one target per matched file; a target list such as foreach: glob('src/*.c') produces one expansion per matching source.

Matching is case-sensitive. * and ? do not cross directory separators; use ** to descend into subdirectories. Directories are excluded, so only files are returned. Relative patterns resolve against the directory containing the manifest — the workspace root — independent of the directory Netsuke is invoked from, so glob('src/*.c') in a Netsukefile at the project root matches <project>/src/*.c. The quick-start guide shows a complete runnable example.

Patterns may be absolute or relative to the manifest directory, including parent-relative patterns such as glob('../shared/*.h'). Relative results retain their pattern-relative spelling after Netsuke removes the workspace base; absolute patterns remain absolute. Expansion is scoped to the pattern's longest literal directory prefix — the text up to the first *, ?, [ or {, trimmed back to the last separator, so src/ for src/**/*.c. If that prefix does not exist, or names something that is not a directory, the call returns an empty list rather than failing. A symbolic-link literal prefix, such as src/link/*.c, cannot establish the capability and causes expansion to fail. A match is skipped rather than reported as an error when the metadata lookup cannot resolve a symbolic link — the match itself or a directory reached on the way to it — because it is unreadable within the prefix, dangling, or resolves outside that prefix. A cyclic symbolic link is reported as an error rather than skipped, since it describes a broken tree rather than a missing file.

Patterns with unmatched braces are rejected during validation. When an opening brace remains unclosed, the diagnostic points to the outermost unmatched opening brace; an unmatched closing brace is reported at that closing brace.

The Jinja helper rejects a matched path unless it can be inserted as one portable unquoted shell word. ASCII letters, digits, /, :, comma, full stop, underscore, and hyphen are accepted; whitespace, control characters, and shell punctuation are rejected. This prevents an untrusted checkout filename from becoming shell syntax when item is interpolated into a command or script. The Rust manifest::glob_paths query performs no shell-safety validation; each caller must validate or escape matched paths before passing them to a command sink.

Rust callers use manifest::glob_paths(pattern, base) with an optional base. Some(&Utf8Path) anchors relative patterns and strips that base from results; absolute patterns ignore the base, while None resolves relative patterns against the process working directory.

Define reusable macros

Macros return rendered text and can accept default arguments:

netsuke_version: "1.0.0"

vars:
  greeting: Hello

macros:
  - signature: "say(name, punctuation='!')"
    body: "{{ greeting }}, {{ name }}{{ punctuation }}"

targets:
  - name: greeting.txt
    command: "echo '{{ say('Netsuke') }}' > {{ outs }}"

defaults:
  - greeting.txt

Select optional tools

which(name, **kwargs) returns an executable path and fails when the command is absent. The same helper is also available as a filter.

On Windows, a name without an extension is matched against the effective PATHEXT, the same list the shell uses — so which('cargo') finds cargo.exe provided .exe is among those entries. A custom PATHEXT may legitimately omit it, in which case it is not a candidate.

PATHEXT falls back to the built-in list only when it is unset or when no entry survives normalization — that is, every entry is empty or whitespace. Any other value is used as given, however unusual. The built-in list, in order:

.com, .exe, .bat, .cmd, .vbs, .vbe, .js, .jse, .wsf, .wsh, .msc

The fallback exists because an empty effective list would match nothing and report every command missing. Entries are matched case-insensitively and tried in the order the list gives them. A name that already carries an extension is used as written.

command_available(name, **kwargs) returns a boolean and is better for complementary branches:

netsuke_version: "1.0.0"

actions:
  - name: test-fast
    command: "cargo nextest run"
    deps:
      - config/test-profile.toml
    when: command_available("cargo-nextest")

  - name: test-fast
    command: "cargo test"
    deps:
      - config/test-profile.toml
    when: not command_available("cargo-nextest")

targets: []

defaults:
  - test-fast

Netsuke evaluates both guards while loading the manifest, without running either recipe, so exactly one test-fast action enters the build graph. The selected action's deps become Ninja implicit dependencies: changes to config/test-profile.toml make the action stale, but the path is not appended to cargo nextest run or cargo test as a recipe argument.

Both helpers accept:

  • all=true: return all which matches. It does not change the boolean result from command_available.
  • canonical=true: canonicalize matching paths.
  • fresh=true: bypass the resolver cache for this lookup.
  • cwd_mode="auto"|"always"|"never": control bounded project-directory fallback searching.

The env(name) function reads one required environment variable. Beta3 does not accept a default argument; an absent or non-Unicode value is an error.

Inject the environment reader for tests

env() does not read std::env::var directly. Manifest parsing goes through an injectable EnvReader seam, so callers that need deterministic env() results — test suites, and any program driving Netsuke's unstable Rust API — can supply their own reader instead of mutating the process environment.

  • netsuke::manifest::from_str parses a manifest using the live process environment.
  • netsuke::manifest::from_str_with_env takes an explicit EnvReader, letting the caller control every value env() returns.
  • netsuke::manifest::process_env_reader builds the process-backed reader that from_str uses by default.

A missing variable still fails the parse with a Jinja "undefined" error, and a non-Unicode value still fails with an "invalid operation" error; only the source of the values changes.

use netsuke::manifest::{EnvReader, from_str_with_env};
use std::sync::Arc;

let reader: EnvReader = Arc::new(|_| Ok(String::from("release")));
let yaml = concat!(
    "netsuke_version: \"1.0.0\"\n",
    "targets:\n",
    "  - name: \"{{ env('PROFILE') }}\"\n",
    "    command: echo hi\n",
);
let manifest = from_str_with_env(yaml, &reader).expect("parse");
assert!(format!("{:?}", manifest.targets[0].name).contains("release"));

This snippet mirrors the executable doctest on from_str_with_env in the API documentation, rather than the YAML-only examples elsewhere in this guide.

Drive Ninja with an explicit environment

Netsuke is a build tool, not a library: the Netsukefile format and the graph export are the only surfaces it commits to, and every Rust API named in this section is private in intent and unstable, liable to change or disappear in any beta release. It is documented here for the benefit of anyone who calls it anyway, with that caveat understood.

A program calling Netsuke's Rust API can invoke Ninja without touching its own process environment. netsuke::runner::CommandEnv carries child environment overrides as data — inherit() changes nothing, with_var and with_path set variables for the spawned command only — and the explicit request forms run_ninja_with and run_ninja_tool_with accept a request naming the program, build file, targets or tool, that environment, and a stderr_mode: StderrMode policy routing the child's standard streams: Suppress drains both streams (keeping JSON diagnostics machine-readable), while Forward relays them to the caller. The convenience wrappers run_ninja and run_ninja_tool behave identically with an inherited environment, deriving the policy from the CLI's JSON setting. Overrides are additive: variables not named are inherited from the calling process, and the injected PATH governs what commands Ninja launches will see. Relative program names remain valid and resolve through that child PATH; supply an absolute or otherwise resolved program only when executable selection must stay isolated from the injected PATH.

The request itself is a named type: netsuke::runner::NinjaBuildRequest for a build and netsuke::runner::NinjaToolRequest for ninja -t <tool>. Both borrow their fields, so one CommandEnv and one NinjaProcessOptions can serve several invocations. The v0.1.0 migration guide summarizes these additions and explains the path-type change. The program and build_file fields are borrowed &Utf8Path; NinjaProcessOptions::working_dir is an Option<Utf8PathBuf>.

The options: &options field and associated NinjaProcessOptions shape shown here are beta3 additions. Published beta2 request types use cli: &cli instead, so beta2 callers must not assume this API shape is available in that release.

use netsuke::runner::{
    BuildTargets, CommandEnv, NinjaBuildRequest, NinjaProcessOptions, NinjaToolRequest,
    StderrMode, run_ninja_tool_with, run_ninja_with,
};
use camino::Utf8Path;

let options = NinjaProcessOptions::default();
let targets = BuildTargets::default();
// `with_path` replaces the child's `PATH` outright, so compose the whole
// value first. The calling process is never modified.
let path = std::env::join_paths(["/opt/toolchain/bin", "/usr/bin"])
    .expect("separator-free entries always join");
let env = CommandEnv::inherit()
    .with_var("NINJA_STATUS", "[%f/%t] ")
    .with_path(&path);

let build = NinjaBuildRequest {
    program: Utf8Path::new("/usr/bin/ninja"),
    options: &options,
    build_file: Utf8Path::new("build.ninja"),
    targets: &targets,
    env: &env,
    // `Suppress` in JSON diagnostics mode keeps the child's output out of
    // the machine-readable streams; `Forward` relays it to the caller.
    stderr_mode: StderrMode::Forward,
};
let clean = NinjaToolRequest {
    program: Utf8Path::new("/usr/bin/ninja"),
    options: &options,
    build_file: Utf8Path::new("build.ninja"),
    tool: "clean",
    env: &env,
    stderr_mode: StderrMode::Forward,
};

if std::env::var_os("NETSUKE_GUIDE_RUN").is_some() {
    run_ninja_with(&build).expect("run ninja");
    run_ninja_tool_with(&clean).expect("run ninja -t clean");
}

The convenience wrappers run_ninja and run_ninja_tool keep their child environment behaviour, but their program and build_file parameters now use &Utf8Path; run_with_ninja_program accepts the same path type. The request bundles use options: &options instead of cli: &cli and gained the required stderr_mode field, so a caller that constructs NinjaBuildRequest/ NinjaToolRequest directly must supply both. Each release records such additions in CHANGELOG.md, which is where Netsuke signposts Rust API changes — with no stability promise attached to them ahead of 1.0.

Capture verbose timing output

Rust callers that wrap a StatusReporter can send verbose timing summaries to an owned sink with VerboseTimingReporter::with_writer:

The writer/completion behaviour described here is a beta3 addition. Published beta2 callers must not assume this timing-writer behaviour; VerboseTimingReporter::new remains the stderr-writing default.

use netsuke::output_prefs::resolve;
use netsuke::status::{SilentReporter, VerboseTimingReporter};

let reporter = VerboseTimingReporter::with_writer(
    Box::new(SilentReporter),
    resolve(None),
    Vec::<u8>::new(),
);

The generic writer must implement Write + Send and is owned by the timing reporter. VerboseTimingReporter::new remains the default API and writes to io::Stderr. On the first completion, the wrapped reporter receives its completion event before the timing summary is written synchronously to the sink. A blocking sink therefore blocks only that completion call; later stage, progress, and completion events remain suppressed. Re-entrant calls observe the completed state, and summary lines retain their rendered order. Write errors are ignored, matching the existing accessible reporter contract; applications can observe them through the bounded timing sink telemetry emitted by their configured metrics and tracing backends.