//! Serialization diagnostics: a read-only pass that reports what a conversion
//! would silently lose.
//!
//! fig's printers degrade or drop data whenever the target format can't hold a
//! value or a comment natively — a TOML `null` vanishes, a datetime collapses to
//! a string, a block comment becomes a run of `#` lines, plain JSON drops every
//! comment. None of that is an error (the output is still valid), so it happens
//! quietly. `analyze` surfaces it: it walks the AST exactly as a printer would
//! and returns one `Warning` per lossy event, each tagged with a `cause` so a
//! consumer (the CLI, a binding) can choose which to show.
//!
//! This is a separate pass, NOT instrumentation threaded through the printers:
//! the printers stay untouched, and the format-capability knowledge lives in one
//! place here (reusing `Lossless.needsEnvelope`/`isUnrepresentable` for the
//! formats whose capability the lossless layer already models). The walk shape
//! mirrors `Lossless.lossyStrip` — same dotted/`[i]` path building — but emits
//! instead of removing.
//!
//! Scope is serialization only. Read-side lossiness (deserialize's
//! `ignore_unknown_fields`, YAML materialize dropping tags/anchors) is not
//! covered here; the model is additive if that is ever wanted.

const std = @import("std");
const Allocator = std.mem.Allocator;
const AST = @import("ast/ast.zig");
const Lossless = @import("lossless.zig");
const Writer = std.Io.Writer;

const ExtKind = AST.Node.Kind.Extended.ExtKind;
const Format = AST.SerializeFormat;

/// One thing a conversion to a given format would lose.
pub const Warning = struct {
    code: Code,
    cause: Cause,
    /// Dotted/`[i]` path to the affected node, relative to the analyzed root.
    /// Empty (`""`) means the document root itself. Arena-owned by `analyze`.
    path: []const u8,
    /// Small extra context — for `type_degraded`, the type the value collapses
    /// to ("string", "number", …); otherwise empty. Static (not arena-owned).
    note: []const u8 = "",

    pub const Code = enum {
        /// A carried comment is not emitted at all (the target/mode has no comment
        /// syntax, or it was explicitly stripped).
        comment_dropped,
        /// A block comment is rendered as a run of line comments (the target has
        /// no block-comment syntax).
        comment_style_degraded,
        /// A node is removed entirely because the target cannot represent it even
        /// degraded (today: a `null` bound for TOML).
        value_dropped,
        /// An `extended` or non-finite value is rendered as a poorer type (a
        /// datetime/enum as a string, a char as a number, `inf`/`nan` quoted).
        type_degraded,
    };

    /// Why the loss happens — so a consumer can keep or ignore each class.
    pub const Cause = enum {
        /// The target format inherently cannot represent it.
        format_limitation,
        /// A caller option forced it (e.g. `strip_comments`).
        explicit_option,
    };

    /// Write the default human-readable message (no trailing newline). Bindings
    /// may instead render their own text from the structured fields.
    pub fn render(self: Warning, writer: *Writer, format: Format) Writer.Error!void {
        const fmt = @tagName(format);
        switch (self.code) {
            .value_dropped => {
                try writer.print("dropped {s} value at ", .{self.note});
                try writeLoc(writer, self.path);
                try writer.print(" ({s} cannot represent it)", .{fmt});
            },
            .type_degraded => {
                try writer.writeAll("degraded value at ");
                try writeLoc(writer, self.path);
                try writer.print(" to {s} ({s} has no native type for it)", .{ self.note, fmt });
            },
            .comment_dropped => {
                if (self.cause == .explicit_option) {
                    try writer.writeAll("stripped comment at ");
                    try writeLoc(writer, self.path);
                } else {
                    try writer.writeAll("dropped comment at ");
                    try writeLoc(writer, self.path);
                    try writer.print(" ({s} has no comment syntax here)", .{fmt});
                }
            },
            .comment_style_degraded => {
                try writer.writeAll("block comment at ");
                try writeLoc(writer, self.path);
                try writer.print(" rendered as line comments ({s} has no block comments)", .{fmt});
            },
        }
    }
};

/// Write a node path for messages: backtick-quoted, or "the document root" for
/// the empty (root) path.
fn writeLoc(writer: *Writer, path: []const u8) Writer.Error!void {
    if (path.len == 0) {
        try writer.writeAll("the document root");
    } else {
        try writer.writeByte('`');
        try writer.writeAll(path);
        try writer.writeByte('`');
    }
}

