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
//! `AST` -> XML text. The inverse of `parser.zig`, used both as a genuinely
//! useful output path and to prove the parse/serialize loop round-trips (see
//! `xml.zig`'s tests). Byte-perfect fidelity to the *original* source is an
//! explicit non-goal here — that will eventually come from span-splicing
//! edits directly into the original text, per Twig's overall design — so
//! this instead re-derives generic, always-valid XML from the tree alone,
//! which happens to be byte-identical to the source for already-canonical
//! input (double-quoted attributes, explicit close tags, no unusual entity
//! usage): nothing here inserts, removes, or reformats whitespace beyond
//! what's already present as `str` nodes in the tree.
//!
//! Self-closing vs. open/close-pair form: the `AST` has no dedicated flag for
//! "this element was written as `<a/>`", so this renderer uses an ABSENT
//! interior span as that signal (exactly what the parser leaves for a
//! self-closing tag, and only for one — see `parser.zig`'s `parseElement`).
//! An element with a non-null interior always renders as an explicit
//! `<a>...</a>` pair, even if it has no children (the zero-width case,
//! `<a></a>`).
//!
//! That is why this is the ONE printer taking a `*const Document` rather than
//! a `*const AST`: the signal it needs is a byte position, which now lives in
//! `src/document.zig` rather than on the node. Every other serializer reads
//! only meaning and keeps the narrower parameter — `languages/html/serializer
//! .zig` decides the same question from the void-element NAME list instead,
//! and so stays span-free. Self-closing is really a spelling, and the honest
//! long-term home for it is the spelling side-table; reading it off the
//! interior span preserves today's behaviour exactly until that lands.

const std = @import("std");
const Allocator = std.mem.Allocator;
const Writer = std.Io.Writer;
const AST = @import("../../ast/ast.zig");
const Document = @import("../../document.zig");
const Node = AST.Node;

fn writeEscapedText(writer: *Writer, s: []const u8) Writer.Error!void {
    for (s) |c| switch (c) {
        '&' => try writer.writeAll("&amp;"),
        '<' => try writer.writeAll("&lt;"),
        '>' => try writer.writeAll("&gt;"),
        else => try writer.writeByte(c),
    };
}

fn writeEscapedAttrValue(writer: *Writer, s: []const u8) Writer.Error!void {
    for (s) |c| switch (c) {
        '&' => try writer.writeAll("&amp;"),
        '<' => try writer.writeAll("&lt;"),
        '"' => try writer.writeAll("&quot;"),
        else => try writer.writeByte(c),
    };
}

fn writeAttrs(ast: *const AST, id: Node.Id, writer: *Writer) Writer.Error!void {
    for (ast.attrsOf(id).entries) |kv| {
        try writer.writeByte(' ');
        try writer.writeAll(kv.key);
        try writer.writeAll("=\"");
        // XML attribute values are never bare (see `AST.KeyVal`'s doc
        // comment); `parser.zig` never produces a null one, but fall back to
        // an empty value rather than crash if a hand-built tree does.
        try writeEscapedAttrValue(writer, kv.value orelse "");
        try writer.writeByte('"');
    }
}

/// Write `id` (and its descendants) as XML text to `writer`.
pub fn serializeNode(doc: *const Document, id: Node.Id, writer: *Writer) Writer.Error!void {
    const node = doc.ast.nodes[id];
    switch (node.kind) {
        .doc => {
            var it = doc.children(id);
            while (it.next()) |child| try serializeNode(doc, child.id, writer);
        },
        .container => |e| {
            try writer.writeByte('<');
            try writer.writeAll(e.name);
            try writeAttrs(&doc.ast, id, writer);
            if (doc.contentSpan(id) == null) {
                try writer.writeAll("/>");
                return;
            }
            try writer.writeByte('>');
            var it = doc.children(id);
            while (it.next()) |child| try serializeNode(doc, child.id, writer);
            try writer.writeAll("</");
            try writer.writeAll(e.name);
            try writer.writeByte('>');
        },
        .str => |s| try writeEscapedText(writer, s),
        // One arm, still exhaustive over `MarkupLeafKind`: a fourth markup
        // leaf fails THIS build (where spelling lives) and no other.
        .markup_leaf => |l| switch (l.kind) {
            .comment => {
                try writer.writeAll("<!--");
                try writer.writeAll(l.text);
                try writer.writeAll("-->");
            },
            .doctype => {
                try writer.writeAll("<!DOCTYPE");
                try writer.writeAll(l.text);
                try writer.writeByte('>');
            },
            .cdata => {
                try writer.writeAll("<![CDATA[");
                try writer.writeAll(l.text);
                try writer.writeAll("]]>");
            },
        },
        .processing_instruction => |pi| {
            try writer.writeAll("<?");
            try writer.writeAll(pi.target);
            if (pi.data.len > 0) {
                try writer.writeByte(' ');
                try writer.writeAll(pi.data);
            }
            try writer.writeAll("?>");
        },
        // `parser.zig` (and any well-behaved XML parser) never produces
        // anything outside these generic-markup kinds.
        else => unreachable,
    }
}

/// Serialize the whole tree (from `ast.root`) to `writer`.
pub fn serialize(doc: *const Document, writer: *Writer) Writer.Error!void {
    try serializeNode(doc, doc.ast.root, writer);
}

/// Convenience wrapper: serialize to an owned string.
pub fn serializeAlloc(allocator: Allocator, doc: *const Document) Allocator.Error![]u8 {
    var out: Writer.Allocating = .init(allocator);
    defer out.deinit();
    // `Writer.Allocating` only fails via `error.WriteFailed` when its own
    // backing allocation fails (mirrors `html.zig`'s `renderAlloc`).
    serialize(doc, &out.writer) catch |err| switch (err) {
        error.WriteFailed => return error.OutOfMemory,
    };
    return out.toOwnedSlice();
}

const testing = std.testing;
const parser = @import("parser.zig");

test "serializes a self-closing element with attributes" {
    var p = parser.Parser.init(testing.allocator, "<a x=\"1\" y=\"2\"/>");
    defer p.deinit();
    var ast = try p.parse();
    defer ast.deinit();

    const out = try serializeAlloc(testing.allocator, &ast);
    defer testing.allocator.free(out);
    try testing.expectEqualStrings("<a x=\"1\" y=\"2\"/>", out);
}

test "serializes an explicit open/close pair even when empty" {
    var p = parser.Parser.init(testing.allocator, "<a></a>");
    defer p.deinit();
    var ast = try p.parse();
    defer ast.deinit();

    const out = try serializeAlloc(testing.allocator, &ast);
    defer testing.allocator.free(out);
    try testing.expectEqualStrings("<a></a>", out);
}

test "escapes text and attribute values" {
    var b = AST.Builder.init(testing.allocator);
    defer b.deinit();
    const text = try b.addLeaf(.{ .str = "a < b & c > d" });
    const el = try b.addContainer(.{ .container = .{ .name = "p" } }, &.{text});
    b.setContentSpan(el, .{ .start = 0, .end = 0 });
    try b.setAttrs(el, .{ .entries = &.{.{ .key = "title", .value = "x \"y\" & z" }} });
    const doc_id = try b.addContainer(.doc, &.{el});

    var ast = try b.finishDocument("", doc_id);
    defer ast.deinit();

    const out = try serializeAlloc(testing.allocator, &ast);
    defer testing.allocator.free(out);
    try testing.expectEqualStrings(
        "<p title=\"x &quot;y&quot; &amp; z\">a &lt; b &amp; c &gt; d</p>",
        out,
    );
}