fig-sys 5.0.0

FFI bindings and native library for fig (the comment-preserving JSON/YAML/TOML/… config engine). Used by the `fig` crate.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
//! The canonical-form printer: a total, 1:1 text encoding of the AST.
//! (Formerly "native"; the human-facing `fig` authoring dialect is separate — see
//! src/languages/fig/DESIGN.md.)
//!
//! Unlike the format printers (JSON/YAML/TOML/ZON), this one is a *bijection*
//! with the AST — every `Node.Kind` arm and the YAML reference layer (anchors,
//! tags, aliases) has an unambiguous surface form, so any AST round-trips
//! through it unchanged. It is the default/debug representation and the
//! comparison oracle: canonicalize two documents to canonical text and `strcmp`.
//!
//! Grammar (informal):
//!   node      ::= prefix* value
//!   prefix    ::= '&' name  |  '!' tagtext        (anchor / tag, space-separated)
//!   value     ::= 'null' | 'true' | 'false'
//!               | string                          ("…", JSON escapes)
//!               | number                          (raw lexeme, see `number`)
//!               | '@' extkind ' ' string          (extended scalar)
//!               | '[' (node (',' node)*)? ']'     (sequence)
//!               | '{' (node ':' node (',' …)*)? '}'  (mapping; keys are nodes)
//!               | '*' name                        (alias)
//!
//! A `number`'s `kind` (integer vs float) is normally implied by its lexeme. On
//! the rare node whose stored kind disagrees with the lexeme (constructible via
//! `AST.Builder.addNumberRaw`), a `~i`/`~f` sigil pins it. Common data never
//! triggers it.

const Printer = @This();
const std = @import("std");
const AST = @import("../ast/ast.zig");
const json_string = @import("../util/json_string.zig");
const Writer = std.Io.Writer;

/// The canonical encoding is total over every AST *node kind* — it rejects no
/// variant. The only failures are the underlying writer's and `NestingTooDeep`,
/// a recursion guard: a pathologically nested AST (e.g. a fuzzer feeding the
/// oracle, or a hand-built `Builder` tree) would otherwise overflow the stack.
/// `max_depth` matches the canonical parser's guard, so any AST the parser accepts
/// prints without hitting it.
pub const Error = Writer.Error || error{NestingTooDeep};

/// Maximum container-nesting depth, shared with `canonical/parser.zig`. Bounds the
/// recursion in both directions of the bijection.
pub const max_depth = 512;

writer: *Writer,
ast: *const AST,

/// Spaces per indentation level. The canonical encoding is an oracle —
/// one document has exactly one spelling — so this is a fixed constant, not a
/// knob: a configurable canonical form would defeat the comparison oracle.
const indent_width = 2;

/// Print the whole AST to `writer`, with a trailing newline, and flush.
pub fn print(writer: *Writer, ast: *const AST) Error!void {
    var p: Printer = .{ .writer = writer, .ast = ast };
    try p.leadingComments(ast.leadingCommentAnchor(ast.root), 0);
    try p.node(ast.root, 0);
    // A block container root emitted its own trailing beside its opening
    // delimiter; a scalar root's, or an inline `{}`/`[]` root's, goes here.
    if (!p.isBlockContainer(ast.root)) try p.trailingComment(ast.root);
    // A container root emits its own dangling run inside its closing delimiter
    // (see `container`); a non-container root has no body for EOF orphans to
    // live in, so they print here, one per line, after the value.
    if (!p.isContainer(ast.root)) {
        for (ast.comments(ast.root).dangling) |c| {
            try writer.writeByte('\n');
            try p.writeComment(c);
        }
    }
    try writer.writeByte('\n');
    try writer.flush();
}

/// Print the subtree rooted at `id`. Adds no trailing newline and does not
/// flush (used for partial renders and by `print`).
pub fn printNode(writer: *Writer, ast: *const AST, id: AST.Node.Id, depth: usize) Error!void {
    var p: Printer = .{ .writer = writer, .ast = ast };
    try p.node(id, depth);
}

