twig-sys 3.7.0

FFI bindings and native library for Twig (the Djot/Markdown/HTML/XML document engine). Used by the `twig-doc` 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
//! Registry-wide engine contracts. Corpora belong to conformance readers.
const std = @import("std");
const testing = std.testing;
const format = @import("../format.zig");
const Document = @import("../document.zig");
const Span = @import("../span.zig");
const Editor = @import("../ast/editor.zig").Editor;
const AST = @import("../ast/ast.zig");
const select = @import("../ast/select.zig");
const Syntax = @import("../syntax.zig").Syntax;

fn expectSpan(span: Span, len: usize) !void {
    try testing.expect(span.start <= span.end);
    try testing.expect(span.end <= len);
}

fn expectColumns(doc: Document) !void {
    try testing.expectEqual(doc.ast.nodes.len, doc.node_spans.len);
    try testing.expectEqual(doc.ast.nodes.len, doc.node_content_spans.len);
    try testing.expect(doc.node_spelling.len <= doc.ast.nodes.len);
    try testing.expect(doc.node_marker_spans.len <= doc.ast.nodes.len);
    try testing.expect(doc.attrs_spans.len <= doc.ast.attrs.len);
    for (doc.node_spans) |span| try expectSpan(span, doc.source.len);
    for (doc.node_content_spans) |maybe| {
        if (maybe) |span| try expectSpan(span, doc.source.len);
    }
    for (doc.node_marker_spans, 0..) |maybe, id| {
        if (maybe) |span| {
            try expectSpan(span, doc.source.len);
            // Leading markers also cover headings, quotes, and AsciiDoc admonitions.
            try testing.expect(switch (doc.ast.nodes[id].kind) {
                .list_item, .task_list_item, .definition_list_item, .heading, .block_quote => true,
                .container => |c| c.form == .block_fenced and doc.containerOrigin(@intCast(id)) == .directive,
                else => false,
            });
        }
    }
    for (doc.attrs_spans, 0..) |maybe, id| {
        if (maybe) |span| {
            try expectSpan(span, doc.source.len);
            var referenced = false;
            for (doc.ast.nodes) |node| {
                if (node.attrs) |attrs_id| {
                    if (attrs_id == id) referenced = true;
                }
            }
            try testing.expect(referenced);
        }
    }
}

fn expectSample(entry: format.Entry, sample: []const u8) !void {
    const config: format.ParseConfig = .{};
    var first = try entry.parse(&config, testing.allocator, sample);
    defer first.deinit();
    try testing.expectEqualStrings(sample, first.doc.source);
    try expectColumns(first.doc);
    if (entry.serializeCanonical) |serialize| {
        const printed = try serialize(testing.allocator, &first);
        defer testing.allocator.free(printed);
        errdefer std.debug.print("\n--- canonical output ---\n{s}\n", .{printed});
        var second = try entry.parse(&config, testing.allocator, printed);
        defer second.deinit();
        try expectColumns(second.doc);
        try testing.expect(first.doc.ast.eql(second.doc.ast));
    }
    if (entry.syntax.authorable()) {
        var editor = try Editor.init(testing.allocator, sample, &config, entry.parseToAst, entry.syntax);
        defer editor.deinit();
        try testing.expect(first.doc.ast.eql(editor.astView().*));
        try expectColumns(editor.splicer.doc);
        try editor.splicer.replaceAtSpan(Span.init(0, 0), "");
        try testing.expectEqualStrings(sample, editor.sourceBytes());
        try testing.expectEqualStrings(sample, editor.splicer.doc.source);
        try testing.expect(first.doc.ast.eql(editor.astView().*));
        try expectColumns(editor.splicer.doc);
    }
}

/// A specials run every format's literal renderer must carry across a
/// reparse: the inline metacharacters, a block opener at column zero, HTML's
/// three, and a backslash.
const literal_specials = "# *a* _b_ `c` [d] <e> & \\ ~f~";

