- Zig 98.8%
- Nix 1.2%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01DEEzAJVqap5D2Zj9ZohPCs |
||
| .forgejo/workflows | ||
| LICENSES | ||
| src | ||
| tests | ||
| tools | ||
| .gitignore | ||
| build.zig | ||
| build.zig.zon | ||
| flake.lock | ||
| flake.nix | ||
| package.nix | ||
| README.md | ||
| REUSE.toml | ||
struthio
A small configuration language for Zig 0.17 whose programs evaluate to a Zig
struct. A program is ZON with expressions in it: it can read a context
the caller hands it, bind names with let, choose with if, compute with the
usual operators, template strings with ${...}, merge structs with ++, and
call a handful of built-in functions and any the caller adds. It cannot loop
or recurse, so every program finishes, in time proportional to its length.
const struthio = @import("struthio");
const Config = struct {
name: []const u8,
port: u16 = 8080,
tls: bool = false,
log_level: enum { debug, info } = .info,
tags: []const []const u8 = &.{},
};
const Context = struct { hostname: []const u8, prod: bool, debug: bool };
const ctx: Context = .{ .hostname = "web1", .prod = true, .debug = false };
var diag: struthio.Diagnostic = .{};
const config = struthio.evalSource(Config, gpa,
\\.{
\\ .name = "web-${ctx.hostname}",
\\ .port = if (ctx.prod) 443 else 8080,
\\ .tls = ctx.prod,
\\ .log_level = if (ctx.debug) .debug else .info,
\\ .tags = .{ "app", if (ctx.debug) "debug" },
\\}
, &ctx, .{}, &diag) catch |err| {
std.log.err("config: {f}", .{&diag}); // "line:column: message"
return err;
};
defer config.deinit();
// config.value is a Config: .{ .name = "web-web1", .port = 443, .tls = true, ... }
The API documentation is generated from the doc comments and published at https://jeff.jcollie.page/struthio/.
The name is the genus of the ostrich, which is a bird that cannot fly — as a struthio program cannot loop.
Why
Configuration that somebody other than the program's author edits wants more than ZON — the same file for staging and production, a hostname spliced into a path — and much less than a general-purpose language. Three properties follow:
- It always finishes. There is no loop syntax and no way to define a
function, and a
letcan only name what came before it, so nothing can refer to itself. What a program builds is bounded byLimits— string length, list length, and the work of comparing and decoding — since++on a value built with++doubles it. - An error is an error, with a position. A syntax error, a type error
(
1 + "a"), a missing field or an out-of-range integer comes back as an error and aDiagnosticsayingline:column: message. A decoding error names the path,.listen[1].port: -1 does not fit in u16, and points at the field in the program that wrote it. - It does no I/O. A program reads its source, its context, and what the host's functions return. Nothing else.
The language
Any ZON file is a program, and decodes to what std.zon.parse would make of
it, with two exceptions: integers are 64-bit signed, so a literal that does
not fit an i64 is an error; and ${ in a string starts an interpolation
(write \$ for a literal $).
// Comments are Zig's.
let host = ctx.hostname; // `let` binds a name, once
let base = .{ .port = 8080, .tls = false };
base ++ .{ // `++` merges structs
.name = "web-${host}-${ctx.region}", // templated string
.port = if (ctx.prod) 443 else base.port,
.tls = ctx.prod and ctx.cert_path != null,
.cert = ctx.cert_path orelse "/etc/ssl/default.pem",
.log_level = if (ctx.debug) .debug else .info, // enum literal
.workers = @max(1, ctx.cpus / 2),
.tags = .{ "app", @lower(ctx.env) },
.admin = if (@contains(ctx.users, "root")) "root", // no `else`: left out
.backend = if (ctx.cache) |c| .{ .redis = c.url } else .memory,
}
Values
| Kind | Written | Notes |
|---|---|---|
| null | null |
|
| bool | true, false |
|
| int | 42, -7, 0xff, 0o17, 0b101, 1_000, 'a' |
i64; overflow is an error |
| float | 1.5, 1e-3, 0x1p4, inf, nan |
f64 |
| string | "a\n${x}", \\ multiline lines |
escapes are Zig's plus \$ |
| enum literal | .name, .@"two words" |
an enum tag, or a union variant with no payload |
| list | .{ 1, 2, 3 } |
also a tuple, array or slice |
| struct | .{ .a = 1, .b = 2 } |
also a union: .{ .variant = payload } |
.{} is both an empty list and an empty struct.
Expressions
From loosest to tightest, as in Zig:
| Operators | |
|---|---|
or |
bool operands only, short-circuit |
and |
bool operands only, short-circuit |
== != < <= > >= |
do not chain; == compares deeply, and 1 == 1.0 |
orelse |
the left side unless it is null |
+ - ++ |
++ joins strings, joins lists, and merges structs (right side wins) |
* / % |
/ truncates on ints; % takes the sign of the left side |
! - |
prefix |
x.name x[i] x.? x.len |
field, index, unwrap an optional, length of a string or list |
There is no truthiness: if and the boolean operators want a bool.
if (cond) a else b is an expression whose branches extend as far right as
they can. if (optional) |x| a else b binds x to the value when it is not
null. As the value of a struct field or a list item, an if may leave out
its else, and when its condition is false the field or item is left out —
so the struct's default applies, or the list is one shorter.
In a multiline \\ string, interpolation works and escapes do not; a literal
${ there is ${"$"}{.
Names
ctxis the caller's context.let name = expr;at the start of a program bindsnamefor everything after it. Names cannot be rebound or shadowed, as in Zig.|name|in anifbinds for thethenbranch.
Every let is evaluated, in order. One that fails is only an error if it is
used, so let cert = ctx.cert.?; is harmless in a program that reads cert
only when ctx.cert != null.
Built-in functions
| Function | |
|---|---|
@lower(s), @upper(s), @trim(s) |
ASCII case; trims spaces, tabs and line ends |
@len(x) |
bytes of a string, items of a list, fields of a struct |
@contains(hay, needle) |
substring of a string, or member of a list |
@startsWith(s, p), @endsWith(s, p) |
|
@join(list, sep) |
items written as ${} would write them |
@replace(s, from, to), @split(s, sep) |
|
@min(...), @max(...) |
of two or more numbers or strings, or of one list |
@int(x), @float(x), @string(x) |
conversions; @int truncates and parses "0x10" |
Using it from Zig
Program.parse parses once; Program.eval(T, gpa, &ctx, &diag) evaluates
against a context and decodes the result into T. evalSource does both.
T may be struthio.Value for the undecoded result, and a field of type
Value takes whatever the program wrote there.
The context is a pointer to anything, read by reflection only as far as
the program reads it: ints, floats, bools, enums, optionals, []const u8
strings, slices, arrays and tuples, structs, tagged unions, and pointers to
any of these. A field of a type the language has no value for — an
allocator, a function — is an error only if a program reads it, so a context
can be a program's real state. Pass {} for no context.
Decoding follows ZON: a field left out takes its default or is an error,
an unknown field is an error, integers are range-checked, and an int is
accepted for a float. The result lives in its own arena, strings included,
and outlives the program and the context; deinit frees it.
Host functions are called like built-ins:
fn env(call: *struthio.Call, args: []const struthio.Value) struthio.EvalError!struthio.Value {
if (args[0] != .string) return call.fail("@env needs a string", .{});
const map: *const Env = @ptrCast(@alignCast(call.context.?));
return if (map.get(args[0].string)) |v| .{ .string = v } else .null;
}
const functions = [_]struthio.Function{
.{ .name = "env", .min_args = 1, .max_args = 1, .context = &my_env, .call = env },
};
var program: struthio.Program = try .parse(gpa, source, .{ .functions = &functions }, &diag);
Names and argument counts are checked when parsing.
Limits (Options.limits) bound expression nesting, the length of any
string or list, and the number of values comparison and decoding may visit.
The defaults are generous for configuration; tighten them for programs from
less trusted hands.
Building and testing
The development shell has Zig 0.17.0 and the rest of the tooling:
$ nix develop
$ zig build test --summary all # unit, golden and fuzz-seed tests
$ zig build fuzz --fuzz # Zig's coverage-guided fuzzer
$ zig build fuzz-run -- --seconds 300 # a reproducible fuzzing loop
$ zig build docs-serve # read the API docs at localhost:8000
$ nix build # the package, tests run in the sandbox
tests/golden/ holds whole programs beside the ZON they must evaluate to
(.zon) or the diagnostic they must fail with (.err).
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/struthio.git
It is also on Radicle as
rad:z3X2SbfjxqgqQMcbe33XhyzFUe1xC, which is the only name that finds it
there — a peer-to-peer repository has no host to browse:
rad clone rad:z3X2SbfjxqgqQMcbe33XhyzFUe1xC
All three serve the same history. CI and the published documentation come from the Forgejo repository.
License
MIT; see LICENSES/MIT.txt. The project follows REUSE.
References cited
- Zig Software Foundation. Zig Language Reference 0.17.0. https://ziglang.org/documentation/0.17.0/ — the syntax, operator precedence and ZON that the language extends.
- Ollie, Jeffrey C. zig-jinja: a subset of Jinja for Zig. 2026. https://git.jcollie.dev/jeff/zig-jinja — the model for the diagnostics, the limits, and the fuzzing harness.