fn node(self: *Printer, id: AST.Node.Id, depth: usize) Error!void {
    try self.prefixes(id);
    const n = self.ast.nodes[id];
    switch (n.kind) {
        .null_ => try self.writer.writeAll("null"),
        .boolean => |value| try self.writer.writeAll(if (value) "true" else "false"),
        .number => |value| try self.number(value),
        .string => |value| try json_string.writeQuoted(self.writer, value),
        .extended => |value| {
            try self.writer.writeByte('@');
            try self.writer.writeAll(@tagName(value.kind));
            try self.writer.writeByte(' ');
            try json_string.writeQuoted(self.writer, value.text);
        },
        .alias => |name| {
            try self.writer.writeByte('*');
            try self.writer.writeAll(name);
        },
        .sequence => |first_child| try self.container(id, '[', ']', first_child, depth),
        .mapping => |first_child| try self.container(id, '{', '}', first_child, depth),
        .keyvalue => |kv| {
            try self.node(kv.key, depth);
            try self.writer.writeAll(": ");
            // A comment between the `:` and the value is the value's leading
            // comment (the parser's `claimLeading(value)`), and it stays
            // there: a block comment on the line, a line comment ending it
            // with the value on the next. An entry's leading comments proper
            // are on its key, written by the container above the line.
            for (self.ast.comments(kv.value).leading) |c| {
                try self.writeComment(c);
                switch (c.style) {
                    .block => try self.writer.writeByte(' '),
                    .line => {
                        try self.writer.writeByte('\n');
                        try self.writeIndent(depth + 1);
                    },
                }
            }
            try self.node(kv.value, depth);
        },
    }
}

/// Emit the YAML reference-layer prefixes attached to this node id: an anchor
/// (`&name `) and/or a tag (`!!str `). Both are stored in side-tables that are
/// empty for non-YAML documents, so the length guard short-circuits there.
fn prefixes(self: *Printer, id: AST.Node.Id) Error!void {
    const a = self.ast;
    if (id < a.node_anchors.len) if (a.node_anchors[id]) |name| {
        try self.writer.writeByte('&');
        try self.writer.writeAll(name);
        try self.writer.writeByte(' ');
    };
    // A `.text` tag prints verbatim (leading `!` included, e.g. `!!str`); a
    // normalized `.kind` tag (fig/builder origin) prints as its core shorthand.
    if (id < a.node_tags.len) if (a.node_tags[id]) |tag| {
        try self.writer.writeAll(switch (tag) {
            .text => |t| t,
            .kind => |k| switch (k) {
                .null_ => "!!null",
                .boolean => "!!bool",
                .string => "!!str",
                .integer => "!!int",
                .float => "!!float",
                .sequence => "!!seq",
                .mapping => "!!map",
            },
        });
        try self.writer.writeByte(' ');
    };
}

/// Render a number's raw lexeme verbatim. If the lexeme's implied kind disagrees
/// with the stored kind, prefix `~i`/`~f` so the parser can restore it exactly.
fn number(self: *Printer, value: AST.Node.Kind.Number) Error!void {
    if (impliedNumberKind(value.raw) != value.kind) {
        try self.writer.writeAll(if (value.kind == .float) "~f" else "~i");
    }
    try self.writer.writeAll(value.raw);
}

/// Classify a numeric lexeme the same way the JSON parser's `getNumber` does, so
/// a bare number round-trips to the same `kind`. Hex (`0x…`) is an integer; a
/// dot or an `e`/`E` exponent makes it a float; otherwise integer. Total (never
/// errors): a malformed lexeme that can't come from a printer is treated as the
/// nearest of the two.
pub fn impliedNumberKind(raw: []const u8) @TypeOf(@as(AST.Node.Kind.Number, undefined).kind) {
    const body = if (raw.len > 0 and (raw[0] == '+' or raw[0] == '-')) raw[1..] else raw;
    if (body.len >= 2 and body[0] == '0' and (body[1] == 'x' or body[1] == 'X')) return .integer;
    if (std.mem.indexOfScalar(u8, raw, '.') != null) return .float;
    if (std.mem.indexOfAny(u8, raw, "eE") != null) return .float;
    return .integer;
}