/// What `Editor` assumes of a declared `renderText`: a run inserted through it
/// at the head of an empty document reparses to visible text equal to the
/// run, with no markup minted — the promise `insertLiteral` makes over every
/// format that carries the renderer.
fn expectRenderText(entry: format.Entry) !void {
    const config: format.ParseConfig = .{};
    var editor = try Editor.init(testing.allocator, "", &config, entry.parseToAst, entry.syntax);
    defer editor.deinit();
    try editor.insertLiteral(0, literal_specials);
    errdefer std.debug.print("\n--- literal source ---\n{s}\n", .{editor.sourceBytes()});
    var visible: std.ArrayList(u8) = .empty;
    defer visible.deinit(testing.allocator);
    for (editor.astView().nodes) |n| switch (n.kind) {
        .str => |t| try visible.appendSlice(testing.allocator, t),
        .inline_mark, .text_leaf, .link, .image, .raw_inline, .heading => return error.LiteralMintedMarkup,
        else => {},
    };
    try testing.expectEqualStrings(literal_specials, visible.items);
}

/// What `Editor` assumes of a declared `renderBlock`: a heading fragment it
/// builds — a `heading` over a `str` — prints as source the format parses
/// back to a heading of that level over that text, which is what lets
/// `setBlock` splice the print in. And the same of every other fragment a
/// gesture builds where its alphabet is missing: a quote and a list over
/// paragraphs, a code block, a link and an image over text — each reparses
/// to its kind with its text intact.
fn expectRenderBlock(entry: format.Entry) !void {
    const render = entry.syntax.renderBlock.?;
    var b = AST.Builder.init(testing.allocator);
    defer b.deinit();
    const text = try b.addLeaf(.{ .str = "title" });
    const heading = try b.addContainer(.{ .heading = .{ .level = 2 } }, &.{text});
    const view = b.view(heading);
    var out: std.Io.Writer.Allocating = .init(testing.allocator);
    defer out.deinit();
    try render(testing.allocator, &view, heading, &out.writer);
    errdefer std.debug.print("\n--- fragment source ---\n{s}\n", .{out.written()});
    const config: format.ParseConfig = .{};
    var parsed = try entry.parse(&config, testing.allocator, out.written());
    defer parsed.deinit();
    const nodes = parsed.doc.ast.nodes;
    var found = false;
    for (nodes) |n| switch (n.kind) {
        .heading => |h| {
            try testing.expectEqual(@as(u32, 2), h.level);
            const child = n.first_child orelse return error.EmptyHeading;
            try testing.expectEqualStrings("title", nodes[child].kind.str);
            try testing.expect(nodes[child].next_sibling == null);
            found = true;
        },
        else => {},
    };
    try testing.expect(found);

    // The gesture fragments, each built the way its gesture builds it.
    var q = AST.Builder.init(testing.allocator);
    defer q.deinit();
    const quote = try q.addContainer(.block_quote, &.{
        try q.addContainer(.para, &.{try q.addLeaf(.{ .str = "one" })}),
        try q.addContainer(.para, &.{try q.addLeaf(.{ .str = "two" })}),
    });
    try expectFragmentReparses(entry, render, &q, quote, .block_quote, "onetwo");

    var l = AST.Builder.init(testing.allocator);
    defer l.deinit();
    const list = try l.addContainer(.{ .ordered_list = .{ .numbering = .decimal, .tight = true, .start = null } }, &.{
        try l.addContainer(.list_item, &.{try l.addContainer(.para, &.{try l.addLeaf(.{ .str = "one" })})}),
        try l.addContainer(.list_item, &.{try l.addContainer(.para, &.{try l.addLeaf(.{ .str = "two" })})}),
    });
    try expectFragmentReparses(entry, render, &l, list, .ordered_list, "onetwo");

    var c = AST.Builder.init(testing.allocator);
    defer c.deinit();
    const code = try c.addLeaf(.{ .code_block = .{ .lang = "zig", .text = "a < b\n" } });
    try expectFragmentReparses(entry, render, &c, code, .code_block, "a < b\n");

    var k = AST.Builder.init(testing.allocator);
    defer k.deinit();
    const link = try k.addContainer(.{ .link = .{ .destination = "https://x.dev/?a=1&b=2", .reference = null } }, &.{try k.addLeaf(.{ .str = "here" })});
    try expectFragmentReparses(entry, render, &k, link, .link, "here");

    var m = AST.Builder.init(testing.allocator);
    defer m.deinit();
    const image = try m.addContainer(.{ .image = .{ .destination = "cat.png", .reference = null } }, &.{try m.addLeaf(.{ .str = "a cat" })});
    try expectFragmentReparses(entry, render, &m, image, .image, "a cat");
}

