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_totalnetsuke_manifest_filtered_actions_totalnetsuke_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 allwhichmatches. It does not change the boolean result fromcommand_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_strparses a manifest using the live process environment.netsuke::manifest::from_str_with_envtakes an explicitEnvReader, letting the caller control every valueenv()returns.netsuke::manifest::process_env_readerbuilds the process-backed reader thatfrom_struses 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.