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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! The language as a caller sees it: source in, a value or a typed result//! out, or a diagnostic saying where it went wrong.
const std = @import("std");const testing = std.testing;
const root = @import("root.zig");const Diagnostic = root.Diagnostic;const Value = root.Value;
const Level = enum { debug, info, warn };
const Backend = union(enum) { memory, redis: struct { url: []const u8, db: u8 = 0 },};
const Context = struct { hostname: []const u8 = "web1", region: []const u8 = "us-east", prod: bool = true, debug: bool = false, cpus: u32 = 8, ratio: f64 = 0.25, level: Level = .warn, cert_path: ?[]const u8 = null, users: []const []const u8 = &.{ "alice", "root" }, ports: [3]u16 = .{ 80, 443, 8080 }, backend: Backend = .{ .redis = .{ .url = "redis://cache" } }, nested: struct { a: struct { b: i32 = -7 } = .{} } = .{}, alloc: std.mem.Allocator = testing.allocator, huge: u64 = std.math.maxInt(u64),};
const ctx: Context = .{};
/// Evaluate to a `Value` and compare its ZON rendering with `expected`.fn expectValue(source: []const u8, expected: []const u8) !void { var diag: Diagnostic = .{}; const result = root.evalSource(Value, testing.allocator, source, &ctx, .{}, &diag) catch |err| { std.debug.print("{s}\n{f}\n", .{ source, &diag }); return err; }; defer result.deinit(); var buf: [1024]u8 = undefined; const got = try std.fmt.bufPrint(&buf, "{f}", .{result.value}); testing.expectEqualStrings(expected, got) catch |err| { std.debug.print("source: {s}\n", .{source}); return err; };}
fn expectError(comptime T: type, source: []const u8, expected_err: anyerror, line: u32, column: u32, message: []const u8) !void { var diag: Diagnostic = .{}; if (root.evalSource(T, testing.allocator, source, &ctx, .{}, &diag)) |result| { result.deinit(); std.debug.print("source: {s}\nexpected: {s}\n", .{ source, message }); return error.TestUnexpectedSuccess; } else |err| { testing.expectEqual(expected_err, err) catch |e| { std.debug.print("source: {s}\n{f}\n", .{ source, &diag }); return e; }; } testing.expectEqualStrings(message, diag.message()) catch |err| { std.debug.print("source: {s}\n", .{source}); return err; }; testing.expectEqual(Diagnostic.Pos{ .line = line, .column = column }, diag.pos) catch |err| { std.debug.print("source: {s}\nmessage: {s}\n", .{ source, message }); return err; };}
test "literals" { try expectValue("null", "null"); try expectValue("true", "true"); try expectValue("0x10 + 0o10 + 0b10 + 1_000", "1026"); try expectValue("1.5e3", "1500.0"); try expectValue("'a'", "97"); try expectValue("\"a\\tb\\u{e9}\\x41\"", "\"a\\tbéA\""); try expectValue(".debug", ".debug"); try expectValue(".{}", ".{}"); try expectValue(".{ 1, \"two\", .three }", ".{ 1, \"two\", .three }"); try expectValue(".{ .a = 1, .@\"b c\" = .{ .d = null } }", ".{ .a = 1, .@\"b c\" = .{ .d = null } }"); try expectValue("-inf", "-inf"); try expectValue( \\\\first ${ctx.hostname} \\\\second \n line , "\"first web1\\nsecond \\\\n line\"");}
test "arithmetic" { try expectValue("1 + 2 * 3 - 4", "3"); try expectValue("(1 + 2) * 3", "9"); try expectValue("7 / 2", "3"); try expectValue("-7 / 2", "-3"); try expectValue("-7 % 3", "-1"); try expectValue("7 / 2.0", "3.5"); try expectValue("ctx.cpus / 2 + ctx.ratio", "4.25"); try expectValue("-ctx.nested.a.b", "7"); try expectValue("1.0 / 0", "inf");}
test "comparison and logic" { try expectValue("1 < 2 and 2 <= 2 and 3 > 2 and 3 >= 3", "true"); try expectValue("1 == 1.0", "true"); try expectValue("\"a\" < \"b\"", "true"); try expectValue("!(1 == 2) or false", "true"); try expectValue(".{ 1, .{ .a = 2 } } == .{ 1, .{ .a = 2 } }", "true"); try expectValue(".{ .a = 1, .b = 2 } == .{ .b = 2, .a = 1 }", "true"); try expectValue("ctx.level == .warn", "true"); try expectValue("ctx.level != .info", "true"); try expectValue("ctx.backend == .redis", "true"); try expectValue("ctx.backend == .memory", "false"); try expectValue("ctx.cert_path == null", "true"); try expectValue("ctx.users == .{ \"alice\", \"root\" }", "true");}
test "and and or short-circuit" { // The right-hand side would fail if it were evaluated. try expectValue("false and ctx.cert_path.? == \"x\"", "false"); try expectValue("true or ctx.cert_path.? == \"x\"", "true");}
test "orelse and unwrap" { try expectValue("ctx.cert_path orelse \"/etc/ssl/default.pem\"", "\"/etc/ssl/default.pem\""); try expectValue("ctx.hostname orelse \"none\"", "\"web1\""); try expectValue("null orelse null orelse 3", "3"); try expectValue("ctx.hostname.?", "\"web1\"");}
test "if" { try expectValue("if (ctx.prod) 443 else 8080", "443"); try expectValue("if (ctx.debug) .debug else if (ctx.prod) .warn else .info", ".warn"); try expectValue("1 + if (false) 1 else 2 * 3", "7"); try expectValue("if (ctx.cert_path) |p| p else \"none\"", "\"none\""); try expectValue("if (ctx.hostname) |h| \"host ${h}\" else \"none\"", "\"host web1\"");}
test "an if with no else leaves a field or an item out" { try expectValue(".{ .a = 1, .b = if (ctx.debug) 2, .c = if (ctx.prod) 3 }", ".{ .a = 1, .c = 3 }"); try expectValue(".{ 1, if (ctx.debug) 2, if (ctx.prod) 3, if (false) 4 else if (false) 5 }", ".{ 1, 3 }"); try expectValue(".{ .x = if (ctx.cert_path) |p| p }", ".{}");}
test "let" { try expectValue( \\let host = ctx.hostname; \\let base = .{ .port = 8080, .tls = false }; \\let full = "${host}.example.com"; \\base ++ .{ .name = full, .tls = ctx.prod } , ".{ .port = 8080, .tls = true, .name = \"web1.example.com\" }");}
test "a let that fails only matters if it is used" { try expectValue( \\let cert = ctx.cert_path.?; \\if (ctx.cert_path != null) cert else "none" , "\"none\""); try expectError(Value, \\let cert = ctx.cert_path.?; \\cert , error.Eval, 1, 25, "attempt to unwrap null");}
test "templates" { try expectValue("\"${ctx.hostname}-${ctx.region}\"", "\"web1-us-east\""); try expectValue("\"${ctx.cpus} ${ctx.ratio} ${ctx.prod} ${ctx.level}\"", "\"8 0.25 true warn\""); try expectValue("\"${1.0}\"", "\"1.0\""); try expectValue("\"a\\${b}\"", "\"a${b}\""); try expectValue("\"${\"inner ${ctx.hostname}\"}!\"", "\"inner web1!\""); try expectValue("\"${@join(ctx.users, \"}\")}\"", "\"alice}root\"");}
test "concatenation and merging" { try expectValue("\"a\" ++ \"b\"", "\"ab\""); try expectValue(".{ 1 } ++ .{ 2, 3 } ++ .{}", ".{ 1, 2, 3 }"); try expectValue("ctx.users ++ .{ \"bob\" }", ".{ \"alice\", \"root\", \"bob\" }"); try expectValue(".{ .a = 1, .b = 2 } ++ .{ .b = 3, .c = 4 }", ".{ .a = 1, .b = 3, .c = 4 }"); try expectValue(".{} ++ .{ .a = 1 }", ".{ .a = 1 }"); try expectValue("ctx.nested.a ++ .{ .c = 1 }", ".{ .b = -7, .c = 1 }"); try expectValue("ctx.backend ++ .{}", ".{ .redis = .{ .url = \"redis://cache\", .db = 0 } }");}
test "field access and indexing" { try expectValue("ctx.nested.a.b", "-7"); try expectValue("ctx.users[1]", "\"root\""); try expectValue("ctx.ports[2]", "8080"); try expectValue("ctx.users.len", "2"); try expectValue("\"abc\".len", "3"); try expectValue("\"abc\"[1]", "98"); try expectValue(".{ .a = .{ 1, 2 } }.a[0]", "1"); try expectValue("ctx.backend.redis.url", "\"redis://cache\"");}
test "built-ins" { try expectValue("@lower(\"MiXed\") ++ @upper(\"MiXed\")", "\"mixedMIXED\""); try expectValue("@trim(\" x \\n\")", "\"x\""); try expectValue(".{ @len(\"abc\"), @len(ctx.users), @len(.{}), @len(.{ .a = 1 }) }", ".{ 3, 2, 0, 1 }"); try expectValue(".{ @contains(ctx.users, \"root\"), @contains(\"hostname\", \"name\"), @contains(ctx.ports, 22) }", ".{ true, true, false }"); try expectValue(".{ @startsWith(ctx.region, \"us-\"), @endsWith(ctx.region, \"west\") }", ".{ true, false }"); try expectValue("@join(ctx.ports, \",\")", "\"80,443,8080\""); try expectValue("@replace(\"a-b-c\", \"-\", \"::\")", "\"a::b::c\""); try expectValue("@split(\"a,b,,c\", \",\")", ".{ \"a\", \"b\", \"\", \"c\" }"); try expectValue(".{ @min(3, 1, 2), @max(1, 2.5), @max(ctx.ports), @min(\"b\", \"a\") }", ".{ 1, 2.5, 8080, \"a\" }"); try expectValue(".{ @int(\"42\"), @int(\"0x10\"), @int(-2.9), @int(true) }", ".{ 42, 16, -2, 1 }"); try expectValue(".{ @float(2), @float(\"1.5\") }", ".{ 2.0, 1.5 }"); try expectValue("@string(ctx.cpus) ++ @string(.x)", "\"8x\"");}
test "runtime errors say where" { try expectError(Value, "1 + \"a\"", error.Eval, 1, 3, "cannot apply '+' to int and string"); try expectError(Value, "\"a\" + \"b\"", error.Eval, 1, 5, "'+' does not join strings; use '++'"); try expectError(Value, "1 / 0", error.Eval, 1, 3, "division by zero"); try expectError(Value, "9223372036854775807 + 1", error.Eval, 1, 21, "integer overflow"); try expectError(Value, "if (1) 2 else 3", error.Eval, 1, 5, "expected bool, found int"); try expectError(Value, "1 and true", error.Eval, 1, 1, "'and' needs bool operands, not int"); try expectError(Value, "ctx.nope", error.Eval, 1, 4, "no field 'nope' in tests.Context"); try expectError(Value, "ctx.cert_path.len", error.Eval, 1, 14, "cannot read field 'len' of null"); try expectError(Value, "ctx.users[5]", error.Eval, 1, 11, "index 5 is out of bounds for length 2"); try expectError(Value, "ctx.backend.memory", error.Eval, 1, 12, "the union holds .redis, not .memory"); try expectError(Value, "ctx.huge", error.Eval, 1, 4, "a value of type u64 does not fit in an i64"); try expectError(Value, "ctx.alloc.ptr", error.Eval, 1, 10, "values of type *anyopaque cannot be read"); try expectError(Value, "\"${null}\"", error.Eval, 1, 4, "cannot turn null into text; use 'orelse' to give a default"); try expectError(Value, "\"${.{}}\"", error.Eval, 1, 4, "cannot turn struct into text"); try expectError(Value, "@lower(1)", error.Eval, 1, 8, "@lower needs a string here, not int"); try expectError(Value, ".{ 1 } ++ .{ .a = 1 }", error.Eval, 1, 8, "cannot apply '++' to list and struct"); try expectError(Value, "1 < \"a\"", error.Eval, 1, 3, "cannot compare int < string");}
test "a program without a context cannot read one" { var diag: Diagnostic = .{}; try testing.expectError(error.Eval, root.evalSource(Value, testing.allocator, "ctx.x", {}, .{}, &diag)); try testing.expectEqualStrings("this program was evaluated without a context", diag.message());}
// -- decoding -----------------------------------------------------------------
const Config = struct { name: []const u8, port: u16 = 8080, tls: bool = false, cert: ?[]const u8 = null, level: Level = .info, ratio: f32 = 1, tags: []const []const u8 = &.{}, listen: [2]Listen = .{ .{}, .{} }, backend: Backend = .memory, pair: struct { u8, []const u8 } = .{ 0, "" }, extra: ?Value = null,
const Listen = struct { host: []const u8 = "::", port: u16 = 0 };};
fn expectConfig(source: []const u8, expected: Config) !void { var diag: Diagnostic = .{}; const result = root.evalSource(Config, testing.allocator, source, &ctx, .{}, &diag) catch |err| { std.debug.print("{s}\n{f}\n", .{ source, &diag }); return err; }; defer result.deinit(); var got = result.value; // `Value` is compared by its rendering, not its pointers. if (expected.extra) |e| { var a: [256]u8 = undefined; var b: [256]u8 = undefined; try testing.expectEqualStrings(try std.fmt.bufPrint(&a, "{f}", .{e}), try std.fmt.bufPrint(&b, "{f}", .{got.extra.?})); got.extra = expected.extra; } try testing.expectEqualDeep(expected, got);}
test "decoding into a struct" { try expectConfig( \\let base = .{ .name = "base", .port = 80 }; \\base ++ .{ \\ .name = "app-${ctx.hostname}", \\ .port = if (ctx.prod) 443 else base.port, \\ .tls = ctx.prod, \\ .cert = ctx.cert_path orelse "/etc/ssl/default.pem", \\ .level = if (ctx.debug) .debug else ctx.level, \\ .ratio = ctx.ratio * 2, \\ .tags = .{ "app", @lower(ctx.region), if (ctx.debug) "debug" }, \\ .listen = .{ .{ .host = "0.0.0.0", .port = 80 }, .{ .port = 443 } }, \\ .backend = ctx.backend, \\ .pair = .{ 7, "seven" }, \\ .extra = .{ .anything = .{ 1, "two" } }, \\} , .{ .name = "app-web1", .port = 443, .tls = true, .cert = "/etc/ssl/default.pem", .level = .warn, .ratio = 0.5, .tags = &.{ "app", "us-east" }, .listen = .{ .{ .host = "0.0.0.0", .port = 80 }, .{ .port = 443 } }, .backend = .{ .redis = .{ .url = "redis://cache" } }, .pair = .{ 7, "seven" }, .extra = .{ .record = &.{.{ .name = "anything", .value = .{ .list = &.{ .{ .int = 1 }, .{ .string = "two" } } } }} }, });}
test "defaults fill what is left out" { try expectConfig(".{ .name = \"x\" }", .{ .name = "x" }); try expectConfig(".{ .name = \"x\", .backend = .{ .redis = .{ .url = \"u\" } } }", .{ .name = "x", .backend = .{ .redis = .{ .url = "u" } } });}
test "decoding errors name the path and point at the field" { try expectError(Config, ".{ .name = \"x\", .port = 70000 }", error.Eval, 1, 18, ".port: 70000 does not fit in u16"); try expectError(Config, ".{ .port = 1 }", error.Eval, 1, 1, "missing field 'name' of tests.Config"); try expectError(Config, ".{ .name = \"x\",\n .nme = 1 }", error.Eval, 2, 4, "tests.Config has no field 'nme'"); try expectError(Config, ".{ .name = \"x\", .level = .loud }", error.Eval, 1, 18, ".level: tests.Level has no tag .loud"); try expectError(Config, ".{ .name = \"x\", .listen = .{ .{}, .{ .port = -1 } } }", error.Eval, 1, 39, ".listen[1].port: -1 does not fit in u16"); try expectError(Config, ".{ .name = \"x\", .listen = .{ .{} } }", error.Eval, 1, 18, ".listen: expected 2 items, found 1"); try expectError(Config, ".{ .name = \"x\", .backend = .redis }", error.Eval, 1, 18, ".backend: variant .redis of tests.Backend needs a value: .{ .redis = ... }"); try expectError(Config, ".{ .name = 1 }", error.Eval, 1, 5, ".name: expected []const u8, found int"); try expectError(Config, "1", error.Eval, 1, 1, "expected tests.Config, found int");}
test "a context struct decodes like a literal" { const Out = struct { a: struct { b: i32 } }; var diag: Diagnostic = .{}; const result = try root.evalSource(Out, testing.allocator, "ctx.nested", &ctx, .{}, &diag); defer result.deinit(); try testing.expectEqual(@as(i32, -7), result.value.a.b);}
test "plain ZON means what std.zon makes of it" { const Manifest = struct { name: enum { struthio }, version: []const u8, fingerprint: u64, minimum_zig_version: []const u8, dependencies: struct {}, paths: []const []const u8, }; const source = \\.{ \\ .name = .struthio, \\ .version = "0.1.0", \\ .fingerprint = 0xe3a97f23cf63b951, // a comment \\ .minimum_zig_version = "0.17.0", \\ .dependencies = .{}, \\ .paths = .{ \\ "build.zig", \\ "src", \\ }, \\} ; var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); var zon_diag: std.zon.parse.Diagnostics = undefined; const expected = try std.zon.parse.fromSlice(Manifest, .{ .gpa = testing.allocator, .arena = arena.allocator(), .source = source, .diagnostics = &zon_diag, });
// The fingerprint does not fit in an i64, which is the one place a // struthio program differs from ZON: integers are 64-bit signed. var diag: Diagnostic = .{}; try testing.expectError(error.Syntax, root.evalSource(Manifest, testing.allocator, source, {}, .{}, &diag));
const result = try root.evalSource(Manifest, testing.allocator, std.mem.replaceOwned( u8, arena.allocator(), source, "0xe3a97f23cf63b951", "0x63a97f23cf63b951", ) catch unreachable, {}, .{}, &diag); defer result.deinit(); var want = expected; want.fingerprint = 0x63a97f23cf63b951; try testing.expectEqualDeep(want, result.value);}
test "a program is evaluated against many contexts" { var diag: Diagnostic = .{}; var program: root.Program = try .parse(testing.allocator, "\"${ctx.hostname}:${ctx.cpus}\"", .{}, &diag); defer program.deinit(); for ([_]u32{ 1, 2, 3 }) |cpus| { const c: Context = .{ .cpus = cpus }; const r = try program.eval([]const u8, testing.allocator, &c, &diag); defer r.deinit(); var buf: [16]u8 = undefined; try testing.expectEqualStrings(try std.fmt.bufPrint(&buf, "web1:{d}", .{cpus}), r.value); }}
// -- host functions -----------------------------------------------------------
fn envLookup(call: *root.Call, args: []const Value) root.EvalError!Value { const env: *const std.StaticStringMap([]const u8) = @ptrCast(@alignCast(call.context.?)); if (args[0] != .string) return call.fail("@env needs a string", .{}); return if (env.get(args[0].string)) |v| .{ .string = v } else .null;}
test "host functions" { var env = std.StaticStringMap([]const u8).initComptime(.{.{ "HOME", "/home/x" }}); const functions = [_]root.Function{.{ .name = "env", .min_args = 1, .max_args = 1, .context = @ptrCast(&env), .call = envLookup }}; var diag: Diagnostic = .{}; const r = try root.evalSource([]const u8, testing.allocator, "\"${@env(\"HOME\")}/${@env(\"SHELL\") orelse \"sh\"}\"", {}, .{ .functions = &functions }, &diag); defer r.deinit(); try testing.expectEqualStrings("/home/x/sh", r.value);
try testing.expectError(error.Eval, root.evalSource(Value, testing.allocator, "@env(1)", {}, .{ .functions = &functions }, &diag)); try testing.expectEqualStrings("@env needs a string", diag.message()); try testing.expectError(error.Syntax, root.evalSource(Value, testing.allocator, "@env()", {}, .{ .functions = &functions }, &diag)); try testing.expectEqualStrings("@env takes 1 argument, not 0", diag.message());}
// -- limits -------------------------------------------------------------------
test "doubling is stopped by the limits" { const limits: root.Limits = .{ .max_string = 1000, .max_list = 1000, .max_steps = 10_000 }; var diag: Diagnostic = .{}; const strings = \\let a = "0123456789"; \\let b = a ++ a; let c = b ++ b; let d = c ++ c; let e = d ++ d; \\let f = e ++ e; let g = f ++ f; let h = g ++ g; \\h ; try testing.expectError(error.LimitExceeded, root.evalSource(Value, testing.allocator, strings, {}, .{ .limits = limits }, &diag)); try testing.expectEqualStrings("string longer than 1000 bytes", diag.message());
// Small to build, large to compare: each level is a list holding the // level below twice. const nested = \\let a = .{ 1, 1 }; let b = .{ a, a }; let c = .{ b, b }; let d = .{ c, c }; \\let e = .{ d, d }; let f = .{ e, e }; let g = .{ f, f }; let h = .{ g, g }; \\let i = .{ h, h }; let j = .{ i, i }; let k = .{ j, j }; let l = .{ k, k }; \\let m = .{ l, l }; let n = .{ m, m }; let o = .{ n, n }; let p = .{ o, o }; \\p == p ; try testing.expectError(error.LimitExceeded, root.evalSource(Value, testing.allocator, nested, {}, .{ .limits = limits }, &diag)); try testing.expectError(error.LimitExceeded, root.evalSource(Value, testing.allocator, nested[0 .. nested.len - 6] ++ "p", {}, .{ .limits = limits }, &diag));}
test "the README's example" { const ReadmeConfig = struct { name: []const u8, port: u16 = 8080, tls: bool = false, log_level: enum { debug, info } = .info, tags: []const []const u8 = &.{}, }; const C = struct { hostname: []const u8, prod: bool, debug: bool }; const c: C = .{ .hostname = "web1", .prod = true, .debug = false };
var diag: Diagnostic = .{}; const config = try root.evalSource(ReadmeConfig, testing.allocator, \\.{ \\ .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" }, \\} , &c, .{}, &diag); defer config.deinit(); try testing.expectEqualDeep(ReadmeConfig{ .name = "web-web1", .port = 443, .tls = true, .tags = &.{"app"} }, config.value);}
// -- switch -----------------------------------------------------------------
test "switch chooses the first prong that matches" { try expectValue("switch (ctx.level) { .debug => 1, .info, .warn => 2, else => 3 }", "2"); try expectValue("switch (ctx.hostname) { \"web1\" => .primary, else => .secondary }", ".primary"); try expectValue("switch (ctx.cpus) { 1...4 => \"small\", 5...16 => \"medium\", else => \"large\" }", "\"medium\""); try expectValue("switch (ctx.ratio) { 0...0.5 => true, else => false }", "true"); try expectValue("switch (ctx.cpus) { 8, 9 => 1, 1...9 => 2 }", "1"); try expectValue("switch (ctx.cert_path) { null => \"none\", else => |p| p }", "\"none\""); try expectValue("switch (\"b\") { \"a\"...\"c\" => 1, else => 2, }", "1"); try expectValue("switch (3) { 1, 2, => 0, else => |n| n * 2 }", "6");}
test "switch on a tagged union captures its payload" { try expectValue("switch (ctx.backend) { .memory => \"none\", .redis => |r| r.url }", "\"redis://cache\""); try expectValue("switch (ctx.backend) { .redis => |r| r.db, else => -1 }", "0"); try expectValue("switch (ctx.level) { .warn => |l| l, else => .other }", ".warn");}
test "a switch is a primary expression" { try expectValue("switch (1) { 1 => .{ .a = 2 }, else => .{ .a = 3 } }.a + 1", "3"); try expectValue("let x = switch (ctx.prod) { true => 443, false => 80 }; x", "443");}
test "a switch that matches nothing leaves out a field or an item" { try expectValue(".{ .a = switch (ctx.level) { .debug => 1 }, .b = 2 }", ".{ .b = 2 }"); try expectValue(".{ 1, switch (ctx.cpus) { 1 => 2 }, 3 }", ".{ 1, 3 }"); try expectValue(".{ if (ctx.prod) switch (ctx.cpus) { 1 => 2 } else 0 }", ".{}"); try expectError(Value, "switch (ctx.level) { .debug => 1 }", error.Eval, 1, 1, "no prong of the switch matches .warn"); // Inside an `if` that is not an item, a switch has to choose something. try expectError(Value, "1 + if (ctx.prod) switch (ctx.cpus) { 1 => 2 } else 0", error.Eval, 1, 19, "no prong of the switch matches 8");}
test "a switch range compares like '<='" { try expectError(Value, "switch (ctx.level) { 1...2 => 1, else => 2 }", error.Eval, 1, 22, "cannot compare int <= enum literal"); try expectValue("switch (ctx.level) { 1 => 1, else => 2 }", "2");}
test "a switch that matches nothing says what it was given" { var diag: Diagnostic = .{}; try testing.expectError(error.Eval, root.evalSource(Value, testing.allocator, "switch (ctx) { 1 => 1 }", &ctx, .{}, &diag)); try testing.expect(std.mem.startsWith(u8, diag.message(), "no prong of the switch matches .{ .hostname = \"web1\""));}
test "only the prong taken is evaluated" { try expectValue("switch (1) { 1 => 2, 2 => 1 / 0, else => ctx.nope }", "2");}
// -- @include -----------------------------------------------------------------
/// Files held in memory, and a note of every path asked for.const Files = struct { files: []const struct { []const u8, []const u8 }, /// Indices into `files`, since a path the reader is handed does not /// outlive the program. asked: [16]usize = undefined, asked_len: usize = 0,
fn read(context: ?*anyopaque, arena: std.mem.Allocator, path: []const u8) anyerror![]const u8 { _ = arena; const self: *Files = @ptrCast(@alignCast(context.?)); for (self.files, 0..) |f, i| { if (!std.mem.eql(u8, f[0], path)) continue; self.asked[self.asked_len] = i; self.asked_len += 1; return f[1]; } return error.FileNotFound; }
fn include(self: *Files) root.Include { return .{ .context = self, .read = read }; }};
fn expectIncluded(files: *Files, filename: ?[]const u8, source: []const u8, expected: []const u8) !void { var diag: Diagnostic = .{}; const result = root.evalSource(Value, testing.allocator, source, &ctx, .{ .filename = filename, .include = files.include(), }, &diag) catch |err| { std.debug.print("{f}\n", .{&diag}); return err; }; defer result.deinit(); var buf: [1024]u8 = undefined; try testing.expectEqualStrings(expected, try std.fmt.bufPrint(&buf, "{f}", .{result.value}));}
/// Fail with `expected_err`, and a diagnostic that prints as `expected`.fn expectIncludeError(files: *Files, filename: ?[]const u8, source: []const u8, expected_err: anyerror, expected: []const u8) !void { var diag: Diagnostic = .{}; if (root.evalSource(Value, testing.allocator, source, &ctx, .{ .filename = filename, .include = files.include(), }, &diag)) |result| { result.deinit(); return error.TestUnexpectedSuccess; } else |err| try testing.expectEqual(expected_err, err); var buf: [512]u8 = undefined; try testing.expectEqualStrings(expected, try std.fmt.bufPrint(&buf, "{f}", .{&diag}));}
test "an included file stands for its value" { var files: Files = .{ .files = &.{ .{ "file:///etc/app/base.struthio", "let port = if (ctx.prod) 443 else 80;\n.{ .port = port, .host = ctx.hostname }" }, } }; try expectIncluded(&files, "file:///etc/app/main.struthio", \\let base = @include("base.struthio"); \\base ++ .{ .tls = base.port == 443 } , ".{ .port = 443, .host = \"web1\", .tls = true }");}
test "include paths are references resolved against the including file" { var files: Files = .{ .files = &.{ .{ "file:///conf/lib/a.struthio", "@include(\"../b.struthio\") + 1" }, .{ "file:///conf/b.struthio", "@include(\"/etc/c.struthio\") + 1" }, .{ "file:///etc/c.struthio", "@include(\"file://localhost/srv/d%20e.struthio\") + 1" }, .{ "file:///srv/d%20e.struthio", "39" }, } }; try expectIncluded(&files, "file:///conf/main.struthio", "@include(\"./lib/a.struthio\")", "42"); try testing.expectEqualSlices(usize, &.{ 0, 1, 2, 3 }, files.asked[0..files.asked_len]);}
test "a filename may say localhost, and is given one spelling" { var files: Files = .{ .files = &.{.{ "file:///a/c", "1" }} }; try expectIncluded(&files, "FILE://LocalHost/a/./b/../main?q#f", "@include(\"c\")", "1"); try expectIncludeError(&files, "file:/a/main", "x", error.Syntax, "file:///a/main:1:1: unknown name 'x'");}
test "a file included twice is read once" { var files: Files = .{ .files = &.{.{ "file:///a", "1" }} }; try expectIncluded(&files, "file:///main", ".{ @include(\"a\"), @include(\"./a\"), @include(\"file:///a\") }", ".{ 1, 1, 1 }"); try testing.expectEqual(@as(usize, 1), files.asked_len);}
test "a program with no filename can include only absolute paths" { var files: Files = .{ .files = &.{.{ "file:///etc/a", "1" }} }; try expectIncluded(&files, null, "@include(\"/etc/a\") + @include(\"file:///etc/a\")", "2"); try expectIncludeError(&files, null, "@include(\"a\")", error.Syntax, "1:10: 'a' is relative, and this program has no filename to resolve it against");}
test "an included file sees ctx and none of the names of the file including it" { var files: Files = .{ .files = &.{.{ "file:///a", "x" }} }; try expectIncludeError(&files, "file:///main", "let x = 1; @include(\"a\")", error.Syntax, "file:///a:1:1: unknown name 'x'");}
test "the names an included file defines are its own" { var files: Files = .{ .files = &.{.{ "file:///a", "let x = 1; x" }} }; try expectIncluded(&files, "file:///main", "let x = 2; x + @include(\"a\")", "3");}
test "errors in an included file name that file" { var files: Files = .{ .files = &.{ .{ "file:///syntax", ".{\n .a = 1 2,\n}" }, .{ "file:///eval", "\n\n ctx.nope" }, .{ "file:///self", "@include(\"loop\")" }, .{ "file:///loop", "@include(\"self\")" }, } }; const main = "file:///main"; try expectIncludeError(&files, main, "@include(\"syntax\")", error.Syntax, "file:///syntax:2:10: expected ',' or '}', found a number"); try expectIncludeError(&files, main, ".{ .x = @include(\"eval\") }", error.Eval, "file:///eval:3:7: no field 'nope' in tests.Context"); try expectIncludeError(&files, main, "@include(\"self\")", error.Syntax, "file:///loop:1:1: 'file:///self' includes itself"); try expectIncludeError(&files, main, "1 +\n @include(\"missing\")", error.Include, "file:///main:2:3: cannot include 'file:///missing': FileNotFound"); try expectIncludeError(&files, main, "@include(ctx.hostname)", error.Syntax, "file:///main:1:10: @include takes a string literal"); try expectIncludeError(&files, main, "@include(\"a\", \"b\")", error.Syntax, "file:///main:1:1: @include takes 1 argument, not 2");}
test "an include must name a local file" { var files: Files = .{ .files = &.{} }; const main = "file:///main"; try expectIncludeError(&files, main, "@include(\"https://example.com/a\")", error.Syntax, "file:///main:1:10: 'https://example.com/a' is not a file: URI"); try expectIncludeError(&files, main, "@include(\"file://server/a\")", error.Syntax, "file:///main:1:10: 'file://server/a' names a file on another machine"); try expectIncludeError(&files, main, "@include(\"//server/a\")", error.Syntax, "file:///main:1:10: '//server/a' names a file on another machine"); try expectIncludeError(&files, main, "@include(\"file://127.0.0.1/a\")", error.Syntax, "file:///main:1:10: 'file://127.0.0.1/a' names a file on another machine");}
test "a file that includes itself directly" { var files: Files = .{ .files = &.{} }; try expectIncludeError(&files, "file:///dir/main", "@include(\"../dir/main\")", error.Syntax, "file:///dir/main:1:1: 'file:///dir/main' includes itself");}
test "the filename must be a file: URI naming a local file" { var files: Files = .{ .files = &.{} }; try expectIncludeError(&files, "main.struthio", "1", error.InvalidFilename, "1:1: the filename 'main.struthio' is not a file: URI"); try expectIncludeError(&files, "https://example.com/main", "1", error.InvalidFilename, "1:1: the filename 'https://example.com/main' is not a file: URI"); try expectIncludeError(&files, "file://server/main", "1", error.InvalidFilename, "1:1: the filename 'file://server/main' names a file on another machine"); try expectIncludeError(&files, "file://user@/main", "1", error.InvalidFilename, "1:1: the filename 'file://user@/main' names a file on another machine"); try expectIncludeError(&files, "file:main", "1", error.InvalidFilename, "1:1: the filename 'file:main' has no absolute path");}
test "without a way to read files, @include is not supported" { try expectError(Value, "1 + @include(\"a\")", error.Syntax, 1, 5, "@include is not supported here");}
test "how many files may be included is limited" { var files: Files = .{ .files = &.{ .{ "file:///a", "1" }, .{ "file:///b", "2" } } }; var diag: Diagnostic = .{}; try testing.expectError(error.Syntax, root.evalSource(Value, testing.allocator, "@include(\"/a\") + @include(\"/b\")", {}, .{ .include = files.include(), .limits = .{ .max_includes = 1 }, }, &diag)); try testing.expectEqualStrings("more than 1 files included", diag.message());}
test "a program with a filename says so in its diagnostics" { var diag: Diagnostic = .{}; try testing.expectError(error.Eval, root.evalSource(Value, testing.allocator, "\n1 + true", {}, .{ .filename = "file:///etc/app.struthio" }, &diag)); var buf: [128]u8 = undefined; try testing.expectEqualStrings("file:///etc/app.struthio:2:3: cannot apply '+' to int and bool", try std.fmt.bufPrint(&buf, "{f}", .{&diag}));}
/// The reader the README shows: a `file:` URI back to a path, and the file/// read from there.fn readFromDisk(context: ?*anyopaque, arena: std.mem.Allocator, file_uri: []const u8) anyerror![]const u8 { const io: *const std.Io = @ptrCast(@alignCast(context.?)); const u = try root.uri.Uri.parse(arena, file_uri); const path = try u.toPath(arena, .{}); return std.Io.Dir.cwd().readFileAlloc(io.*, path, arena, .limited(1 << 20));}
test "included files read from disk" { const io = testing.io; var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.createDirPath(io, "lib"); try tmp.dir.writeFile(io, .{ .sub_path = "lib/ports.struthio", .data = ".{ .http = 80, .https = 443 }" }); const dir = try tmp.dir.realPathFileAlloc(io, ".", testing.allocator); defer testing.allocator.free(dir);
const main_path = try std.fs.path.join(testing.allocator, &.{ dir, "main.struthio" }); defer testing.allocator.free(main_path); const main = try root.uri.Uri.fromPath(testing.allocator, main_path, .{}); defer main.deinit(); const filename = try main.toText(testing.allocator, .{}); defer testing.allocator.free(filename);
var diag: Diagnostic = .{}; const result = root.evalSource(Value, testing.allocator, "@include(\"lib/ports.struthio\").https", {}, .{ .filename = filename, .include = .{ .context = @constCast(&io), .read = readFromDisk }, }, &diag) catch |err| { std.debug.print("{f}\n", .{&diag}); return err; }; defer result.deinit(); try testing.expectEqual(@as(i64, 443), result.value.int);
try testing.expectError(error.Include, root.evalSource(Value, testing.allocator, "@include(\"nope\")", {}, .{ .filename = filename, .include = .{ .context = @constCast(&io), .read = readFromDisk }, }, &diag)); try testing.expect(std.mem.endsWith(u8, diag.message(), "/nope': FileNotFound"));}
// -- which fields of the context were read ------------------------------------
const Pane = struct { foreground: ?struct { name: []const u8 } = null, appearance: enum { light, dark } = .light, hostname: []const u8 = "web1",};const PaneReads = root.ContextReads(Pane);
fn readsOf(source: []const u8, files: ?*Files, functions: []const root.Function) !PaneReads { const pane: Pane = .{}; var reads: PaneReads = .full; var diag: Diagnostic = .{}; const result = root.evalSourceReads(Value, testing.allocator, source, &pane, .{ .filename = if (files != null) "file:///main" else null, .include = if (files) |f| f.include() else null, .functions = functions, }, &reads, &diag) catch |err| switch (err) { error.Eval => return reads, else => return err, }; result.deinit(); return reads;}
fn expectReads(source: []const u8, expected: []const std.meta.FieldEnum(Pane)) !void { try expectReadsWith(source, null, &.{}, expected);}
fn expectReadsWith(source: []const u8, files: ?*Files, functions: []const root.Function, expected: []const std.meta.FieldEnum(Pane)) !void { const got = try readsOf(source, files, functions); const want: PaneReads = .initMany(expected); if (!got.eql(want)) { std.debug.print("source: {s}\nread:", .{source}); var it = got.iterator(); while (it.next()) |f| std.debug.print(" {t}", .{f}); std.debug.print("\n", .{}); return error.TestExpectedEqual; }}
test "the fields a program reads are reported" { try expectReads("1", &.{}); try expectReads("ctx.hostname", &.{.hostname}); try expectReads(".{ .a = ctx.hostname, .b = ctx.appearance }", &.{ .hostname, .appearance }); // `len` is not a field, and reads none of them. try expectReads("ctx.len", &.{});}
test "only the branch taken reads the context" { try expectReads("if (ctx.appearance == .dark) ctx.hostname else 1", &.{.appearance}); try expectReads("if (ctx.foreground) |f| f.name else ctx.hostname", &.{ .foreground, .hostname }); try expectReads("switch (ctx.appearance) { .light => 1, .dark => ctx.hostname }", &.{.appearance}); try expectReads("false and ctx.foreground != null", &.{});}
test "a let reads the context only if it is used" { try expectReads("let h = ctx.hostname; 1", &.{}); try expectReads("let h = ctx.hostname; h", &.{.hostname}); try expectReads("let h = ctx.hostname; let g = h; g", &.{.hostname}); try expectReads("let h = ctx.hostname; let g = h; 1", &.{}); // The README's example: a let that fails, and is never used. try expectReads("let name = ctx.foreground.?.name; ctx.hostname", &.{.hostname}); // One that fails and is used fails the program, and still says so. try expectReads("let name = ctx.foreground.?.name; name", &.{.foreground});}
test "a failed program reports what it read before it failed" { try expectReads(".{ .a = ctx.appearance, .b = 1 + \"x\", .c = ctx.hostname }", &.{.appearance});}
test "the whole of ctx reads every field" { try expectReads("ctx", &.{ .foreground, .appearance, .hostname }); try expectReads("let c = ctx; c.hostname", &.{ .foreground, .appearance, .hostname }); try expectReads("let c = ctx; 1", &.{}); try expectReads("@string(ctx)", &.{ .foreground, .appearance, .hostname });}
fn askForCtx(call: *root.Call, _: []const Value) root.EvalError!Value { return call.ctx() orelse .null;}
fn ignoreCtx(_: *root.Call, _: []const Value) root.EvalError!Value { return .{ .int = 1 };}
test "a host function reads the context only by asking for it" { const functions = [_]root.Function{ .{ .name = "asks", .call = askForCtx }, .{ .name = "ignores", .call = ignoreCtx }, }; try expectReadsWith("@ignores()", null, &functions, &.{}); try expectReadsWith("@asks()", null, &functions, &.{ .foreground, .appearance, .hostname });}
test "an included file's reads count where it is used" { var files: Files = .{ .files = &.{ .{ "file:///colors", "let unused = ctx.hostname; if (ctx.appearance == .dark) 0 else 1" }, .{ "file:///fg", "ctx.foreground" }, } }; try expectReadsWith("@include(\"colors\")", &files, &.{}, &.{.appearance}); try expectReadsWith("let fg = @include(\"fg\"); @include(\"colors\")", &files, &.{}, &.{.appearance}); try expectReadsWith("let fg = @include(\"fg\"); fg", &files, &.{}, &.{.foreground});}
test "a program evaluated with evalReads gives what eval gives" { var diag: Diagnostic = .{}; var program: root.Program = try .parse(testing.allocator, "if (ctx.foreground) |f| f.name else ctx.hostname", .{}, &diag); defer program.deinit(); var reads: PaneReads = .empty; const pane: Pane = .{ .foreground = .{ .name = "vim" } }; const r = try program.evalReads([]const u8, testing.allocator, &pane, &reads, &diag); defer r.deinit(); try testing.expectEqualStrings("vim", r.value); try testing.expect(reads.eql(.initOne(.foreground)));}