A subset of Jinja for Zig: the template language Home Assistant speaks, evaluated at run time
  • Zig 98.2%
  • Nix 1%
  • Python 0.8%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Jeffrey C. Ollie 9446e90820
All checks were successful
test / test (push) Successful in 8m20s
test / docs (push) Successful in 6m17s
Build with Zig 0.17
Zig 0.17.0 from nixpkgs replaces the devshell's patched 0.16 -- its test
runner is fixed, so the fuzz targets compile under --fuzz as they are, and
the fuzz test binary is built with LLVM so that the fuzzer gets coverage.
Array repetition with ** is gone from the language; the two tests that
used it build their strings with @splat. Version 0.2.0; 0.16 lives on the
zig-0.16 branch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AwwDvnsTr5ah1n3cntWbmK
2026-10-06 12:42:23 -05:00
.forgejo/workflows A subset of Jinja for Zig 2026-09-26 11:13:20 -05:00
LICENSES A subset of Jinja for Zig 2026-09-26 11:13:20 -05:00
src Build with Zig 0.17 2026-10-06 12:42:23 -05:00
tests A subset of Jinja for Zig 2026-09-26 11:13:20 -05:00
tools Build with Zig 0.17 2026-10-06 12:42:23 -05:00
.gitignore A subset of Jinja for Zig 2026-09-26 11:13:20 -05:00
build.zig Build with Zig 0.17 2026-10-06 12:42:23 -05:00
build.zig.zon Build with Zig 0.17 2026-10-06 12:42:23 -05:00
flake.lock Build with Zig 0.17 2026-10-06 12:42:23 -05:00
flake.nix Build with Zig 0.17 2026-10-06 12:42:23 -05:00
package.nix Build with Zig 0.17 2026-10-06 12:42:23 -05:00
README.md Build with Zig 0.17 2026-10-06 12:42:23 -05:00
REUSE.toml A subset of Jinja for Zig 2026-09-26 11:13:20 -05:00

zig-jinja

A subset of Jinja for Zig 0.17: the template language Home Assistant speaks, parsed once and rendered at run time over data a host supplies.

const jinja = @import("jinja");

var d: jinja.Diagnostic = .{};
var t = try jinja.Template.parse(gpa, "{{ (temp|float * 9 / 5 + 32)|round(1) }}°F", .{}, &d);
defer t.deinit();

const out = try t.renderAlloc(arena, &env, &d); // "70.7°F"

The API documentation is generated from the doc comments and published at https://jeff.jcollie.page/zig-jinja/.

It exists for templates that somebody other than the program's author writes — a key on a Stream Deck, a label in a configuration file — and three properties follow from that:

  • A syntax error is an error. It comes back with a line and column and nothing is rendered. There is no recovery that skips a broken statement and renders the rest, which is the one outcome worse than refusing.
  • Every render is bounded — bytes written, loop iterations, recursion depth, list lengths — by Limits, whatever the template says. A template cannot make a render take more than its host allowed.
  • Names can be checked when parsing. A host that lists the variables, functions and filters it provides gets {{ stats('light.x') }} refused at load time, with a position, instead of a template that quietly renders nothing.

There is no I/O anywhere in it: no include, import or extends, no loaders, no clock. A template can read what its host hands it and write its output, and nothing else.

What it speaks

