fig 1.0.0

Parse, edit, and convert config files while preserving comments. Supports JSON, YAML, TOML, and more.
//! Reflection-based deserialization: parse a config format straight into a
//! native Zig type, à la `std.json.parseFromSlice`.
//!
//! The format is parsed to a `fig` AST, then mapped onto `T` by `@typeInfo`
//! reflection — structs from mappings, slices/arrays from sequences, enums from
//! string (or ZON enum-literal) scalars, and so on. It is deliberately a
//! *convenience* layer: by default it ignores mapping keys with no matching
//! field (`Options.ignore_unknown_fields`), so it is tolerant ("lossy") of extra
//! data the way frontmatter readers usually want.
//!
//! For comment-preserving edits or full structural access, use `Editor` /
//! `Document` / `AST` directly; this is the one-shot "give me my struct" path.

const std = @import("std");
const AST = @import("ast/ast.zig");
const build_options = @import("build_options");

const Json = @import("json/parser.zig");
const Yaml = if (build_options.lang_yaml) @import("yaml/parser.zig") else void;
const Toml = if (build_options.lang_toml) @import("toml/parser.zig") else void;
const Zon = if (build_options.lang_zon) @import("zon/parser.zig") else void;

/// The source format to parse before mapping onto `T`. A format that was
/// compiled out is still a valid enum value, but parsing it yields
/// `error.FormatDisabled` (see `parseToAst`).
pub const Format = enum { json, jsonc, yaml, toml, zon };

pub const Options = struct {
    /// Mapping keys with no matching struct field are ignored when true (the
    /// tolerant default), or an `error.UnknownField` when false.
    ignore_unknown_fields: bool = true,
};

/// Ways the mapping onto `T` can fail (parser errors are surfaced separately by
/// the entry points).
pub const Error = error{
    /// A node's kind doesn't match the target type (e.g. a string for an `i64`).
    UnexpectedType,
    /// A required struct field (no default, not optional) was absent.
    MissingField,
    /// A mapping key matched no field and `ignore_unknown_fields` was false.
    UnknownField,
    /// A numeric scalar didn't parse into the target integer/float type.
    InvalidNumber,
    /// A scalar didn't match any tag of the target enum.
    InvalidEnum,
} || std.mem.Allocator.Error || AST.ResolveError;

/// An owned deserialization result: `value` and the arena backing all of its
/// allocations. Free both with `deinit`. Mirrors `std.json.Parsed`.
pub fn Parsed(comptime T: type) type {
    return struct {
        arena: *std.heap.ArenaAllocator,
        value: T,

        pub fn deinit(self: @This()) void {
            const child = self.arena.child_allocator;
            self.arena.deinit();
            child.destroy(self.arena);
        }
    };
}

/// Parse `source` as `format` and deserialize it into a `T`, returning a value
/// that owns its allocations via an internal arena (`Parsed(T).deinit`).
pub fn parseFromSlice(
    comptime T: type,
    allocator: std.mem.Allocator,
    source: []const u8,
    format: Format,
    options: Options,
) !Parsed(T) {
    const arena = try allocator.create(std.heap.ArenaAllocator);
    errdefer allocator.destroy(arena);
    arena.* = .init(allocator);
    errdefer arena.deinit();

    const value = try parseFromSliceLeaky(T, arena.allocator(), source, format, options);
    return .{ .arena = arena, .value = value };
}

/// Like `parseFromSlice`, but every allocation is made in `allocator` with no
/// cleanup of its own — pass an arena and free it yourself. The intermediate
/// AST is freed before returning; only `T`'s own data is left allocated.
pub fn parseFromSliceLeaky(
    comptime T: type,
    allocator: std.mem.Allocator,
    source: []const u8,
    format: Format,
    options: Options,
) !T {
    var ast = try parseToAst(allocator, source, format);
    defer ast.deinit();
    return parseValue(T, allocator, &ast, ast.root, options);
}

fn parseToAst(allocator: std.mem.Allocator, source: []const u8, format: Format) !AST {
    return switch (format) {
        .json => Json.parseAbstract(allocator, source, .JSON),
        .jsonc => Json.parseAbstract(allocator, source, .JSONC),
        .yaml => if (comptime build_options.lang_yaml) Yaml.parseAbstract(allocator, source, .v1_2_2) else error.FormatDisabled,
        .toml => if (comptime build_options.lang_toml) Toml.parseAbstract(allocator, source, .TOML_1_1) else error.FormatDisabled,
        .zon => if (comptime build_options.lang_zon) Zon.parseAbstract(allocator, source, .ZON) else error.FormatDisabled,
    };
}

fn parseValue(comptime T: type, allocator: std.mem.Allocator, ast: *const AST, id: AST.Node.Id, options: Options) Error!T {
    // Resolve a YAML alias to its target (a no-op for every other node/format).
    const node = ast.nodes[try ast.resolveDeep(ast.nodes[id])];
    return switch (@typeInfo(T)) {
        .bool => switch (node.kind) {
            .boolean => |b| b,
            else => error.UnexpectedType,
        },
        .int => parseInt(T, node),
        .float => parseFloat(T, node),
        .optional => |opt| if (node.kind == .null_)
            null
        else
            try parseValue(opt.child, allocator, ast, node.id, options),
        .@"enum" => parseEnum(T, node),
        .@"struct" => parseStruct(T, allocator, ast, node, options),
        .pointer => parseSlice(T, allocator, ast, node, options),
        .array => parseArray(T, allocator, ast, node, options),
        else => @compileError("fig.deserialize: cannot deserialize into " ++ @typeName(T)),
    };
}