/// What the consumer is asking the printer to do — mirrors the subset of
/// `AST.SerializeOptions` that changes what gets lost, plus the lossless flag
/// (which lives outside `SerializeOptions`).
pub const Options = struct {
    /// Pretty (multi-line) output. Comment-bearing formats with no compact
    /// comment form (JSON5/JSONC/ZON) drop comments when this is false.
    pretty: bool = true,
    /// The caller asked to drop comments. Every comment becomes a
    /// `comment_dropped`/`explicit_option` warning.
    strip_comments: bool = false,
    /// Lossless conversion is in effect: unrepresentable values are preserved
    /// through a `$fig` envelope, so value losses are suppressed. (Comment
    /// losses still apply — the envelope path still prints comments normally.)
    lossless: bool = false,
};

/// Walk the subtree rooted at `root_id` as it would be serialized to `format`
/// and collect every lossy event. Warnings (and their `path` strings) are
/// allocated in `arena`; the returned slice is owned by the caller's arena.
pub fn analyze(arena: Allocator, ast: *const AST, root_id: AST.Node.Id, format: Format, options: Options) Allocator.Error![]Warning {
    var c = Collector{ .ast = ast, .arena = arena, .format = format, .options = options };
    try c.walk(root_id, "");
    return c.warnings.toOwnedSlice(arena);
}

const Collector = struct {
    ast: *const AST,
    arena: Allocator,
    format: Format,
    options: Options,
    warnings: std.ArrayList(Warning) = .empty,

    fn add(self: *Collector, w: Warning) Allocator.Error!void {
        try self.warnings.append(self.arena, w);
    }

    /// Visit one node: check its own value + comments, then recurse into a
    /// container's children (building each child's path).
    fn walk(self: *Collector, id: AST.Node.Id, path: []const u8) Allocator.Error!void {
        try self.checkComments(id, path);
        try self.checkValue(id, path);
        switch (self.ast.nodes[id].kind) {
            .mapping => |first| {
                var child = first;
                while (child) |cid| : (child = self.ast.nodes[cid].next_sibling) {
                    const kv = self.ast.nodes[cid].kind.keyvalue;
                    const child_path = try self.keyPath(path, kv.key);
                    // A mapping entry's leading comment binds to its KEY node; its
                    // trailing/value losses ride the value node (checked in walk).
                    try self.checkComments(kv.key, child_path);
                    try self.walk(kv.value, child_path);
                }
            },
            .sequence => |first| {
                var child = first;
                var i: usize = 0;
                while (child) |cid| : (child = self.ast.nodes[cid].next_sibling) {
                    const child_path = try indexPath(self.arena, path, i);
                    i += 1;
                    try self.walk(cid, child_path);
                }
            },
            else => {},
        }
    }

    fn checkValue(self: *Collector, id: AST.Node.Id, path: []const u8) Allocator.Error!void {
        // `path.len == 0` uniquely identifies the node `analyze` was called
        // with (see `Warning.path`'s doc comment) — every recursive call below
        // builds a non-empty dotted/`[i]` child path, so this can't
        // false-positive on a deeply nested node.
        const loss = valueLoss(self.format, self.ast.nodes[id].kind, path.len == 0) orelse return;
        // Under lossless, unrepresentable values are enveloped, not lost.
        if (self.options.lossless) return;
        try self.add(.{ .code = loss.code, .cause = .format_limitation, .path = path, .note = loss.note });
    }

    fn checkComments(self: *Collector, id: AST.Node.Id, path: []const u8) Allocator.Error!void {
        const nc = self.ast.comments(id);
        if (nc.isEmpty()) return;
        // An explicit `strip_comments` is the user asking for the drop: that's the
        // actionable reason, so it wins even when the target also couldn't hold
        // the comment (a consumer filtering out `explicit_option` then correctly
        // stays silent — it's not a surprising loss).
        if (self.options.strip_comments) {
            try self.add(.{ .code = .comment_dropped, .cause = .explicit_option, .path = path });
            return;
        }
        // Otherwise, a format with no comment syntax here drops it outright.
        if (!commentsEmitted(self.format, self.options.pretty)) {
            try self.add(.{ .code = .comment_dropped, .cause = .format_limitation, .path = path });
            return;
        }
        if (!blockComments(self.format) and hasBlock(nc)) {
            try self.add(.{ .code = .comment_style_degraded, .cause = .format_limitation, .path = path });
        }
    }

    /// `parent.key` (or `key` at the root). A non-string key shows as `?` (only
    /// its value can be a loss target, so the key text is a hint).
    fn keyPath(self: *Collector, parent: []const u8, key_id: AST.Node.Id) Allocator.Error![]const u8 {
        const name = switch (self.ast.nodes[key_id].kind) {
            .string => |s| s,
            else => "?",
        };
        if (parent.len == 0) return self.arena.dupe(u8, name);
        return std.fmt.allocPrint(self.arena, "{s}.{s}", .{ parent, name });
    }
};

