twig-sys 2.8.0

FFI bindings and native library for Twig (the Djot/Markdown/HTML/XML document engine). Used by the `twig-doc` crate.
Documentation
//! The ONE algorithm that spells a node's attributes back into source — the
//! serializer-side counterpart to `ast/editor.zig`, which is where the
//! authoring gestures' format-independent algorithms live.
//!
//! ── Why this is a file and not a method on `Syntax` ────────────────────────
//! `syntax.zig`'s header states the property it is protecting: the tables are
//! INERT, so "a new format is a `Syntax` literal, not new code paths". Keeping
//! the walk out of that file preserves it. `Syntax.AttrSpelling` is the
//! alphabet; this is the algorithm that reads it, and neither knows which
//! format it is serving.
//!
//! ── What it replaced ───────────────────────────────────────────────────────
//! `djot/serializer.zig`'s `writeDjotAttrs` and `markdown/serializer.zig`'s
//! `writeDirectiveAttrs`: two walks over the same side table, in the same
//! order, applying the same `id`/`class`/bare-attribute rules, differing only
//! in a quoting policy (djot quotes every value, Markdown quotes only what the
//! bare grammar can't hold). rST's `:width: 50%` option block is the same walk
//! again with no braces and a newline between entries — so it arrives as a
//! table entry rather than a third copy of this loop.
//!
//! HTML is deliberately NOT a client. `html/serializer.zig`'s
//! `renderAttributes` merges a synthesized `extra` list into the node's own
//! entries, dedups keys across the two, and escapes for a tag's interior; that
//! is HTML-output machinery rather than surface spelling, and HTML carries
//! `Syntax.none` regardless.

const std = @import("std");
const Writer = std.Io.Writer;

const AST = @import("ast/ast.zig");
const syntax = @import("syntax.zig");
const AttrSpelling = syntax.AttrSpelling;

/// Spell `attrs` per `sp`, writing nothing at all when there is nothing to say.
///
/// `indent` is written after every `between` — the continuation prefix a
/// line-per-entry form needs (rST indents its options under the directive) and
/// which a brace form leaves empty. It is not part of `AttrSpelling` because it
/// is a property of the POSITION being written into, not of the format: the
/// same rST spelling indents by two spaces under a top-level directive and by
/// more inside a list item, exactly as `ContainerSpelling`'s prefixes compose.
pub fn write(
    w: *Writer,
    attrs: AST.Attrs,
    sp: AttrSpelling,
    indent: []const u8,
) Writer.Error!void {
    if (attrs.isEmpty()) return;

    // An entry can turn out to spell NOTHING (see `willWrite`), so the opener
    // and the separators are emitted only once the entry is known to write —
    // otherwise a set whose sole entry is degenerate would leave a stray `{}`,
    // and a trailing `between` could reach the wire with nothing after it.
    var wrote_any = false;
    for (attrs.entries) |kv| {
        if (!willWrite(kv, sp)) continue;
        if (!wrote_any) {
            try w.writeAll(sp.open);
        } else {
            try w.writeAll(sp.between);
            try w.writeAll(indent);
        }
        wrote_any = true;
        try writeEntry(w, kv, sp, indent);
    }
    if (wrote_any) try w.writeAll(sp.close);
}

/// Whether `kv` spells anything under `sp`. The two degenerate cases are an
/// `id` with no value and a `class` whose value holds no classes: both are
/// unreachable from any parser (an `id`/`class` entry always carries a
/// non-empty value), and both would otherwise emit a dangling sigil — a lone
/// `#` or `.` — which is worse than emitting nothing. A hand-built tree (via
/// `AST.Builder` or the C ABI's `twig_builder_set_attrs`) can still produce
/// them, which is why this is checked rather than asserted.
fn willWrite(kv: AST.KeyVal, sp: AttrSpelling) bool {
    if (sp.id_sigil != null and std.mem.eql(u8, kv.key, "id")) {
        const v = kv.value orelse return false;
        return v.len > 0;
    }
    if (sp.class_sigil != null and std.mem.eql(u8, kv.key, "class")) {
        const v = kv.value orelse return false;
        var it = std.mem.tokenizeScalar(u8, v, ' ');
        return it.next() != null;
    }
    return true;
}

fn writeEntry(w: *Writer, kv: AST.KeyVal, sp: AttrSpelling, indent: []const u8) Writer.Error!void {
    if (sp.id_sigil) |sigil| {
        if (std.mem.eql(u8, kv.key, "id")) {
            try w.writeAll(sigil);
            try w.writeAll(kv.value.?);
            return;
        }
    }
    if (sp.class_sigil) |sigil| {
        if (std.mem.eql(u8, kv.key, "class")) {
            // `class="a b"` is ONE entry holding several classes — the parse-time
            // accumulation this reverses (see `AST.Attrs`) space-joins them at
            // the position of the first `.foo`, so they come back apart here.
            var it = std.mem.tokenizeScalar(u8, kv.value.?, ' ');
            var first = true;
            while (it.next()) |cls| {
                // Several classes are several ENTRIES on the wire even though
                // they are one side-table entry, so they take the same
                // separator-plus-indent the outer loop uses.
                if (!first) {
                    try w.writeAll(sp.between);
                    try w.writeAll(indent);
                }
                first = false;
                try w.writeAll(sigil);
                try w.writeAll(cls);
            }
            return;
        }
    }

    try w.writeAll(sp.key_prefix);
    try w.writeAll(kv.key);
    // A bare attribute is the key alone — HTML's `disabled`, which must not
    // round-trip as `disabled=""`. See `AST.KeyVal`.
    const value = kv.value orelse return;
    try w.writeAll(sp.key_value);
    if (quotes(sp.quoting, value)) {
        try w.writeByte('"');
        for (value) |c| {
            if (std.mem.indexOfScalar(u8, sp.quote_escapes, c) != null) try w.writeByte('\\');
            try w.writeByte(c);
        }
        try w.writeByte('"');
    } else {
        try w.writeAll(value);
    }
}