fn parseInt(comptime T: type, node: AST.Node) Error!T {
    const raw = switch (node.kind) {
        .number => |n| n.raw,
        else => return error.UnexpectedType,
    };
    return std.fmt.parseInt(T, raw, 0) catch error.InvalidNumber;
}

fn parseFloat(comptime T: type, node: AST.Node) Error!T {
    const raw = switch (node.kind) {
        .number => |n| n.raw,
        else => return error.UnexpectedType,
    };
    if (specialFloat(T, raw)) |f| return f;
    return std.fmt.parseFloat(T, raw) catch error.InvalidNumber;
}

/// YAML's `.inf`/`.nan` spellings, which `std.fmt.parseFloat` rejects.
fn specialFloat(comptime T: type, raw: []const u8) ?T {
    const eql = std.mem.eql;
    if (eql(u8, raw, ".inf") or eql(u8, raw, ".Inf") or eql(u8, raw, ".INF") or
        eql(u8, raw, "+.inf") or eql(u8, raw, "+.Inf") or eql(u8, raw, "+.INF"))
        return std.math.inf(T);
    if (eql(u8, raw, "-.inf") or eql(u8, raw, "-.Inf") or eql(u8, raw, "-.INF"))
        return -std.math.inf(T);
    if (eql(u8, raw, ".nan") or eql(u8, raw, ".NaN") or eql(u8, raw, ".NAN"))
        return std.math.nan(T);
    return null;
}

fn parseEnum(comptime T: type, node: AST.Node) Error!T {
    const name = switch (node.kind) {
        .string => |s| s,
        // A ZON enum literal (`.foo`) carries the bare name in `text`.
        .extended => |e| if (e.kind == .enum_literal) e.text else return error.UnexpectedType,
        else => return error.UnexpectedType,
    };
    return std.meta.stringToEnum(T, name) orelse error.InvalidEnum;
}

fn parseStruct(comptime T: type, allocator: std.mem.Allocator, ast: *const AST, node: AST.Node, options: Options) Error!T {
    const first = switch (node.kind) {
        .mapping => |m| m,
        else => return error.UnexpectedType,
    };
    const fields = @typeInfo(T).@"struct".fields;

    var result: T = undefined;
    var seen = [_]bool{false} ** fields.len;

    var cur = first;
    while (cur) |kvid| : (cur = ast.nodes[kvid].next_sibling) {
        const kv = ast.nodes[kvid].kind.keyvalue;
        const key = switch (ast.nodes[kv.key].kind) {
            .string => |s| s,
            else => continue, // non-string keys can't name a Zig field
        };
        var matched = false;
        inline for (fields, 0..) |field, i| {
            if (!matched and std.mem.eql(u8, field.name, key)) {
                @field(result, field.name) = try parseValue(field.type, allocator, ast, kv.value, options);
                seen[i] = true;
                matched = true;
            }
        }
        if (!matched and !options.ignore_unknown_fields) return error.UnknownField;
    }

    // Fill fields the mapping didn't provide: a default, else null for an
    // optional, else it's missing.
    inline for (fields, 0..) |field, i| {
        if (!seen[i]) {
            if (field.default_value_ptr) |ptr| {
                @field(result, field.name) = @as(*const field.type, @ptrCast(@alignCast(ptr))).*;
            } else if (@typeInfo(field.type) == .optional) {
                @field(result, field.name) = null;
            } else {
                return error.MissingField;
            }
        }
    }
    return result;
}

fn parseSlice(comptime T: type, allocator: std.mem.Allocator, ast: *const AST, node: AST.Node, options: Options) Error!T {
    const ptr = @typeInfo(T).pointer;
    if (ptr.size != .slice) @compileError("fig.deserialize: unsupported pointer type " ++ @typeName(T));

    // `[]const u8` / `[:0]const u8` are strings, not byte sequences.
    if (ptr.child == u8) {
        const s = switch (node.kind) {
            .string => |str| str,
            else => return error.UnexpectedType,
        };
        if (comptime ptr.sentinel_ptr != null) return allocator.dupeZ(u8, s);
        return allocator.dupe(u8, s);
    }

    const first = switch (node.kind) {
        .sequence => |seq| seq,
        else => return error.UnexpectedType,
    };
    var count: usize = 0;
    var c = first;
    while (c) |cid| : (c = ast.nodes[cid].next_sibling) count += 1;

    const out = try allocator.alloc(ptr.child, count);
    c = first;
    var i: usize = 0;
    while (c) |cid| : (c = ast.nodes[cid].next_sibling) {
        out[i] = try parseValue(ptr.child, allocator, ast, cid, options);
        i += 1;
    }
    return out;
}

