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
//! `zig build bench -- [--format <fmt>] [--iters N] <file>` — parse a document
//! under a `CountingAllocator` and report exactly how many allocations and how
//! many bytes the parse costs. This is the tool that answers the memory
//! questions `/usr/bin/time` can't: the CLI runs on an arena (per-node `dupe`s
//! become cheap bumps, so RSS barely moves when you remove allocations),
//! whereas this harness wraps the *page allocator* and counts every call, so a
//! change like "borrow source instead of duping" shows up as a real drop in
//! `allocations`/`bytes alloc` even when wall-clock is flat.
//!
//! Format defaults to inference from the file extension; override with
//! `--format djot|markdown|html|xml`. `--iters N` reparses N times (each parse
//! fully deinit'd) and reports the *per-iteration* averages, which steadies
//! the numbers and lets you eyeball allocator throughput.

const std = @import("std");
const Io = std.Io;
const twig = @import("twig");
const CountingAllocator = @import("counting_allocator.zig").CountingAllocator;

const Format = enum { djot, markdown, html, xml };

const max_source_bytes = 64 * 1024 * 1024;

pub fn main(init: std.process.Init) !void {
    const arena = init.arena.allocator();
    const io = init.io;

    var stderr_buf: [1024]u8 = undefined;
    var stderr_fw: Io.File.Writer = .init(.stderr(), io, &stderr_buf);
    const stderr = &stderr_fw.interface;

    var stdout_buf: [4096]u8 = undefined;
    var stdout_fw: Io.File.Writer = .init(.stdout(), io, &stdout_buf);
    const stdout = &stdout_fw.interface;

    const argv = try init.minimal.args.toSlice(arena);
    var path: ?[]const u8 = null;
    var forced: ?Format = null;
    var iters: usize = 1;

    var i: usize = 1; // skip argv[0]
    while (i < argv.len) : (i += 1) {
        const a = argv[i];
        if (std.mem.eql(u8, a, "--format") or std.mem.eql(u8, a, "-f")) {
            i += 1;
            if (i >= argv.len) return fail(stderr, "--format needs a value", .{});
            forced = std.meta.stringToEnum(Format, argv[i]) orelse
                return fail(stderr, "unknown format '{s}' (djot|markdown|html|xml)", .{argv[i]});
        } else if (std.mem.eql(u8, a, "--iters") or std.mem.eql(u8, a, "-n")) {
            i += 1;
            if (i >= argv.len) return fail(stderr, "--iters needs a value", .{});
            iters = std.fmt.parseInt(usize, argv[i], 10) catch
                return fail(stderr, "bad --iters '{s}'", .{argv[i]});
            if (iters == 0) return fail(stderr, "--iters must be >= 1", .{});
        } else if (path == null) {
            path = a;
        } else {
            return fail(stderr, "unexpected extra argument '{s}'", .{a});
        }
    }

    const file = path orelse return fail(stderr, "usage: bench [--format <fmt>] [--iters N] <file>", .{});
    const fmt = forced orelse inferFormat(file) orelse
        return fail(stderr, "cannot infer format of '{s}'; pass --format", .{file});

    const source = Io.Dir.cwd().readFileAlloc(io, file, arena, .limited(max_source_bytes)) catch |err|
        return fail(stderr, "could not read '{s}': {t}", .{ file, err });

    // Wrap the page allocator (NOT the arena) so frees are real and
    // `bytes peak` reflects the true live high-water mark of one parse.
    var counter = CountingAllocator.init(std.heap.page_allocator);
    const galloc = counter.allocator();

    var n: usize = 0;
    while (n < iters) : (n += 1) {
        switch (fmt) {
            .djot => {
                var doc = try twig.Djot.parse(galloc, source);
                doc.deinit();
            },
            .markdown => {
                var doc = try twig.Markdown.parse(galloc, source, .{});
                doc.deinit();
            },
            .html => {
                var ast = try twig.Html.parse(galloc, source);
                ast.deinit();
            },
            .xml => {
                var ast = twig.Xml.parse(galloc, source) catch |err|
                    return fail(stderr, "xml parse failed: {t}", .{err});
                ast.deinit();
            },
        }
    }

    const s = counter.stats;
    const fi: f64 = @floatFromInt(iters);
    try stdout.print(
        \\file        : {s}
        \\format      : {t}
        \\source      : {d} bytes
        \\iterations  : {d}
        \\
        \\── totals over all iterations ──
        \\{f}
        \\
        \\── per iteration ──
        \\allocations : {d:.1}
        \\bytes alloc : {d:.0}
        \\bytes/byte  : {d:.2}  (alloc'd bytes per source byte)
        \\
    , .{
        file,
        fmt,
        source.len,
        iters,
        s,
        @as(f64, @floatFromInt(s.alloc_count)) / fi,
        @as(f64, @floatFromInt(s.bytes_allocated)) / fi,
        if (source.len == 0) 0 else @as(f64, @floatFromInt(s.bytes_allocated)) / (fi * @as(f64, @floatFromInt(source.len))),
    });

    // A clean parse frees everything; a nonzero live count means a leak.
    if (s.bytes_live != 0)
        try stdout.print("\nWARNING: {d} bytes still live after deinit (leak?)\n", .{s.bytes_live});

    try stdout.flush();
}

fn inferFormat(path: []const u8) ?Format {
    const dot = std.mem.lastIndexOfScalar(u8, path, '.') orelse return null;
    const ext = path[dot + 1 ..];
    if (std.ascii.eqlIgnoreCase(ext, "dj") or std.ascii.eqlIgnoreCase(ext, "djot")) return .djot;
    if (std.ascii.eqlIgnoreCase(ext, "md") or std.ascii.eqlIgnoreCase(ext, "markdown")) return .markdown;
    if (std.ascii.eqlIgnoreCase(ext, "html") or std.ascii.eqlIgnoreCase(ext, "htm")) return .html;
    if (std.ascii.eqlIgnoreCase(ext, "xml")) return .xml;
    return null;
}

fn fail(stderr: *Io.Writer, comptime fmt: []const u8, args: anytype) error{BenchFailed} {
    stderr.print("bench: " ++ fmt ++ "\n", args) catch {};
    stderr.flush() catch {};
    return error.BenchFailed;
}