twig-sys 2.2.3

FFI bindings and native library for Twig (the Djot/Markdown/HTML/XML document engine). Used by the `twig-doc` crate.
Documentation
//! Acceptance test: runs the SAME vendored djot.js conformance corpus
//! `languages/djot/conformance.zig` uses, but through THIS module's generic
//! `serialize` instead of `djot/html.zig`'s bespoke renderer — proving the
//! shared printer is a drop-in replacement (byte-for-byte identical output)
//! before `djot/html.zig` is ever touched.
//!
//! The fixture format and file list are identical to
//! `languages/djot/conformance.zig`; both the format-parsing helpers and the
//! file list are duplicated here rather than imported because
//! `djot/conformance.zig` keeps them private (this module has no business
//! reaching into djot's internals for anything beyond the public `Document`
//! fields `references`/`auto_references`/`footnotes`, which is exactly the
//! shape `Context` mirrors). See that file's doc comment for the fixture
//! syntax itself.

const std = @import("std");
const Allocator = std.mem.Allocator;
const djot = @import("../djot/djot.zig");
const html = @import("html.zig");

const testfiles = [_][]const u8{
    "attributes.test",
    "block_quote.test",
    "code_blocks.test",
    "definition_lists.test",
    "symb.test",
    "emphasis.test",
    "escapes.test",
    "fenced_divs.test",
    "footnotes.test",
    "headings.test",
    "insert_delete_mark.test",
    "links_and_images.test",
    "lists.test",
    "math.test",
    "para.test",
    "raw.test",
    "regression.test",
    "smart.test",
    "spans.test",
    "sourcepos.test",
    "super_subscript.test",
    "tables.test",
    "task_lists.test",
    "thematic_breaks.test",
    "verbatim.test",
};

const TestCase = struct {
    line: usize,
    options: []const u8,
    input: []const u8,
    expected: []const u8,
};

fn startsWithFence(line: []const u8) bool {
    return line.len >= 3 and line[0] == '`' and line[1] == '`' and line[2] == '`';
}

fn isCloseFence(line: []const u8, tick_len: usize) bool {
    if (line.len < tick_len) return false;
    for (line[0..tick_len]) |c| {
        if (c != '`') return false;
    }
    return true;
}

fn stripCr(line: []const u8) []const u8 {
    return if (line.len > 0 and line[line.len - 1] == '\r') line[0 .. line.len - 1] else line;
}

/// Parse every fenced test case out of `content`. Identical logic to
/// `djot/conformance.zig`'s `parseTests`.
fn parseTests(allocator: Allocator, content: []const u8, out: *std.ArrayList(TestCase)) !void {
    var lines = std.ArrayList([]const u8).empty;
    defer lines.deinit(allocator);
    var it = std.mem.splitScalar(u8, content, '\n');
    while (it.next()) |line| try lines.append(allocator, stripCr(line));

    var idx: usize = 0;
    while (true) {
        var open_line: ?[]const u8 = null;
        while (idx < lines.items.len) {
            const l = lines.items[idx];
            idx += 1;
            if (startsWithFence(l)) {
                open_line = l;
                break;
            }
        }
        const line = open_line orelse break;
        const testlinenum = idx;

        var tick_len: usize = 0;
        while (tick_len < line.len and line[tick_len] == '`') tick_len += 1;
        const options = std.mem.trim(u8, line[tick_len..], " \t");

        var input = std.ArrayList(u8).empty;
        errdefer input.deinit(allocator);
        while (idx < lines.items.len) {
            const l = lines.items[idx];
            idx += 1;
            if (std.mem.eql(u8, l, ".") or std.mem.eql(u8, l, "!")) break;
            try input.appendSlice(allocator, l);
            try input.append(allocator, '\n');
        }

        var output = std.ArrayList(u8).empty;
        errdefer output.deinit(allocator);
        while (idx < lines.items.len) {
            const l = lines.items[idx];
            idx += 1;
            if (isCloseFence(l, tick_len)) break;
            try output.appendSlice(allocator, l);
            try output.append(allocator, '\n');
        }

        try out.append(allocator, .{
            .line = testlinenum,
            .options = options,
            .input = try input.toOwnedSlice(allocator),
            .expected = try output.toOwnedSlice(allocator),
        });
    }
}

pub const Summary = struct {
    total: usize = 0,
    passed: usize = 0,
    failed: usize = 0,
    skipped: usize = 0,
};

