twig-sys 3.1.0

FFI bindings and native library for Twig (the Djot/Markdown/HTML/XML document engine). Used by the `twig-doc` crate.
Documentation
//! HTML's surface spelling — the table `Editor`'s authoring gestures consult.
//! See `src/syntax.zig` for the model.
//!
//! ── Why HTML has a table at all ────────────────────────────────────────────
//! It used to carry `Syntax.none` on the reasoning that authoring gestures
//! spell lightweight markup and HTML has none. Half of that is right. HTML has
//! no `**`, no `> ` prefix, no fence — but it spells seven of the nine inline
//! marks with a plain element pair, and `html/parser.zig` reads every one of
//! them back (`semanticKind`'s `em`/`strong`/`mark`/`ins`/`del`/`sup`/`sub`
//! arms). A `Delims{open, close}` is exactly a tag pair. So Cmd-B over HTML
//! needs no new code — only the bytes, which is what this file is.
//!
//! ── Why it stops where it does ─────────────────────────────────────────────
//! Everything left `null` below is null because HTML's spelling has a different
//! SHAPE, not because nobody filled it in. Three shapes are missing:
//!
//!   * `heading_marker` is a byte repeated `level` times then a space. HTML's
//!     `<h1>…</h1>` is a wrapping pair carrying the level in BOTH ends.
//!   * `ContainerSpelling` prefixes every LINE. `<blockquote>` wraps a range,
//!     and a list needs a per-item `<li>` — a different algorithm, not a
//!     different alphabet, which is the premise `syntax.zig` is built on.
//!   * `CodeFence` measures the longest run of its fence byte. `<pre><code>`
//!     doesn't measure anything; it entity-escapes a body instead.
//!
//! And one mechanism is missing: the escape fields (`text_escapes`,
//! `link_text_escapes`, `link_dest_escapes`) all feed routines in
//! `ast/editor.zig` that emit a literal BACKSLASH before a byte from the
//! alphabet. HTML escapes with entities, and a link's destination lives in a
//! quoted `href` attribute rather than in `(…)`. Filling those fields with
//! `&<>` would make `insertLiteral` write `\&`, which is two literal characters
//! in HTML and not an escape at all. They stay `null`, and every gesture that
//! reads them stays a clean `error.UnsupportedFormat`.
//!
//! Lifting those four is a change to `syntax.zig` and `editor.zig`, not to this
//! file — which is the point of keeping this file inert.

const std = @import("std");
const syntax = @import("../../syntax.zig");

pub const table: syntax.Syntax = .{
    // Seven of nine. Each is the tag `html/serializer.zig` emits AND the tag
    // `html/parser.zig` maps back to this very mark, so a toggle reverses.
    //
    // The aliases are deliberately absent: `<b>`, `<i>` and `<s>` parse back as
    // strong/emph/delete too, so a document can hold them, but a gesture has to
    // pick ONE spelling to author and the semantic tag is the one the serializer
    // already emits. Toggling an existing `<b>` OFF still works — `Splicer`
    // strips via the parser's `content_span` rather than by matching these bytes
    // (see `splicer.zig`'s `toggleInline`), so the alias needs no entry here.
    // The one visible consequence: toggling `<b>x</b>` off and on again yields
    // `<strong>x</strong>`. Normalizing, not byte-identical.
    .inline_delims = .init(.{
        .emph = .{ .open = "<em>", .close = "</em>" },
        .strong = .{ .open = "<strong>", .close = "</strong>" },
        .mark = .{ .open = "<mark>", .close = "</mark>" },
        .superscript = .{ .open = "<sup>", .close = "</sup>" },
        .subscript = .{ .open = "<sub>", .close = "</sub>" },
        .insert = .{ .open = "<ins>", .close = "</ins>" },
        .delete = .{ .open = "<del>", .close = "</del>" },
        // The two the serializer renders as CHARACTERS — curly quotes, not a
        // tag pair (see its `double_quoted`/`single_quoted` arms). The parser
        // has no rule turning a curly quote back into a quoted container, so
        // there is no spelling here to author OR to emit: `null`, not a `<q>`
        // the round trip would lose. This is where HTML differs from djot and
        // Markdown, which both spell all nine.
        .double_quoted = null,
        .single_quoted = null,
    }),
    // `<code>` is the one text leaf HTML spells as a pair the parser reads back
    // — and only when the element holds exactly one text child, which is what a
    // wrap over a plain selection produces (`html/parser.zig`'s `code` arm).
    //
    // The rest are `null` for the reason `verbatim` is not: HTML spells them,
    // but not as a SYMMETRIC PAIR. A url or email is `<a href="…">text</a>`,
    // where the payload sits in an attribute; a footnote is a `<sup><a>` pair
    // plus a matching `<li>` elsewhere in the document. `Delims` cannot describe
    // either, and inventing one that drops the destination would make a toggle
    // lossy in a way `error.UnsupportedFormat` isn't.
    .text_leaf_delims = .init(.{
        .verbatim = .{ .open = "<code>", .close = "</code>" },
        .symb = null,
        .inline_math = null,
        .display_math = null,
        .url = null,
        .email = null,
        .footnote_reference = null,
        .citation_reference = null,
        .substitution_reference = null,
    }),
    // What the serializer emits (its `thematic_break` arm renders an `hr` tag).
    // The void spelling, not the XHTML `<hr />` the `xhtml_void` option can
    // produce: both parse back to `.thematic_break`, and a gesture that writes
    // one form must be the form a round trip reproduces by default.
    .thematic_break = "<hr>",
    // `<br>` — `html/parser.zig` maps it to `.hard_break` and the serializer
    // emits it, so the token round-trips the way `Syntax.cell_line_break`
    // requires. Unlike Markdown, where `<br>` is borrowed raw HTML admitted only
    // because a GFM row is one source line, here it is simply how HTML spells a
    // break. The gesture is still cell-only (`Editor.insertLineBreak` checks for
    // an enclosing `.cell`), so this understates what HTML can do — a general
    // hard break is the same future work it is for every other format.
    .cell_line_break = "<br>",

    // ── Deliberately absent ────────────────────────────────────────────────
    // `heading_marker`, `container_spelling`, `code_fence`, `task_marker`,
    // `footnote`, `link_*_escapes`, `text_escapes`/`block_start_escapes`: the
    // shape and mechanism mismatches in this file's doc comment.
    //
    // `spellsAutolink`: HTML has no autolink form at all — a bare `<https://x>`
    // is a tag with a nonsense name, never a link.
    //
    // `attr_spelling`: HTML is deliberately not a client of it. Its serializer's
    // `renderAttributes` merges a synthesized `extra` list, dedups against it and
    // escapes for a tag's interior — output machinery, not surface spelling. See
    // `syntax.zig`'s `AttrSpelling` doc.
};