fn indexPath(arena: Allocator, parent: []const u8, i: usize) Allocator.Error![]const u8 {
    return std.fmt.allocPrint(arena, "{s}[{d}]", .{ parent, i });
}

// ── Capability table ────────────────────────────────────────────────────────
//
// The single source of truth for what each target format can hold. Keyed on the
// full 7-way `SerializeFormat` (not the coarser `Lossless.Target`) because the
// JSON family splits: plain JSON drops comments and quotes non-finite floats,
// while JSON5 keeps comments and has native `Infinity`/`NaN`, and JSONC keeps
// comments but still quotes non-finite. For the three formats whose type system
// the lossless layer already models exactly (YAML/TOML/ZON), value capability is
// delegated to `Lossless.needsEnvelope`/`isUnrepresentable`.

const Loss = struct { code: Warning.Code, note: []const u8 };

/// The value loss serializing `kind` to `format` would cause, or null if none.
/// Lossless suppression is the caller's concern (`checkValue`). `is_root`
/// distinguishes the node `analyze` was called with from a nested descendant —
/// only fig's root-position rule (below) depends on it; every other branch
/// ignores it.
fn valueLoss(format: Format, kind: AST.Node.Kind, is_root: bool) ?Loss {
    switch (format) {
        // Canonical is the lossless oracle: every kind round-trips.
        .canonical => return null,
        // fig natively represents every kind EXCEPT a scalar/null/extended/alias
        // sitting at the document root: the fig dialect's root must be a map or
        // a sequence (see `src/languages/fig/printer.zig`'s `root`, which
        // hard-errors with `FigUnrepresentableRoot` rather than emit
        // non-conforming output for exactly this case). Everywhere else, the
        // `extended` scalars that lack a bare spelling (char, enum atom,
        // non-finite float) round-trip losslessly via an explicit
        // `: char`/`: enum`/`: float` annotation — a syntax choice, not a value
        // loss (see `writeExtended`).
        .fig => {
            if (is_root) switch (kind) {
                .mapping, .sequence => return null,
                else => return .{ .code = .value_dropped, .note = rootScalarNote(kind) },
            };
            return null;
        },
        // Plain JSON / JSONC: no datetimes, enums, chars; non-finite floats quote.
        .json, .jsonc => switch (kind) {
            .extended => |e| return .{ .code = .type_degraded, .note = degradedNote(format, e.kind) },
            else => return null,
        },
        // JSON5 additionally has native non-finite floats.
        .json5 => switch (kind) {
            .extended => |e| {
                if (e.kind == .number_special) return null;
                return .{ .code = .type_degraded, .note = degradedNote(format, e.kind) };
            },
            else => return null,
        },
        // YAML/TOML/ZON: defer to the lossless capability model.
        .yaml, .toml, .zon => {
            const target: Lossless.Target = switch (format) {
                .yaml => .yaml,
                .toml => .toml,
                .zon => .zon,
                else => unreachable,
            };
            if (Lossless.isUnrepresentable(target, kind)) return .{ .code = .value_dropped, .note = dropNote(kind) };
            if (Lossless.needsEnvelope(target, kind)) {
                // `needsEnvelope` is true here only for `extended` kinds (a `null`
                // is either representable or already caught by `isUnrepresentable`).
                return switch (kind) {
                    .extended => |e| .{ .code = .type_degraded, .note = degradedNote(format, e.kind) },
                    else => null,
                };
            }
            return null;
        },
        // XML has no number/boolean/datetime/enum/char primitive of its own —
        // every one of these collapses to plain element/attribute text the
        // moment it leaves the AST (see `languages/xml/printer.zig`'s
        // `writeScalarValue`). `null`/`string`/`mapping`/`sequence` all have a
        // direct, lossless XML shape (empty element, text-only element,
        // nested element, repeated elements).
        .xml => switch (kind) {
            .boolean, .number => return .{ .code = .type_degraded, .note = "string" },
            .extended => |e| return .{ .code = .type_degraded, .note = degradedNote(format, e.kind) },
            else => return null,
        },
    }
}