pub const Failure = struct {
    file: []const u8,
    line: usize,
    input: []const u8,
    expected: []const u8,
    actual: []const u8,

    fn deinit(self: Failure, allocator: Allocator) void {
        allocator.free(self.file);
        allocator.free(self.input);
        allocator.free(self.expected);
        allocator.free(self.actual);
    }
};

/// Run every fixture, rendering each parsed `Document` through THIS module's
/// `html.serialize` (via a `Context` built from the `Document`'s side
/// tables) rather than `djot/html.zig`. Structurally identical to
/// `djot/conformance.zig`'s `run`.
pub fn run(allocator: Allocator, max_failures: usize, failures: *std.ArrayList(Failure)) !Summary {
    var threaded = std.Io.Threaded.init(allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

    var summary: Summary = .{};
    for (testfiles) |name| {
        const path = try std.fmt.allocPrint(allocator, "testdata/djot/{s}", .{name});
        defer allocator.free(path);
        const content = try std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .limited(4 * 1024 * 1024));
        defer allocator.free(content);

        var cases = std.ArrayList(TestCase).empty;
        defer {
            for (cases.items) |c| {
                allocator.free(c.input);
                allocator.free(c.expected);
            }
            cases.deinit(allocator);
        }
        try parseTests(allocator, content, &cases);

        for (cases.items) |c| {
            summary.total += 1;
            if (std.mem.indexOfScalar(u8, c.options, 'a') != null) {
                // AST-pretty-print mode: neither renderer implements it, so
                // `djot/conformance.zig` skips these ~6 cases too.
                summary.skipped += 1;
                continue;
            }
            var doc = djot.parse(allocator, c.input) catch {
                summary.failed += 1;
                if (failures.items.len < max_failures) {
                    try failures.append(allocator, .{
                        .file = try allocator.dupe(u8, name),
                        .line = c.line,
                        .input = try allocator.dupe(u8, c.input),
                        .expected = try allocator.dupe(u8, c.expected),
                        .actual = try allocator.dupe(u8, "<parse error>"),
                    });
                }
                continue;
            };
            defer doc.deinit();

            // Build a `Context` straight from the `Document`'s public side
            // tables -- a test-only use of djot internals; `serializer.zig`
            // itself never imports djot (see this file's module doc comment
            // and `serializer.zig`'s module doc comment).
            const ctx: html.Context = .{
                .references = doc.references,
                .auto_references = doc.auto_references,
                .footnotes = doc.footnotes,
            };
            const rendered = try html.serializeAlloc(allocator, &doc.ast, &ctx);
            if (std.mem.eql(u8, rendered, c.expected)) {
                summary.passed += 1;
                allocator.free(rendered);
            } else {
                summary.failed += 1;
                if (failures.items.len < max_failures) {
                    try failures.append(allocator, .{
                        .file = try allocator.dupe(u8, name),
                        .line = c.line,
                        .input = try allocator.dupe(u8, c.input),
                        .expected = try allocator.dupe(u8, c.expected),
                        .actual = rendered,
                    });
                } else {
                    allocator.free(rendered);
                }
            }
        }
    }
    return summary;
}

test "shared HTML printer matches djot.js conformance corpus exactly like djot/html.zig does" {
    const allocator = std.testing.allocator;
    var failures = std.ArrayList(Failure).empty;
    defer {
        for (failures.items) |f| f.deinit(allocator);
        failures.deinit(allocator);
    }
    const summary = try run(allocator, 40, &failures);

    // Report to stderr only on failure. A passing run stays silent on purpose:
    // under `zig build test` the child's stderr carries the build runner's
    // `std.Progress` IPC, so a raw `std.debug.print` can corrupt that protocol
    // and surface as a confusing `failed command` even when every test passed.
    // On failure the build is already red, so the detail earns its noise; run
    // the test binary directly if you want a summary of a green run.
    if (summary.failed > 0) {
        std.debug.print(
            "\nhtml printer conformance: {d}/{d} HTML cases passed, {d} failed ({d} djot.js AST-dump cases skipped; behaviours covered by native AST tests)\n",
            .{ summary.passed, summary.total - summary.skipped, summary.failed, summary.skipped },
        );
        for (failures.items) |f| {
            std.debug.print(
                "\n-- {s}:{d} --\ninput:\n{s}\nexpected:\n{s}\nactual:\n{s}\n",
                .{ f.file, f.line, f.input, f.expected, f.actual },
            );
        }
    }
    try std.testing.expectEqual(@as(usize, 0), summary.failed);
}