/// Sequences and mappings differ only in delimiters and in how each child
/// renders (a bare node vs. a `key: value`), the latter handled by `node`.
fn container(self: *Printer, node_id: AST.Node.Id, open: u8, close: u8, first_child: ?AST.Node.Id, depth: usize) Error!void {
    const dangling = self.ast.comments(node_id).dangling;
    // Only a truly empty container (no children, no trailing orphan comments)
    // prints inline; otherwise it opens a block so the dangling run has a home.
    // An inline one's trailing comment is written by whoever writes the line
    // — the parent, after the comma, as for a scalar — so that `[] // c,`
    // never happens, which the parser would read as a comment `c,`.
    if (first_child == null and dangling.len == 0) {
        try self.writer.writeByte(open);
        try self.writer.writeByte(close);
        return;
    }
    // Guard the recursion below; the inline fast path above never descends.
    if (depth >= max_depth) return error.NestingTooDeep;
    try self.writer.writeByte(open);
    // The container's own trailing comment rides the line it opened on.
    try self.trailingComment(node_id);
    try self.writer.writeByte('\n');
    var current_id = first_child;
    while (current_id) |id| {
        // A mapping child is a `keyvalue`: its leading comment sits above the
        // key, its trailing comment after the value. A sequence child is the
        // value node itself, so both anchors collapse to `id`.
        try self.leadingComments(self.ast.leadingCommentAnchor(id), depth + 1);
        try self.writeIndent(depth + 1);
        try self.node(id, depth + 1);
        current_id = self.ast.nodes[id].next_sibling;
        if (current_id != null) try self.writer.writeByte(',');
        // A block container child emitted its own trailing beside its opener;
        // a scalar's or an inline container's goes here, after the comma.
        const anchor = self.ast.trailingCommentAnchor(id);
        if (!self.isBlockContainer(anchor)) try self.trailingComment(anchor);
        try self.writer.writeByte('\n');
    }
    // Comments dangling at the end of the body (after the last child, or the
    // entire body of an otherwise-empty container).
    for (dangling) |c| {
        try self.writeIndent(depth + 1);
        try self.writeComment(c);
        try self.writer.writeByte('\n');
    }
    try self.writeIndent(depth);
    try self.writer.writeByte(close);
}

/// Whether `id` is a container node.
fn isContainer(self: *const Printer, id: AST.Node.Id) bool {
    return switch (self.ast.nodes[id].kind) {
        .sequence, .mapping => true,
        else => false,
    };
}

/// Whether `id` is a container that prints as a block — one with children or
/// dangling comments — and so emits its own trailing comment beside its
/// opening delimiter, rather than leaving it to whoever writes its line.
fn isBlockContainer(self: *const Printer, id: AST.Node.Id) bool {
    if (!self.isContainer(id)) return false;
    const first_child = switch (self.ast.nodes[id].kind) {
        .sequence => |first| first,
        .mapping => |first| first,
        else => unreachable,
    };
    return first_child != null or self.ast.comments(id).dangling.len != 0;
}

/// Emit a node's leading comments, one per line at `depth`, each terminated by a
/// newline (so the node's own indented line follows).
fn leadingComments(self: *Printer, id: AST.Node.Id, depth: usize) Error!void {
    for (self.ast.comments(id).leading) |c| {
        try self.writeIndent(depth);
        try self.writeComment(c);
        try self.writer.writeByte('\n');
    }
}

/// Emit a node's trailing comment (if any) after a leading space. No newline —
/// the caller closes the line.
fn trailingComment(self: *Printer, id: AST.Node.Id) Error!void {
    if (self.ast.comments(id).trailing) |c| {
        try self.writer.writeByte(' ');
        try self.writeComment(c);
    }
}

/// Render one comment in native syntax: `// text` for a line comment, `/* text
/// */` for a block. Mirrors the marker set the canonical parser accepts.
fn writeComment(self: *Printer, c: AST.Comment) Error!void {
    switch (c.style) {
        .line => {
            try self.writer.writeAll("//");
            if (c.text.len != 0) {
                try self.writer.writeByte(' ');
                try self.writer.writeAll(c.text);
            }
        },
        .block => {
            try self.writer.writeAll("/*");
            if (c.text.len != 0) {
                try self.writer.writeByte(' ');
                try self.writer.writeAll(c.text);
                try self.writer.writeByte(' ');
            }
            try self.writer.writeAll("*/");
        },
    }
}

fn writeIndent(self: *Printer, depth: usize) Error!void {
    try self.writer.splatByteAll(' ', depth * indent_width);
}

// Strings are quoted/escaped by the shared `json_string.writeQuoted` (the native
// parser's escape decoder accepts exactly that set).

// =======
// Testing
// =======