fn quotes(q: syntax.Quoting, value: []const u8) bool {
    return switch (q) {
        .never => false,
        .always => true,
        .when_needed => needsQuoting(value),
    };
}

/// A bare (unquoted) value may hold only name characters; empty or anything
/// else must be quoted. Markdown's rule, moved here verbatim from
/// `markdown/serializer.zig` — the alphabet matches `markdown/attributes.zig`'s
/// `isNameChar`, which is what reads it back.
fn needsQuoting(v: []const u8) bool {
    if (v.len == 0) return true;
    for (v) |c| {
        if (!(std.ascii.isAlphanumeric(c) or c == '-' or c == '_' or c == ':')) return true;
    }
    return false;
}

// ── tests ───────────────────────────────────────────────────────────────────

const testing = std.testing;

fn spell(sp: AttrSpelling, entries: []const AST.KeyVal, indent: []const u8) ![]u8 {
    var out: Writer.Allocating = .init(testing.allocator);
    errdefer out.deinit();
    try write(&out.writer, .{ .entries = entries }, sp, indent);
    return out.toOwnedSlice();
}

/// The three alphabets, one loop — the property this file exists for.
const djot_spelling: AttrSpelling = .{
    .open = "{",
    .close = "}",
    .quoting = .always,
    .id_sigil = "#",
    .class_sigil = ".",
};

const markdown_spelling: AttrSpelling = .{
    .open = "{",
    .close = "}",
    .quoting = .when_needed,
    .quote_escapes = "\"\\",
    .id_sigil = "#",
    .class_sigil = ".",
};

/// What an rST directive's option block will want: no braces, one `:key: value`
/// per line. Not wired to a format yet — it is here to prove the table can say
/// it without this file changing, which is the whole claim.
const rst_spelling: AttrSpelling = .{
    .between = "\n",
    .key_prefix = ":",
    .key_value = ": ",
};

test "djot quotes every value and spells the shorthands" {
    const out = try spell(djot_spelling, &.{
        .{ .key = "id", .value = "intro" },
        .{ .key = "class", .value = "lead wide" },
        .{ .key = "lang", .value = "en" },
    }, "");
    defer testing.allocator.free(out);
    try testing.expectEqualStrings("{#intro .lead .wide lang=\"en\"}", out);
}

test "markdown quotes only what the bare grammar cannot hold" {
    const out = try spell(markdown_spelling, &.{
        .{ .key = "key", .value = "val" },
        .{ .key = "title", .value = "two words" },
    }, "");
    defer testing.allocator.free(out);
    try testing.expectEqualStrings("{key=val title=\"two words\"}", out);
}

test "a quoted value escapes per the table's alphabet" {
    const out = try spell(markdown_spelling, &.{.{ .key = "t", .value = "a\"b" }}, "");
    defer testing.allocator.free(out);
    try testing.expectEqualStrings("{t=\"a\\\"b\"}", out);

    // Djot quotes but does not escape, and the table says so rather than the
    // algorithm deciding.
    const dj = try spell(djot_spelling, &.{.{ .key = "t", .value = "a\"b" }}, "");
    defer testing.allocator.free(dj);
    try testing.expectEqualStrings("{t=\"a\"b\"}", dj);
}

test "a bare attribute is the key alone" {
    const out = try spell(markdown_spelling, &.{.{ .key = "disabled", .value = null }}, "");
    defer testing.allocator.free(out);
    try testing.expectEqualStrings("{disabled}", out);
}

test "an rST-shaped option block is the same walk with no braces" {
    const out = try spell(rst_spelling, &.{
        .{ .key = "width", .value = "50%" },
        .{ .key = "class", .value = "new" },
        .{ .key = "name", .value = "eq:Eulers law" },
    }, "   ");
    defer testing.allocator.free(out);
    // No sigils, so `class`/`name` are ordinary options — which is exactly what
    // docutils parses them as.
    try testing.expectEqualStrings(
        \\:width: 50%
        \\   :class: new
        \\   :name: eq:Eulers law
    , out);
}

test "nothing is written for an empty set, and no block is opened for one that spells nothing" {
    const empty = try spell(djot_spelling, &.{}, "");
    defer testing.allocator.free(empty);
    try testing.expectEqualStrings("", empty);

    // A degenerate `id`/`class` (unreachable from a parser) emits no dangling
    // sigil — and since it was the only entry, no braces either.
    const degenerate = try spell(djot_spelling, &.{
        .{ .key = "id", .value = null },
        .{ .key = "class", .value = "" },
    }, "");
    defer testing.allocator.free(degenerate);
    try testing.expectEqualStrings("", degenerate);
}