/// The parse configs a per-table claim — `names_leaf_containers`,
/// `block_attrs`, `inline_attrs` — can be made under. A claim is a claim about
/// a TABLE, and Markdown has one table per option combination — `::name` is a
/// paragraph of colons without `ParseOptions.directives`, a `<div>` a raw
/// block without `html_elements` — so the check below asks every table the
/// registry row can produce for these, and parses each with the very config
/// that table came from. Every other row answers the same table for all
/// four, and is checked once.
const claim_configs = [_]format.ParseConfig{
    .{},
    .{ .markdown = .{ .directives = true } },
    .{ .markdown = .{ .html_elements = true } },
    .{ .markdown = .{ .directives = true, .html_elements = true } },
};

/// The three attributes every attribute claim is measured with: one per key
/// class the spellings distinguish, and a `data-` key for the rest.
const claim_attrs: AST.Attrs = .{ .entries = &.{
    .{ .key = "id", .value = "intro" },
    .{ .key = "class", .value = "lead" },
    .{ .key = "data-size", .value = "large" },
} };

/// Every key of `claim_attrs` is on `id`, values intact. A class is looked
/// for rather than compared, since a format may merge one it adds (a name
/// carried as a class) into the same value.
fn expectClaimAttrs(ast: *const AST, id: AST.Node.Id) !void {
    const a = ast.attrsOf(id);
    for (claim_attrs.entries) |kv| {
        const v = a.get(kv.key) orelse return error.AttrsLost;
        if (std.mem.eql(u8, kv.key, "class")) {
            try testing.expect(std.mem.indexOf(u8, v, kv.value.?) != null);
        } else {
            try testing.expectEqualStrings(kv.value.?, v);
        }
    }
}

/// What `Editor.setBlockAttrs` assumes of a table claiming
/// `Syntax.block_attrs`: a paragraph carrying `claim_attrs`, printed through
/// `renderBlock`, reparses to a paragraph carrying them (`native`), or to a
/// container whose SOLE CHILD is that paragraph and which carries them
/// (`wrapped`) — and the claim says which.
fn expectBlockAttrs(entry: format.Entry, cfg: *const format.ParseConfig, shape: @import("../syntax.zig").BlockAttrs) !void {
    const render = tableFor(entry, cfg).renderBlock.?;
    var b = AST.Builder.init(testing.allocator);
    defer b.deinit();
    const para = try b.addContainer(.para, &.{try b.addLeaf(.{ .str = "text" })});
    try b.setAttrs(para, claim_attrs);
    const view = b.view(para);
    var out: std.Io.Writer.Allocating = .init(testing.allocator);
    defer out.deinit();
    try render(testing.allocator, &view, para, &out.writer);
    errdefer std.debug.print("\n--- attributed block source ---\n{s}\n", .{out.written()});

    var parsed = try entry.parse(cfg, testing.allocator, out.written());
    defer parsed.deinit();
    const ast = &parsed.doc.ast;
    for (ast.nodes, 0..) |n, i| {
        if (n.kind != .para) continue;
        const id: AST.Node.Id = @intCast(i);
        switch (shape) {
            .native => return expectClaimAttrs(ast, id),
            .wrapped => {
                try testing.expect(n.next_sibling == null);
                for (ast.nodes, 0..) |p, j| {
                    if (p.kind != .container or p.first_child != id) continue;
                    try testing.expect(p.kind.container.form == .block_fenced);
                    return expectClaimAttrs(ast, @intCast(j));
                }
                return error.BlockNotWrapped;
            },
        }
    }
    return error.BlockDidNotReparse;
}