/// The type an `extended` value collapses to in `format` — matches what each
/// printer actually writes (see `src/*/printer.zig`).
fn degradedNote(format: Format, ext: ExtKind) []const u8 {
    return switch (ext) {
        .enum_literal,
        .offset_datetime,
        .local_datetime,
        .local_date,
        .local_time,
        => "string",
        // A char literal's text is its codepoint: most printers emit it as a
        // number; TOML as an integer; XML as plain text (it has no number type
        // at all). (fig never reaches here — it re-emits the char natively via
        // `: char`, so `valueLoss(.fig, …)` returns null.)
        .char_literal => switch (format) {
            .toml => "integer",
            .xml => "string",
            else => "number",
        },
        // Non-finite floats quote in plain JSON/JSONC; elsewhere they degrade to
        // a plain string scalar (YAML/ZON/XML — JSON5 keeps them natively,
        // handled before this is reached).
        .number_special => switch (format) {
            .json, .jsonc => "quoted string",
            else => "string",
        },
    };
}

/// Describes `kind` for the `value_dropped` note when it is fig's unique
/// root-position loss (see `valueLoss`'s `.fig` branch). Never called for
/// `.mapping`/`.sequence` (the caller already excludes those) or the
/// container-only `.keyvalue` kind, which never reaches a value-loss check.
fn rootScalarNote(kind: AST.Node.Kind) []const u8 {
    return switch (kind) {
        .null_ => "null",
        .boolean => "boolean",
        .number => "number",
        .string => "string",
        .alias => "alias",
        .extended => |e| switch (e.kind) {
            .enum_literal => "enum",
            .char_literal => "char",
            .number_special => "number",
            .offset_datetime, .local_datetime, .local_date, .local_time => "datetime",
        },
        .mapping, .sequence, .keyvalue => unreachable,
    };
}

fn dropNote(kind: AST.Node.Kind) []const u8 {
    return switch (kind) {
        .null_ => "null",
        else => "",
    };
}

/// Whether `format` emits comments at all in this mode. Plain JSON never does;
/// JSON5/JSONC/ZON only in pretty (multi-line) output; YAML/TOML/canonical
/// always. XML never does — the reader has no comment syntax, so an XML-sourced
/// AST never carries any, and the printer emits none.
fn commentsEmitted(format: Format, pretty: bool) bool {
    return switch (format) {
        .json, .xml => false,
        .json5, .jsonc, .zon => pretty,
        .yaml, .toml, .canonical, .fig => true,
    };
}

/// Whether `format` has block-comment syntax (`/* … */`). Only the JSON5 family
/// and canonical do; YAML/TOML/ZON/fig degrade a block comment to a line run;
/// XML has no comments at all (moot — `commentsEmitted` already excludes it).
fn blockComments(format: Format) bool {
    return switch (format) {
        .json5, .jsonc, .canonical => true,
        .json, .yaml, .toml, .zon, .fig, .xml => false,
    };
}

fn hasBlock(nc: AST.NodeComments) bool {
    for (nc.leading) |c| if (c.style == .block) return true;
    for (nc.dangling) |c| if (c.style == .block) return true;
    if (nc.trailing) |t| if (t.style == .block) return true;
    return false;
}

// ── Tests ───────────────────────────────────────────────────────────────────

const testing = std.testing;

/// Build a small AST via the Builder, run `analyze`, and return the warnings in
/// an arena the caller owns.
fn analyzeBuilt(arena: Allocator, build: anytype, format: Format, options: Options) ![]Warning {
    var b = AST.Builder.init(arena);
    const root = try build(&b);
    var ast = try b.finish(root);
    return analyze(arena, &ast, ast.root, format, options);
}

