A small configuration language for Zig: ZON with let, if, templated strings and a context, evaluated to a struct and guaranteed to finish
  • Zig 98.8%
  • Nix 1.2%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Jeffrey C. Ollie 0b64e3fe9a
All checks were successful
test / test (push) Successful in 8m3s
test / docs (push) Successful in 6m20s
Say where the repository lives
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DEEzAJVqap5D2Zj9ZohPCs
2026-10-10 11:46:31 -05:00
.forgejo/workflows Add struthio, a configuration language that evaluates to a Zig struct 2026-10-10 11:46:01 -05:00
LICENSES Add struthio, a configuration language that evaluates to a Zig struct 2026-10-10 11:46:01 -05:00
src Add struthio, a configuration language that evaluates to a Zig struct 2026-10-10 11:46:01 -05:00
tests Add struthio, a configuration language that evaluates to a Zig struct 2026-10-10 11:46:01 -05:00
tools Add struthio, a configuration language that evaluates to a Zig struct 2026-10-10 11:46:01 -05:00
.gitignore Add struthio, a configuration language that evaluates to a Zig struct 2026-10-10 11:46:01 -05:00
build.zig Add struthio, a configuration language that evaluates to a Zig struct 2026-10-10 11:46:01 -05:00
build.zig.zon Add struthio, a configuration language that evaluates to a Zig struct 2026-10-10 11:46:01 -05:00
flake.lock Add struthio, a configuration language that evaluates to a Zig struct 2026-10-10 11:46:01 -05:00
flake.nix Add struthio, a configuration language that evaluates to a Zig struct 2026-10-10 11:46:01 -05:00
package.nix Add struthio, a configuration language that evaluates to a Zig struct 2026-10-10 11:46:01 -05:00
README.md Say where the repository lives 2026-10-10 11:46:31 -05:00
REUSE.toml Add struthio, a configuration language that evaluates to a Zig struct 2026-10-10 11:46:01 -05:00

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 let can only name what came before it, so nothing can refer to itself. What a program builds is bounded by Limits — 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 a Diagnostic saying line: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

  • ctx is the caller's context.
  • let name = expr; at the start of a program binds name for everything after it. Names cannot be rebound or shadowed, as in Zig.
  • |name| in an if binds for the then branch.

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