/// What `Editor.wrapRangeAttrs` assumes of a table claiming
/// `Syntax.inline_attrs`: an ANONYMOUS inline container carrying
/// `claim_attrs`, printed through `renderBlock` as the fragment root — which
/// is how the gesture prints it, spliced into a line of the document —
/// reparses to an inline container, anonymous or named `span`, carrying them.
fn expectInlineAttrs(entry: format.Entry, cfg: *const format.ParseConfig) !void {
    const render = tableFor(entry, cfg).renderBlock.?;
    var b = AST.Builder.init(testing.allocator);
    defer b.deinit();
    const span = try b.addContainer(.{ .container = .{ .name = "", .form = .inline_text } }, &.{try b.addLeaf(.{ .str = "text" })});
    try b.setAttrs(span, claim_attrs);
    const view = b.view(span);
    var out: std.Io.Writer.Allocating = .init(testing.allocator);
    defer out.deinit();
    try render(testing.allocator, &view, span, &out.writer);
    errdefer std.debug.print("\n--- attributed span source ---\n{s}\n", .{out.written()});

    var parsed = try entry.parse(cfg, testing.allocator, out.written());
    defer parsed.deinit();
    const ast = &parsed.doc.ast;
    for (ast.nodes, 0..) |n, i| {
        const c = switch (n.kind) {
            .container => |c| c,
            else => continue,
        };
        if (c.form != .inline_text) continue;
        if (c.name.len != 0 and !std.mem.eql(u8, c.name, "span")) continue;
        try expectClaimAttrs(ast, @intCast(i));
        const child = n.first_child orelse return error.SpanLostItsText;
        try testing.expectEqualStrings("text", ast.nodes[child].kind.str);
        return;
    }
    return error.SpanDidNotReparse;
}

/// The table `entry` holds for `cfg` — `format.syntaxForConfig` without the
/// `Format` round trip, since the harness already has the row in hand.
fn tableFor(entry: format.Entry, cfg: *const format.ParseConfig) *const Syntax {
    const pick = entry.syntaxFor orelse return entry.syntax;
    return pick(cfg);
}

/// What `Editor.insertDirective` assumes of a table claiming
/// `Syntax.names_leaf_containers`: a named LEAF CONTAINER carrying an
/// attribute, printed through `renderBlock`, reparses to a container that
/// still carries the name — as its own `name`, or as a `class` where the
/// format has nowhere else to put one (djot's fence is anonymous; AsciiDoc's
/// open block carries the name as its style) — with its attributes intact.
///
/// The name is `x-embed` and not the obvious `embed`, because `embed` is a
/// VOID element in HTML: the serializer writes `<embed src="…">` with no
/// closing tag, so the contract would pass without ever exercising the
/// `<name></name>` tag pair the gesture actually mints. A name no format
/// spells natively is what makes this a test of the generic path.
///
/// The name and the attributes are the whole claim, and the attributes only
/// where the spelling has room — see the field's doc, and AsciiDoc's `<<<`.
/// The reparsed `form` is not part of it: HTML leaves an unknown element
/// unclassified (`form = null`), which is the honest answer there and no
/// obstacle to the gesture. Nor is the label, which only Markdown and djot
/// keep.
fn expectNamedLeafContainer(entry: format.Entry, cfg: *const format.ParseConfig) !void {
    const render = tableFor(entry, cfg).renderBlock.?;
    var b = AST.Builder.init(testing.allocator);
    defer b.deinit();
    const label = try b.addLeaf(.{ .str = "Contents" });
    const root = try b.addContainer(
        .{ .container = .{ .name = "x-embed", .form = .block_leaf } },
        &.{label},
    );
    try b.setAttrs(root, .{ .entries = &.{.{ .key = "src", .value = "x.html" }} });
    const view = b.view(root);
    var out: std.Io.Writer.Allocating = .init(testing.allocator);
    defer out.deinit();
    try render(testing.allocator, &view, root, &out.writer);
    errdefer std.debug.print("\n--- directive fragment source ---\n{s}\n", .{out.written()});

    var parsed = try entry.parse(cfg, testing.allocator, out.written());
    defer parsed.deinit();
    const ast = &parsed.doc.ast;
    for (ast.nodes, 0..) |n, i| {
        const c = switch (n.kind) {
            .container => |c| c,
            else => continue,
        };
        const named = std.mem.eql(u8, c.name, "x-embed") or blk: {
            const class = ast.attrsOf(@intCast(i)).get("class") orelse break :blk false;
            break :blk std.mem.indexOf(u8, class, "x-embed") != null;
        };
        if (!named) continue;
        const src = ast.attrsOf(@intCast(i)).get("src") orelse return error.DirectiveLostItsAttrs;
        try testing.expectEqualStrings("x.html", src);
        return;
    }
    return error.DirectiveDidNotReparse;
}