test "null dropped for TOML, not for JSON/YAML" {
    var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();

    const Build = struct {
        fn f(b: *AST.Builder) !AST.Node.Id {
            const k = try b.addString("a");
            const v = try b.addNull();
            return b.addMapping(&.{.{ .key = k, .value = v }});
        }
    };

    const toml = try analyzeBuilt(arena, Build.f, .toml, .{});
    try testing.expectEqual(@as(usize, 1), toml.len);
    try testing.expectEqual(Warning.Code.value_dropped, toml[0].code);
    try testing.expectEqualStrings("a", toml[0].path);

    const json = try analyzeBuilt(arena, Build.f, .json, .{});
    try testing.expectEqual(@as(usize, 0), json.len);
}

test "datetime degrades to JSON, canonical to TOML" {
    var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();

    const Build = struct {
        fn f(b: *AST.Builder) !AST.Node.Id {
            return b.addExtended(.offset_datetime, "1979-05-27T07:32:00Z");
        }
    };

    const json = try analyzeBuilt(arena, Build.f, .json, .{});
    try testing.expectEqual(@as(usize, 1), json.len);
    try testing.expectEqual(Warning.Code.type_degraded, json[0].code);
    try testing.expectEqualStrings("string", json[0].note);

    const toml = try analyzeBuilt(arena, Build.f, .toml, .{});
    try testing.expectEqual(@as(usize, 0), toml.len);
}

test "non-finite float degrades to JSON but not JSON5" {
    var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();

    const Build = struct {
        fn f(b: *AST.Builder) !AST.Node.Id {
            return b.addExtended(.number_special, "Infinity");
        }
    };

    const json = try analyzeBuilt(arena, Build.f, .json, .{});
    try testing.expectEqual(@as(usize, 1), json.len);
    try testing.expectEqualStrings("quoted string", json[0].note);

    const json5 = try analyzeBuilt(arena, Build.f, .json5, .{});
    try testing.expectEqual(@as(usize, 0), json5.len);
}

test "char literal is native to fig, degrades to a number elsewhere" {
    var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();

    // Wrapped in a mapping (not bare at the root) so this exercises the
    // char-literal KIND's capability, not fig's separate root-position rule
    // (see "fig root scalar is flagged as unrepresentable" below).
    const Build = struct {
        fn f(b: *AST.Builder) !AST.Node.Id {
            const k = try b.addString("a");
            const v = try b.addExtended(.char_literal, "65"); // 'A'
            return b.addMapping(&.{.{ .key = k, .value = v }});
        }
    };

    // fig re-emits it via `: char =` — no loss.
    const fig = try analyzeBuilt(arena, Build.f, .fig, .{});
    try testing.expectEqual(@as(usize, 0), fig.len);

    // JSON has no char; it collapses to the codepoint as a number.
    const json = try analyzeBuilt(arena, Build.f, .json, .{});
    try testing.expectEqual(@as(usize, 1), json.len);
    try testing.expectEqual(Warning.Code.type_degraded, json[0].code);
    try testing.expectEqualStrings("number", json[0].note);
}

test "fig root scalar is flagged as unrepresentable, a root map/sequence is not" {
    var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();

    // A bare number sitting at the document root: the fig dialect's root must
    // be a map or a sequence (see `languages/fig/printer.zig`'s `root`, which
    // hard-errors with `FigUnrepresentableRoot` for exactly this AST shape) —
    // this is defense in depth so `--strict` catches it independent of the
    // printer's hard error.
    const ScalarRoot = struct {
        fn f(b: *AST.Builder) !AST.Node.Id {
            return b.addNumberRaw("42", false);
        }
    };
    const scalar = try analyzeBuilt(arena, ScalarRoot.f, .fig, .{});
    try testing.expectEqual(@as(usize, 1), scalar.len);
    try testing.expectEqual(Warning.Code.value_dropped, scalar[0].code);
    try testing.expectEqual(Warning.Cause.format_limitation, scalar[0].cause);
    try testing.expectEqualStrings("", scalar[0].path);
    try testing.expectEqualStrings("number", scalar[0].note);

    // Other formats have no such rule — a bare JSON number root is fine.
    const json = try analyzeBuilt(arena, ScalarRoot.f, .json, .{});
    try testing.expectEqual(@as(usize, 0), json.len);

    // A root mapping is unaffected — still fully representable.
    const MapRoot = struct {
        fn f(b: *AST.Builder) !AST.Node.Id {
            const k = try b.addString("a");
            const v = try b.addNumberRaw("1", false);
            return b.addMapping(&.{.{ .key = k, .value = v }});
        }
    };
    const map_fig = try analyzeBuilt(arena, MapRoot.f, .fig, .{});
    try testing.expectEqual(@as(usize, 0), map_fig.len);
}

