// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // 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"); }