/// Print `root` of `b` through `render`, parse the print, and find a node of
/// `tag` whose text is `text` — the reparse promise each gesture fragment
/// rests on. A link or image also has to read its destination back.
fn expectFragmentReparses(
    entry: format.Entry,
    render: @typeInfo(@FieldType(Syntax, "renderBlock")).optional.child,
    b: *const AST.Builder,
    root: AST.Node.Id,
    tag: std.meta.Tag(AST.Node.Kind),
    text: []const u8,
) !void {
    const view = b.view(root);
    var out: std.Io.Writer.Allocating = .init(testing.allocator);
    defer out.deinit();
    try render(testing.allocator, &view, root, &out.writer);
    errdefer std.debug.print("\n--- {s} fragment source ---\n{s}\n", .{ @tagName(tag), out.written() });
    const config: format.ParseConfig = .{};
    var parsed = try entry.parse(&config, testing.allocator, out.written());
    defer parsed.deinit();
    const ast = &parsed.doc.ast;
    for (ast.nodes, 0..) |n, i| {
        if (std.meta.activeTag(n.kind) != tag) continue;
        const got = try select.textOf(testing.allocator, ast, @intCast(i));
        defer testing.allocator.free(got);
        // Whether a listing's payload ends in a line end is the format's
        // convention (AsciiDoc's does not), not part of the promise.
        try testing.expectEqualStrings(std.mem.trimEnd(u8, text, "\n"), std.mem.trimEnd(u8, got, "\n"));
        switch (n.kind) {
            .link => |v| try testing.expectEqualStrings("https://x.dev/?a=1&b=2", v.destination.?),
            .image => |v| try testing.expectEqualStrings("cat.png", v.destination.?),
            .code_block => |v| try testing.expectEqualStrings("zig", v.lang.?),
            else => {},
        }
        return;
    }
    return error.FragmentDidNotReparse;
}

test "harness: every declared renderer keeps the engine's promise" {
    for (format.registry) |entry| {
        errdefer std.debug.print("\n{s}: renderer contract\n", .{@tagName(entry.id)});
        if (entry.syntax.renderText != null) try expectRenderText(entry);
        if (entry.syntax.renderBlock != null) try expectRenderBlock(entry);

        // The per-table claims, per table rather than per row — and each
        // table only once, since a row whose spelling does not move with the
        // config answers the same address for every entry in the list.
        var seen: [claim_configs.len]*const Syntax = undefined;
        var n: usize = 0;
        next: for (&claim_configs) |*cfg| {
            const t = tableFor(entry, cfg);
            for (seen[0..n]) |s| {
                if (s == t) continue :next;
            }
            seen[n] = t;
            n += 1;
            if (t.names_leaf_containers) try expectNamedLeafContainer(entry, cfg);
            if (t.block_attrs) |shape| try expectBlockAttrs(entry, cfg, shape);
            if (t.inline_attrs) try expectInlineAttrs(entry, cfg);
        }
    }
}

test "harness: every format declares samples and satisfies the engine contract" {
    for (format.registry) |entry| {
        if (entry.samples.len == 0) {
            std.debug.print("\n{s}: no harness samples declared\n", .{@tagName(entry.id)});
            return error.MissingSamples;
        }
        for (entry.samples, 0..) |sample, index| {
            errdefer std.debug.print("\n{s}: harness sample {d}\n--- source ---\n{s}\n", .{ @tagName(entry.id), index, sample });
            try expectSample(entry, sample);
        }
    }
}