test "lossless suppresses value warnings" {
    var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();

    const Build = struct {
        fn f(b: *AST.Builder) !AST.Node.Id {
            const k = try b.addString("a");
            const v = try b.addNull();
            return b.addMapping(&.{.{ .key = k, .value = v }});
        }
    };

    const toml = try analyzeBuilt(arena, Build.f, .toml, .{ .lossless = true });
    try testing.expectEqual(@as(usize, 0), toml.len);
}

test "comment dropped, style-degraded, and explicitly stripped" {
    var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();

    const Build = struct {
        fn f(b: *AST.Builder) !AST.Node.Id {
            const k = try b.addString("a");
            const v = try b.addNumberRaw("1", false);
            const m = try b.addMapping(&.{.{ .key = k, .value = v }});
            // A block comment leads the entry (binds to the key node).
            try b.setComments(k, .{ .leading = &.{.{ .text = "note", .style = .block }} });
            return m;
        }
    };

    // YAML emits comments but has no block syntax → style degraded.
    const yaml = try analyzeBuilt(arena, Build.f, .yaml, .{});
    try testing.expectEqual(@as(usize, 1), yaml.len);
    try testing.expectEqual(Warning.Code.comment_style_degraded, yaml[0].code);
    try testing.expectEqualStrings("a", yaml[0].path);

    // Plain JSON has no comments at all → dropped, format limitation.
    const json = try analyzeBuilt(arena, Build.f, .json, .{});
    try testing.expectEqual(@as(usize, 1), json.len);
    try testing.expectEqual(Warning.Code.comment_dropped, json[0].code);
    try testing.expectEqual(Warning.Cause.format_limitation, json[0].cause);

    // JSON5 keeps block comments → no warning.
    const json5 = try analyzeBuilt(arena, Build.f, .json5, .{});
    try testing.expectEqual(@as(usize, 0), json5.len);

    // Explicit strip on a format that would otherwise keep it → explicit_option.
    const stripped = try analyzeBuilt(arena, Build.f, .json5, .{ .strip_comments = true });
    try testing.expectEqual(@as(usize, 1), stripped.len);
    try testing.expectEqual(Warning.Code.comment_dropped, stripped[0].code);
    try testing.expectEqual(Warning.Cause.explicit_option, stripped[0].cause);

    // Explicit strip wins even when the target ALSO can't hold comments: the user
    // asked for the drop, so it's `explicit_option`, not `format_limitation`.
    const stripped_json = try analyzeBuilt(arena, Build.f, .json, .{ .strip_comments = true });
    try testing.expectEqual(@as(usize, 1), stripped_json.len);
    try testing.expectEqual(Warning.Cause.explicit_option, stripped_json[0].cause);
}

test "path shapes match lossyStrip (dotted keys, bracket indices)" {
    var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();

    // { b: null, c: [1, null], d: { e: null } } → toml
    const Build = struct {
        fn f(b: *AST.Builder) !AST.Node.Id {
            const bk = try b.addString("b");
            const bv = try b.addNull();

            const c1 = try b.addNumberRaw("1", false);
            const c2 = try b.addNull();
            const cseq = try b.addSequence(&.{ c1, c2 });
            const ck = try b.addString("c");

            const ek = try b.addString("e");
            const ev = try b.addNull();
            const inner = try b.addMapping(&.{.{ .key = ek, .value = ev }});
            const dk = try b.addString("d");

            return b.addMapping(&.{
                .{ .key = bk, .value = bv },
                .{ .key = ck, .value = cseq },
                .{ .key = dk, .value = inner },
            });
        }
    };

    const w = try analyzeBuilt(arena, Build.f, .toml, .{});
    try testing.expectEqual(@as(usize, 3), w.len);
    try testing.expectEqualStrings("b", w[0].path);
    try testing.expectEqualStrings("c[1]", w[1].path);
    try testing.expectEqualStrings("d.e", w[2].path);
}