test "html authors the seven marks it can read back, and neither quote" {
    const AST = @import("../../ast/ast.zig");
    const paired = [_]AST.InlineMark{ .emph, .strong, .mark, .superscript, .subscript, .insert, .delete };
    for (std.enums.values(AST.InlineMark)) |m| {
        const want = std.mem.indexOfScalar(AST.InlineMark, &paired, m) != null;
        const d = table.inline_delims.get(m);
        try std.testing.expectEqual(want, d != null);
        if (d) |dd| try std.testing.expect(dd.authorable);
    }
    // Every opener is a start tag and every closer its matching end tag — the
    // property that makes a wrap reparse as the mark it was meant to be.
    for (std.enums.values(AST.InlineMark)) |m| {
        const d = table.inline_delims.get(m) orelse continue;
        try std.testing.expect(std.mem.startsWith(u8, d.open, "<"));
        try std.testing.expect(std.mem.startsWith(u8, d.close, "</"));
        try std.testing.expectEqualStrings(d.open[1..], d.close[2..]);
    }
    table.assertCoherent();
    try std.testing.expect(table.authorable());
}

test "html spells `code` and no other text leaf" {
    const AST = @import("../../ast/ast.zig");
    for (std.enums.values(AST.TextLeafKind)) |l| {
        try std.testing.expectEqual(l == .verbatim, table.text_leaf_delims.get(l) != null);
    }
    try std.testing.expect(table.text_leaf_delims.get(.verbatim).?.authorable);
}

test "html spells no block structure and no escape alphabet" {
    // The four shape/mechanism mismatches, pinned so lifting one is a
    // deliberate edit here rather than a silent drift in `syntax.zig`.
    try std.testing.expect(table.heading_marker == null);
    try std.testing.expect(table.container_spelling.get(.block_quote) == null);
    try std.testing.expect(table.container_spelling.get(.bullet_list) == null);
    try std.testing.expect(table.container_spelling.get(.ordered_list) == null);
    try std.testing.expect(table.code_fence == null);
    // Backslash escaping is the mechanism HTML does not have; `assertCoherent`
    // only pairs their nullness, so the fact that BOTH pairs are null — rather
    // than half-filled with `&<>` — is stated here.
    try std.testing.expect(table.text_escapes == null);
    try std.testing.expect(table.block_start_escapes == null);
    try std.testing.expect(table.link_text_escapes == null);
    try std.testing.expect(table.link_dest_escapes == null);
    // No footnotes, no task boxes, no autolink form, no attribute spelling.
    try std.testing.expect(table.footnote == null);
    try std.testing.expect(table.task_marker == null);
    try std.testing.expect(table.spellsAutolink == null);
    try std.testing.expect(table.attr_spelling == null);
    table.assertCoherent();
}

test "html spells the rule and the break as void tags" {
    try std.testing.expectEqualStrings("<hr>", table.thematic_break.?);
    try std.testing.expectEqualStrings("<br>", table.cell_line_break.?);
}