fn parseArray(comptime T: type, allocator: std.mem.Allocator, ast: *const AST, node: AST.Node, options: Options) Error!T {
    const arr = @typeInfo(T).array;
    const first = switch (node.kind) {
        .sequence => |seq| seq,
        else => return error.UnexpectedType,
    };
    var result: T = undefined;
    var c = first;
    var i: usize = 0;
    while (c) |cid| : (c = ast.nodes[cid].next_sibling) {
        if (i >= arr.len) return error.UnexpectedType; // too many elements
        result[i] = try parseValue(arr.child, allocator, ast, cid, options);
        i += 1;
    }
    if (i != arr.len) return error.UnexpectedType; // too few elements
    return result;
}

// --- tests ---------------------------------------------------------------

const testing = std.testing;

test "deserialize: yaml into a struct (defaults, optionals, unknown ignored)" {
    if (comptime !build_options.lang_yaml) return error.SkipZigTest;
    const Config = struct {
        title: []const u8,
        count: i64,
        ratio: f64,
        enabled: bool,
        tags: []const []const u8,
        nickname: ?[]const u8 = null,
        retries: u8 = 7,
    };
    const src =
        \\title: Hi
        \\count: 42
        \\ratio: 1.5
        \\enabled: true
        \\tags:
        \\- a
        \\- b
        \\extra: ignored
        \\
    ;
    const parsed = try parseFromSlice(Config, testing.allocator, src, .yaml, .{});
    defer parsed.deinit();

    try testing.expectEqualStrings("Hi", parsed.value.title);
    try testing.expectEqual(@as(i64, 42), parsed.value.count);
    try testing.expectEqual(@as(f64, 1.5), parsed.value.ratio);
    try testing.expect(parsed.value.enabled);
    try testing.expectEqual(@as(usize, 2), parsed.value.tags.len);
    try testing.expectEqualStrings("a", parsed.value.tags[0]);
    try testing.expectEqualStrings("b", parsed.value.tags[1]);
    try testing.expect(parsed.value.nickname == null);
    try testing.expectEqual(@as(u8, 7), parsed.value.retries);
}

test "deserialize: json into a struct" {
    const S = struct { name: []const u8, n: u32 };
    const parsed = try parseFromSlice(S, testing.allocator, "{\"name\": \"x\", \"n\": 5}", .json, .{});
    defer parsed.deinit();
    try testing.expectEqualStrings("x", parsed.value.name);
    try testing.expectEqual(@as(u32, 5), parsed.value.n);
}

test "deserialize: enums from string scalars" {
    if (comptime !build_options.lang_yaml) return error.SkipZigTest;
    const Color = enum { red, green, blue };
    const S = struct { c: Color };
    const parsed = try parseFromSlice(S, testing.allocator, "c: green\n", .yaml, .{});
    defer parsed.deinit();
    try testing.expectEqual(Color.green, parsed.value.c);

    try testing.expectError(error.InvalidEnum, parseFromSlice(S, testing.allocator, "c: mauve\n", .yaml, .{}));
}

test "deserialize: strict unknown fields and missing required fields error" {
    if (comptime !build_options.lang_yaml) return error.SkipZigTest;
    const S = struct { a: u8, b: u8 };
    try testing.expectError(
        error.UnknownField,
        parseFromSlice(S, testing.allocator, "a: 1\nb: 2\nx: 3\n", .yaml, .{ .ignore_unknown_fields = false }),
    );
    try testing.expectError(
        error.MissingField,
        parseFromSlice(S, testing.allocator, "a: 1\n", .yaml, .{}),
    );
}

test "deserialize: zon enum literals and toml scalars" {
    if (comptime !(build_options.lang_zon and build_options.lang_toml)) return error.SkipZigTest;
    const Mode = enum { fast, slow };
    const S = struct { mode: Mode, n: u32 };

    // ZON: a `.field = value` struct, `.fast` an enum literal.
    const z = try parseFromSlice(S, testing.allocator, ".{ .mode = .fast, .n = 3 }", .zon, .{});
    defer z.deinit();
    try testing.expectEqual(Mode.fast, z.value.mode);
    try testing.expectEqual(@as(u32, 3), z.value.n);

    // TOML: enums come from string scalars.
    const t = try parseFromSlice(S, testing.allocator, "mode = \"slow\"\nn = 9\n", .toml, .{});
    defer t.deinit();
    try testing.expectEqual(Mode.slow, t.value.mode);
    try testing.expectEqual(@as(u32, 9), t.value.n);
}

test "deserialize: fixed-size array requires an exact-length sequence" {
    if (comptime !build_options.lang_yaml) return error.SkipZigTest;
    const S = struct { rgb: [3]u8 };
    const ok = try parseFromSlice(S, testing.allocator, "rgb: [1, 2, 3]\n", .yaml, .{});
    defer ok.deinit();
    try testing.expectEqual([3]u8{ 1, 2, 3 }, ok.value.rgb);

    try testing.expectError(error.UnexpectedType, parseFromSlice(S, testing.allocator, "rgb: [1, 2]\n", .yaml, .{}));
}