test "prints scalars, sequences, and mappings 1:1" {
    const Parser = @import("parser.zig");
    var ast = try Parser.parseAbstract(std.testing.allocator,
        \\{ "name": "fig", "port": 8080, "ratio": 1.0, "tags": ["a", true, null] }
    );
    defer ast.deinit();

    var out: Writer.Allocating = .init(std.testing.allocator);
    defer out.deinit();
    try print(&out.writer, &ast);
    try std.testing.expectEqualStrings(
        \\{
        \\  "name": "fig",
        \\  "port": 8080,
        \\  "ratio": 1.0,
        \\  "tags": [
        \\    "a",
        \\    true,
        \\    null
        \\  ]
        \\}
        \\
    , out.written());
}

test "prints empty containers inline" {
    const Parser = @import("parser.zig");
    var ast = try Parser.parseAbstract(std.testing.allocator, "{ \"a\": [], \"b\": {} }");
    defer ast.deinit();

    var out: Writer.Allocating = .init(std.testing.allocator);
    defer out.deinit();
    try print(&out.writer, &ast);
    try std.testing.expectEqualStrings(
        \\{
        \\  "a": [],
        \\  "b": {}
        \\}
        \\
    , out.written());
}

test "emits leading and trailing comments" {
    const a = std.testing.allocator;
    var b = AST.Builder.init(a);
    defer b.deinit();

    // { "name": "fig" } with a leading comment on the entry and a trailing
    // comment on its value, plus a block comment leading the whole document.
    const v_name = try b.addString("fig");
    try b.setComments(v_name, .{ .trailing = .{ .text = "inline", .style = .line } });
    const k_name = try b.addString("name");
    try b.setComments(k_name, .{ .leading = &.{.{ .text = "greeting", .style = .line }} });
    const root = try b.addMapping(&.{.{ .key = k_name, .value = v_name }});
    try b.setComments(root, .{ .leading = &.{.{ .text = "doc", .style = .block }} });

    var ast = try b.finish(root);
    defer ast.deinit();

    var out: Writer.Allocating = .init(a);
    defer out.deinit();
    try print(&out.writer, &ast);
    try std.testing.expectEqualStrings(
        \\/* doc */
        \\{
        \\  // greeting
        \\  "name": "fig" // inline
        \\}
        \\
    , out.written());
}

test "an empty container's trailing comment follows its comma" {
    const Parser = @import("parser.zig");
    var ast = try Parser.parseAbstract(std.testing.allocator,
        \\{ "a": {}, // e
        \\  "b": [], "c": 1 }
    );
    defer ast.deinit();

    var out: Writer.Allocating = .init(std.testing.allocator);
    defer out.deinit();
    try print(&out.writer, &ast);
    try std.testing.expectEqualStrings(
        \\{
        \\  "a": {}, // e
        \\  "b": [],
        \\  "c": 1
        \\}
        \\
    , out.written());
}

test "a value's leading comment stays between the colon and the value" {
    const Parser = @import("parser.zig");
    var ast = try Parser.parseAbstract(std.testing.allocator,
        \\{ "a": /* b */ 1, "c": // l
        \\ 2 }
    );
    defer ast.deinit();

    var out: Writer.Allocating = .init(std.testing.allocator);
    defer out.deinit();
    try print(&out.writer, &ast);
    try std.testing.expectEqualStrings(
        \\{
        \\  "a": /* b */ 1,
        \\  "c": // l
        \\    2
        \\}
        \\
    , out.written());
}

test "bounds nesting depth" {
    const a = std.testing.allocator;
    // Build `levels` nested one-element sequences around an empty innermost seq.
    const nest = struct {
        fn build(alloc: std.mem.Allocator, levels: usize) !AST {
            var b = AST.Builder.init(alloc);
            errdefer b.deinit();
            // Innermost holds a scalar so it isn't the empty-container fast path
            // (which prints inline and never recurses, escaping the guard).
            var id = try b.addSequence(&.{try b.addNull()}); // 1 level
            for (1..levels) |_| id = try b.addSequence(&.{id});
            const ast = try b.finish(id);
            b.deinit();
            return ast;
        }
    }.build;

    var out: Writer.Allocating = .init(a);
    defer out.deinit();

    // Exactly `max_depth` levels prints; one deeper is rejected, not a crash.
    var ok = try nest(a, max_depth);
    defer ok.deinit();
    try print(&out.writer, &ok);

    var too_deep = try nest(a, max_depth + 1);
    defer too_deep.deinit();
    try std.testing.expectError(error.NestingTooDeep, print(&out.writer, &too_deep));
}