Tags {{ expr }}, {% statement %}, {# comment #}, whitespace control with - on either side, {% raw %}
Statements if / elif / else; for with unpacking, a filter (for x in xs if cond), else, and loop.index, index0, revindex, revindex0, first, last, length, previtem, nextitem; set, including set a, b = …, set ns.attr = … and {% set x %}…{% endset %}
Literals integers, floats, strings with Python's escapes, true/false/none in either case, lists, tuples, dicts
Operators + - * / // % **, ~, == != < <= > >= chained as in Python, in and not in, and/or/not returning operands as Python's do, a if c else b
Access x.attr, x['key'], x[0], x[-1], x.0, slices x[a:b:c]
Filters abs capitalize count default/d escape/e first float format int join last length list lower map max min reject rejectattr replace reverse round safe select selectattr sort string sum title tojson trim truncate unique upper
Tests boolean defined divisibleby eq/equalto even false float ge gt in integer iterable le lower lt mapping ne none number odd sameas sequence string true undefined upper
Methods on strings upper lower title capitalize strip lstrip rstrip startswith endswith split replace join find count isdigit; on dicts items keys values get
Functions range, dict, namespace

Values follow Python rather than Zig, because that is what a Jinja template means: {{ 1 / 2 }} is 0.5, {{ none }} is None, {{ 3.0 }} is 3.0, {{ 2.675|round(2) }} is 2.67 — rounded from the exact binary value, a tie to even, as Python does — and '%5.1f'|format(x) and '%d%%' % pct format the way printf does. An undefined name renders as nothing and is false, and doing anything else with it is an error, which is Jinja's default.

What it is narrower about, deliberately: integers are 64 bits, and overflow is an error rather than a big integer; case conversion is ASCII; a tuple is a list, and prints as one; format is printf's conversions and not str.format; macros, call, with, loop.cycle and recursive loops are not there.

Using it from a host

A host provides an Env:

const env: jinja.Env = .{
    .context = &my_state,            // handed to every callback
    .resolve = resolveName,          // looks up a free variable
    .functions = &.{.{ .name = "states", .call = states }},
    .filters = &.{.{ .name = "duration", .call = duration }},
    .autoescape = true,              // for XML or HTML output
    .limits = .{ .max_output = 16 << 10 },
};

A callback is handed a *Call, which carries the render's arena, the host's context, and the place in the template it was called from, so that call.fail("no entity '{s}'", .{id}) reports the error where the template wrote it.

Data can be handed over eagerly — Value.list, Value.map, or a parsed JSON document through json.fromJson, which borrows its strings — or lazily, as an Object whose callbacks answer attribute lookups and method calls when the template asks. The second is what keeps a render from copying everything a template might read.

Template.dependencies walks a parsed template and says what it reads: the free names, the attribute paths read off them (player.position), and every function call with its string-literal arguments, so states('light.kitchen') names the entity outright. A host that redraws on change uses that to know what to watch without running anything.

isTemplate(text) says whether a string contains template syntax at all, so a setting can be plain text or a template and plain text stays exactly as it was.

Checking it against Jinja

tests/conformance.json is a list of templates, each with the variables it is rendered with and what Jinja renders it as — or that Jinja refuses it. The expectations are not written by hand:

$ nix develop -c python3 tools/jinja_oracle.py           # rewrite them from Jinja2
$ nix develop -c python3 tools/jinja_oracle.py --check   # only say which differ

renders every case with Jinja2 and writes down what it got, and zig build test holds this library to it. A case added there is a claim about Jinja, checked against Jinja.

Development

$ nix develop
$ zig build test --summary all
$ zig build fuzz --fuzz                       # until interrupted
$ zig build fuzz-run -- --seconds 300
$ zig build docs && zig build docs-serve      # then http://localhost:8000

The targets in tests/fuzz.zig say that any bytes as a template must parse or fail at a position inside the source, and that any template must render within its limits or fail with one of its own errors. zig build fuzz --fuzz runs them under Zig's own fuzzer, with coverage feedback; zig build fuzz-run drives them with a loop of its own whose runs are reproducible from a seed, which is what the workflow uses. tools/fuzz.zig says more.

Zig 0.16 users want the zig-0.16 branch, or the v0.1.0 tag.

Where this lives

The canonical repository is on my Forgejo instance, and Tangled carries a mirror of it:

git clone https://git.jcollie.dev/jeff/zig-jinja.git

It is also on Radicle as rad:z2GvXg4i7YQjJFXBntCqxeb73huUt, which is the only name that finds it there — a peer-to-peer repository has no host to browse:

rad clone rad:z2GvXg4i7YQjJFXBntCqxeb73huUt

All three serve the same history. CI, the published documentation and the issue tracker follow the canonical one.

To depend on it:

$ zig fetch --save git+https://git.jcollie.dev/jeff/zig-jinja.git

Licence

MIT. The project follows the REUSE specification; reuse lint checks it.

References cited