Something went wrong. Try again.
A small configuration language for Zig: ZON with let, if and templates over a context, evaluated to a struct, always finishing.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! struthio: a small configuration language whose programs evaluate to a Zig//! struct.//!//! A program is ZON with expressions in it. Any ZON file is a program, and//! means what `std.zon.parse` would make of it; on top of that a program can//! read a context the caller hands it, bind names with `let`, choose with//! `if` and `switch`, compute with the usual operators, interpolate with//! `"${...}"`, merge structs with `++` and call a handful of built-in//! functions and any the caller adds.//!//! ```zig//! const Config = struct {//! name: []const u8,//! port: u16 = 8080,//! level: enum { debug, info } = .info,//! };//! const ctx = .{ .host = "web1", .prod = true };//!//! var diag: struthio.Diagnostic = .{};//! const config = try struthio.evalSource(Config, gpa,//! \\.{//! \\ .name = "app-${ctx.host}",//! \\ .port = if (ctx.prod) 443 else 8080,//! \\}//! , &ctx, .{}, &diag);//! defer config.deinit();//! ```//!//! What a program cannot do is loop or recurse. There is no loop syntax and//! no way to define a function, and a `let` can name only what came before//! it, so every program finishes, in time proportional to its length. What//! it builds is bounded by `Limits`. It performs no I/O: it reads its source,//! its context and the results of the host's functions, and nothing else.//!//! A program can name another file with `@include("path")`, which stands for//! that file's value. The host reads the file, through `Options.include`;//! a host that gives no way to read one does not support `@include`, and a//! program that uses it does not parse. Files are read when the program is//! parsed, never when it is evaluated.//!//! Files are named by `file:` URIs (RFC 8089), and only ones on this machine://! the program's own, `Options.filename`, and the one `Include.read` is//! asked for. The path an `@include` is written with is a URI reference,//! resolved against the URI of the file it is in, so it is usually just a//! relative path. `uri` is the URI library this uses, re-exported for a host//! that wants to turn one back into a filesystem path.
const std = @import("std");const Allocator = std.mem.Allocator;
pub const Diagnostic = @import("Diagnostic.zig");pub const Value = @import("value.zig").Value;pub const Host = @import("value.zig").Host;pub const Limits = evaluate.Limits;pub const Function = evaluate.Function;pub const Call = evaluate.Call;pub const Include = Parser.Include;pub const uri = @import("uri");
/// Lays a program out as `zig fmt` would: `format(gpa, source, .{}, &diag)`/// returns the formatted source, which the caller frees. `format.zig` gives/// the rules.pub const format = fmt.format;pub const FormatOptions = fmt.Options;pub const FormatError = fmt.Error;
/// A program as a tree of spans, for an editor to fold or to select by:/// `outline(gpa, source, .{}, &diag)` parses as `format` does and returns/// the spans, which the caller frees.pub const outline = fmt.outline;pub const Span = fmt.Span;pub const SpanKind = fmt.SpanKind;pub const NameSpan = fmt.NameSpan;
/// Splits source into tokens, for an editor to read a program that does not/// parse yet: `Tokenizer.init(source)`, then `next()` until `.eof`.pub const Tokenizer = @import("Tokenizer.zig");
const ast = @import("ast.zig");const Parser = @import("Parser.zig");const evaluate = @import("eval.zig");const decode = @import("decode.zig");const host = @import("host.zig");const fmt = @import("format.zig");
pub const ParseError = Parser.Error;pub const EvalError = evaluate.Error;pub const Error = ParseError || EvalError;
pub const Options = struct { /// The `file:` URI of the file the program was read from, such as /// `file:///etc/app/main.struthio`. Diagnostics name it, as /// `file:///etc/app/main.struthio:3:7: message`, and a relative /// `@include` path is resolved against it. /// /// Its host must be empty or `localhost`. Anything else -- another /// scheme, another machine, or text that is not a URI at all, such as a /// bare path -- fails the parse with `error.InvalidFilename`. filename: ?[]const u8 = null, /// How to read a file named by `@include`. Null, the default, means a /// program cannot include anything: `@include` is a syntax error. include: ?Include = null, /// Functions a program may call as `@name(...)`, in addition to the /// built-ins. Names are checked when parsing, so calling one that is not /// here is a syntax error. functions: []const Function = &.{}, limits: Limits = .{},};
/// A result and the arena that holds everything it points to.pub fn Parsed(comptime T: type) type { return struct { arena: *std.heap.ArenaAllocator, value: T,
pub fn deinit(self: @This()) void { const gpa = self.arena.child_allocator; self.arena.deinit(); gpa.destroy(self.arena); } };}
/// A parsed program, ready to be evaluated any number of times against any/// number of contexts.pub const Program = struct { arena: *std.heap.ArenaAllocator, tree: Parser.Tree, functions: []const Function, limits: Limits,
/// Parse `source`, which is copied: the program does not refer to it /// afterwards. Any file it includes is read now, through /// `options.include`. On any error but `error.OutOfMemory` the /// diagnostic says what and where. pub fn parse(gpa: Allocator, source: []const u8, options: Options, diag: *Diagnostic) ParseError!Program { const arena = try gpa.create(std.heap.ArenaAllocator); arena.* = .init(gpa); errdefer { arena.deinit(); gpa.destroy(arena); } const a = arena.allocator(); const src = try a.dupe(u8, source); const functions = try a.dupe(Function, options.functions); const tree = try Parser.parse(a, src, .{ .name = options.filename, .functions = functions, .include = options.include, .max_depth = options.limits.max_depth, .max_includes = options.limits.max_includes, }, diag); return .{ .arena = arena, .tree = tree, .functions = functions, .limits = options.limits }; }
pub fn deinit(self: *Program) void { const gpa = self.arena.child_allocator; self.arena.deinit(); gpa.destroy(self.arena); self.* = undefined; }
/// Evaluate the program and decode its result into a `T`. /// /// `ctx` is what the program sees as `ctx`: a pointer to anything -- /// usually a struct -- or `{}` for no context at all. It is read only as /// the program reads it, and nothing of it is kept: everything the result /// points to is copied into the result's own arena. /// /// `T` may be `Value` to get the result undecoded. pub fn eval(self: *const Program, comptime T: type, gpa: Allocator, ctx: anytype, diag: *Diagnostic) EvalError!Parsed(T) { return self.run(T, gpa, ctx, {}, diag); }
/// `eval`, also saying in `reads` which of the context's fields the /// program read, whether or not it succeeded. /// /// A field counts as read only if evaluating the program read it: in an /// `if` or a `switch`, in the branch that was taken, in an included file, /// or through a `let` that was used. A `let` that nothing uses reads /// nothing, even if it failed. Writing `ctx` other than as `ctx.name` -- /// `let c = ctx;`, `@string(ctx)` -- reads every field, and so does a host /// function calling `Call.ctx`. /// /// A host can therefore know that a field it did not see read cannot /// change the result, as long as the other fields stay the same. pub fn evalReads( self: *const Program, comptime T: type, gpa: Allocator, ctx: anytype, reads: *ContextReads(@typeInfo(@TypeOf(ctx)).pointer.child), diag: *Diagnostic, ) EvalError!Parsed(T) { reads.* = .empty; return self.run(T, gpa, ctx, reads, diag); }
/// `reads` is `{}`, or where to say which fields of `ctx` were read. fn run(self: *const Program, comptime T: type, gpa: Allocator, ctx: anytype, reads: anytype, diag: *Diagnostic) EvalError!Parsed(T) { var scratch: std.heap.ArenaAllocator = .init(gpa); defer scratch.deinit();
const result = try gpa.create(std.heap.ArenaAllocator); result.* = .init(gpa); errdefer { result.deinit(); gpa.destroy(result); }
var e: evaluate.Evaluator = .init(scratch.allocator(), self.tree.sources, self.tree, null, self.functions, self.limits, diag); e.ctx = try contextValue(&e, ctx); if (@TypeOf(reads) != void) e.ctx_fields = comptime fieldNames(@TypeOf(reads.*).Key); // Whether the program succeeds or fails. defer if (@TypeOf(reads) != void) report(&e, reads); const v = try e.run();
var d: decode.Decoder = .{ .arena = result.allocator(), .sources = self.tree.sources, .diag = diag, .steps = &e.steps, .pos = self.tree.root.pos, }; const decoded = try decode.decode(T, &d, v); return .{ .arena = result, .value = decoded }; }};
/// The set of a context's fields that a program read, as `Program.evalReads`/// reports it: `reads.contains(.hostname)`. Only a struct has fields to read.pub fn ContextReads(comptime C: type) type { if (@typeInfo(C) != .@"struct") @compileError("only a struct context has fields to report reads of; found " ++ @typeName(C)); return std.EnumSet(std.meta.FieldEnum(C));}
/// The names of the fields of `E`, a `std.meta.FieldEnum`, in order.fn fieldNames(comptime E: type) []const []const u8 { const names = std.meta.fieldNames(E); var out: [names.len][]const u8 = undefined; for (names, &out) |n, *o| o.* = n; const final = out; return &final;}
fn report(e: *const evaluate.Evaluator, reads: anytype) void { const E = @TypeOf(reads.*).Key; // Empty if evaluation never started: the context could not be read. if (e.reads.bit_length == 0) return; inline for (comptime std.meta.fieldNames(E), 0..) |name, i| { if (e.reads.isSet(i)) reads.insert(@field(E, name)); }}
fn contextValue(e: *evaluate.Evaluator, ctx: anytype) EvalError!?Value { const C = @TypeOf(ctx); if (C == void) return null; const info = @typeInfo(C); if (info != .pointer or info.pointer.size != .one) { @compileError("the context must be a pointer, or {} for none; found " ++ @typeName(C)); } return host.of(info.pointer.child, ctx, &e.why) catch |err| e.hostFail(0, err);}
/// Parse `source` and evaluate it once: `Program.parse` then `Program.eval`.pub fn evalSource( comptime T: type, gpa: Allocator, source: []const u8, ctx: anytype, options: Options, diag: *Diagnostic,) Error!Parsed(T) { var program: Program = try .parse(gpa, source, options, diag); defer program.deinit(); return program.eval(T, gpa, ctx, diag);}
/// `evalSource`, saying in `reads` which fields of `ctx` were read, as/// `Program.evalReads` does. A program that does not parse read none.pub fn evalSourceReads( comptime T: type, gpa: Allocator, source: []const u8, ctx: anytype, options: Options, reads: *ContextReads(@typeInfo(@TypeOf(ctx)).pointer.child), diag: *Diagnostic,) Error!Parsed(T) { reads.* = .empty; var program: Program = try .parse(gpa, source, options, diag); defer program.deinit(); return program.evalReads(T, gpa, ctx, reads, diag);}
test { _ = @import("Diagnostic.zig"); _ = @import("Tokenizer.zig"); _ = @import("Parser.zig"); _ = @import("value.zig"); _ = @import("host.zig"); _ = @import("tests.zig"); _ = @import("format.zig");}