Use Jinja safely

Updated Oct 16, 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.

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"|"workspace-recursive": choose the executable search domain. auto searches only PATH entries, including empty components in a non-empty PATH; it reports the command missing when PATH is empty or unset. always prepends the workspace root/current directory, never excludes it, and workspace-recursive explicitly scans the workspace tree after the normal PATH pass. Recursive search can resolve a checkout-controlled executable, so use it only when that trust boundary is intended.

The env(name) function reads one environment variable, and env(name, default='...') supplies the value to use when it is absent. A present variable always takes precedence over the default. An empty string is a present value, so it yields '' rather than the default. The default is consulted only for a missing variable: a non-Unicode value is still an error, and a variable refused by the access policy is still refused. The default must be a string; a number, boolean, list, or map is rejected rather than stringified. Omitting the default, passing default=none, or passing an undefined default supplies no fallback at all: each behaves exactly as if the argument had not been written.

which resolver observability

which and command_available record two bounded counters, both emitted in the drained metrics snapshot described in Diagnose configuration selection:

  • netsuke_stdlib_which_cache_total — a counter that records cache outcomes with the cwd_mode and outcome labels. outcome is hit, miss, or bypass, where bypass is a fresh=true lookup.
  • netsuke_stdlib_which_resolution_total — a counter with two label shapes that counts resolution outcomes. A success series carries cwd_mode and outcome=found. A failure series carries cwd_mode, an outcome of not_found or error, and a bounded category. The category label occurs only on failure series, so a query matching on it selects exactly the failures.

The cwd_mode label is drawn from a closed vocabulary of auto, always, never, and workspace_recursive. It records how the current directory was asked to contribute to the search: Netsuke reads it from the options before the cache probe and before the lookup, so it does not indicate whether recursive workspace lookup ran or which search produced the result.

The label's spelling is not the template's. A manifest writes cwd_mode="workspace-recursive", with the hyphen. The underscore spelling workspace_recursive is the label's value and is not a value a template may supply.

The category label is the resolver's bounded failure taxonomy: not_found, direct_not_found, args, canonicalize, is_executable, canonicalize_non_utf8, workspace_non_utf8, walkdir, cwd_resolve, or cwd_non_utf8. Every label on both counters is drawn from a closed set fixed by the resolver rather than by manifest content. The cwd_mode label does record which search policy a manifest requested — that is the fact the vocabulary exists to carry — but it names it as one of the fixed spellings rather than quoting the template. Nothing else is recorded: command names, filesystem paths, workspace names, and PATH or PATHEXT values never reach a label.