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
//! What a format's *surface syntax* looks like — the spelling knowledge the
//! authoring gestures in `ast/editor.zig` need, and the only thing standing
//! between the language-agnostic `Splicer` and a working Cmd-B.
//!
//! ── Why this is a table and not a switch ───────────────────────────────────
//! Twig's four formats are RAGGED: every one of them parses and renders, but
//! only djot and Markdown can be *authored* into, and they don't even author
//! the same set — djot spells all eight inline marks, Markdown only three
//! (`**`/`*`/`` ` ``); XML and HTML spell none. A `?Delims` per (format, kind)
//! makes that raggedness DATA. The alternative — a `switch (format)` per op,
//! with an `else => unsupported_format` arm — is what the C ABI grew instead,
//! and it put the spelling of djot's `{=mark=}` behind an `extern` boundary
//! where the CLI couldn't reach it and only a C caller could test it.
//!
//! So: a `null` field means "this format has no spelling for that", and every
//! caller turns that into one uniform "unsupported" error. Exactly how
//! `format.zig`'s `TargetEntry.serializeFromAst: ?*const fn(...)` already says
//! "Twig cannot write that target yet".
//!
//! ── Why data, not behaviour ────────────────────────────────────────────────
//! Everything here is a byte string or a flag except `spellsAutolink`, which
//! has to run the format's OWN scanner (see below). That's deliberate: the
//! *algorithms* — walk the destination escaping bytes, prefix each line of a
//! covered block — are format-INDEPENDENT and live once in `ast/editor.zig`.
//! Only the alphabet changes. Keeping the tables inert means a new format is a
//! `Syntax` literal, not new code paths.
//!
//! `Syntax` names no format and imports no language module; `format.zig`'s
//! registry is what binds a `Format` to its `Syntax`, and `ast/editor.zig`
//! takes a `*const Syntax` without ever learning which format it came from.

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

/// The inline marks a toolbar can wrap or toggle over a selection. Named for
/// the `AST.Node.Kind` tags they parse back as — see `kindTag`.
pub const InlineKind = enum {
    strong,
    emph,
    verbatim,
    mark,
    superscript,
    subscript,
    insert,
    delete,
};

/// The blocks `Editor.setBlock` converts between by rewriting a leading marker.
pub const BlockKind = enum { paragraph, heading };

/// The containers `Editor.toggleBlockContainer` wraps a block range in. Unlike
/// a `BlockKind` these prefix EVERY line, they nest, and a list numbers its
/// items — which is why they're a separate vocabulary.
pub const ContainerKind = enum { block_quote, bullet_list, ordered_list };

/// The source delimiters that mark an inline kind. Values are exactly what the
/// format's serializer emits, so a wrap round-trips.
pub const Delims = struct {
    open: []const u8,
    close: []const u8,
    /// May an EDITOR gesture author this, or is the spelling emit-only?
    ///
    /// The two questions are genuinely different, and conflating them is why
    /// this flag exists. Converting a djot document to Markdown should spell a
    /// `mark` as `==x==` — lossy, but better than dropping the node. A Cmd-B
    /// style toggle must NOT mint the same bytes: `==x==` is not CommonMark, so
    /// the reparse gives back a `str`, not a `mark`, and the toggle isn't
    /// reversible.
    ///
    /// `false` = the serializer may spell it, `Editor` refuses it with
    /// `error.UnsupportedFormat`. Before this flag the two answers lived in two
    /// places — a `null` here and a hand-written arm in the serializer — and
    /// nothing kept them honest.
    authorable: bool = true,
};

/// How a format spells a container's per-line prefix.
pub const ContainerSpelling = struct {
    /// Opens the container on the first line of each covered block.
    marker: []const u8,
    /// Holds a block's continuation lines inside the container.
    cont: []const u8,
    /// A blank line INSIDE the container. A blank line separates list items (it
    /// merely makes the list loose) but BREAKS a quote in two, so a quote has to
    /// mark its blanks and a list must not.
    blank: []const u8,
    /// The marker is a per-item ordinal (`1. `, `2. `…), built at emit time
    /// rather than read from `marker`.
    numbered: bool = false,
};

/// How a format escapes a link's `(destination)` position.
///
/// This is NOT `link_text_escapes`' alphabet: this one guards the position
/// where parens end the destination and emphasis means nothing.
pub const DestEscapes = struct {
    /// Bytes to backslash-escape in the ordinary `(dest)` form.
    plain: []const u8,
    /// The `<dest>` form, used when the destination holds a space or tab —
    /// `null` when the format has no angle form and must escape in place.
    angle: ?struct {
        /// Bytes to backslash-escape between the angle brackets. A different
        /// alphabet: the brackets themselves now matter, parens no longer do.
        escapes: []const u8,
    } = null,
};

/// How a format spells a node's ATTRIBUTES back into its own source.
///
/// The formats disagree only on an alphabet, never on the algorithm: walk the
/// entries in stored order, spell `id`/`class` with their shorthand sigils if
/// the format has them, spell everything else as a key and (unless bare) a
/// value. That walk lived twice — `djot/serializer.zig`'s `writeDjotAttrs` and
/// `markdown/serializer.zig`'s `writeDirectiveAttrs` — as near-identical code
/// differing in a quoting policy. rST would have made it a third copy, which is
/// what this table exists to prevent: `.. image::`'s `:width: 50%` options are
/// the SAME walk with `open`/`close` empty, `between` a newline, and `:` for a
/// key prefix. See `attrs_writer.zig` for the one algorithm.
///
/// `null` = this format has no attribute spelling, so a serializer that reaches
/// for one writes nothing. HTML is deliberately not a client of this: its
/// `renderAttributes` merges a synthesized `extra` list, dedups keys against it,
/// and escapes for a tag's interior — output machinery, not surface spelling —
/// and HTML carries `none` anyway.
pub const AttrSpelling = struct {
    /// Opens and closes the whole block: `{`/`}` for a brace form. Both empty
    /// when each entry stands on its own (rST's field lines).
    open: []const u8 = "",
    close: []const u8 = "",
    /// Written between two entries: a space inside braces, a newline (plus a
    /// caller-supplied indent) for field lines.
    between: []const u8 = " ",
    /// Written before every key. Empty for a brace form, `:` for rST.
    key_prefix: []const u8 = "",
    /// Written between a key and its value. A BARE entry (`KeyVal.value ==
    /// null` — HTML's `disabled`) omits this and the value both.
    key_value: []const u8 = "=",
    /// When to wrap a value in `"`.
    quoting: Quoting = .never,
    /// Bytes to backslash-escape inside a quoted value. Empty means the format
    /// quotes without escaping.
    quote_escapes: []const u8 = "",
    /// The `#name` shorthand for the `id` key. `null` spells `id` as an
    /// ordinary key — which is what rST wants: docutils has no sigils, and
    /// `:name:` is just another option.
    id_sigil: ?[]const u8 = null,
    /// The `.name` shorthand, written once per space-separated class in the
    /// `class` value (`class="a b"` -> `.a .b`). `null` spells `class` as an
    /// ordinary key.
    class_sigil: ?[]const u8 = null,
};

/// Whether a value needs `"` around it.
///
/// `when_needed` means "quote unless the value is spellable bare", where bare
/// admits only name characters — Markdown's rule, and the reason `key=val` and
/// `key="a b"` both appear in its output. `always` is djot's.
pub const Quoting = enum { never, always, when_needed };

/// The spelling of a format that can't be authored into at all — every field
/// left at "can't spell it". A parse-only language (XML, HTML) carries THIS
/// rather than a `null`, which is what lets `Editor.syntax` be a plain pointer:
/// every gesture consults a table, finds the `null` it would have found anyway,
/// and reports unsupported through the one uniform path. There is no second
/// "but does this format have a table at all?" question to forget to ask.
pub const none: Syntax = .{};

/// One format's surface spelling. Every field defaults to "can't spell it", so
/// a format that only parses is `.{}` (see `none`) and every gesture over it
/// reports unsupported without that format needing to say so.
pub const Syntax = struct {
    /// Delimiters per inline mark. `null` for a mark this format cannot spell
    /// AT ALL; a spelling that exists but must not be authored carries
    /// `authorable = false` instead (see `Delims`).
    ///
    /// Keyed on `AST.InlineMark` rather than on `InlineKind`, so the table has
    /// exactly one entry per node the serializers emit — which is what lets
    /// them read it instead of keeping a second, drifting copy.
    inline_delims: std.EnumArray(AST.InlineMark, ?Delims) = .initFill(null),

    /// Delimiters per delimited text leaf — a `` `code` `` span, `$math$`, a
    /// `:shortcode:`. Same contract as `inline_delims`, for the other family.
    text_leaf_delims: std.EnumArray(AST.TextLeafKind, ?Delims) = .initFill(null),

    /// Per-line prefixes per container kind.
    container_spelling: std.EnumArray(ContainerKind, ?ContainerSpelling) = .initFill(null),

    /// How a node's attributes are spelled back. `null` = no spelling, so a
    /// serializer writes nothing. See `AttrSpelling`.
    attr_spelling: ?AttrSpelling = null,

    /// The byte that opens an ATX heading, repeated `level` times then a space.
    /// `null` = this format has no heading marker, so `setBlock` is unsupported.
    heading_marker: ?u8 = null,

    /// The bytes a link's TEXT position must have backslash-escaped for the text
    /// to reparse as the literal string handed in. Each one either opens a
    /// construct that swallows the text — `*`/`_`/`` ` ``/`~`/`^` emphasis-ish
    /// runs, djot's `{…}` attributes and `"`/`'`/`-`/`.`/`:` smart punctuation,
    /// Markdown's `<…>` raw HTML and `&…;` entities — or breaks the brackets
    /// outright (`[`/`]`/`\`).
    ///
    /// The sets differ because the metacharacters do: djot has attributes and no
    /// entities, Markdown the reverse. Both read `\` + ASCII punctuation as that
    /// literal character, so an escape here is always safe, never a stray
    /// backslash.
    ///
    /// `null` = this format can't spell a link at all, and every link gesture
    /// over it reports unsupported.
    link_text_escapes: ?[]const u8 = null,

    /// How to escape a link's destination. `null` alongside a non-null
    /// `link_text_escapes` is a contradiction — see `assertCoherent`.
    link_dest_escapes: ?DestEscapes = null,

    /// The bytes a run of user-typed text must have backslash-escaped for the
    /// run to reparse as ITSELF in ordinary *body-text* position — the alphabet
    /// `Editor.insertLiteral` guards. These are the inline metacharacters that
    /// fire anywhere on a line: `*`/`_`/`` ` ``/`~`/`^` emphasis-ish runs, `[`/`]`
    /// link brackets, `\` itself, plus each format's own — Markdown's `<…>` raw
    /// HTML and `&…;` entities, djot's `{…}` attributes and `"`/`'`/`-`/`.`/`:`
    /// smart punctuation. A `\` before ASCII punctuation is that literal
    /// character in both formats, so an escape here is always safe.
    ///
    /// A sibling of `link_text_escapes`, not the same set: link text sits inside
    /// `[…]` where the brackets already bound it, while body text also opens
    /// blocks (see `block_start_escapes`) and is where a typed `<https://…>`
    /// would otherwise autolink. Over-escaping is safe (valid, just noisier
    /// source), so this errs wide — the Hidden-mode caller never shows the
    /// source. `null` = a parse-only format, so `insertLiteral` is
    /// `error.UnsupportedFormat`.
    text_escapes: ?[]const u8 = null,

    /// The bytes that only open a construct at a LINE START — block markers
    /// (`#`, `>`, `-`, `+`, table `|`, setext `=`…). `insertLiteral` escapes one
    /// only when the insertion point sits in the leading whitespace of its line;
    /// mid-line they are ordinary text and left alone, so a sentence's "5 - 3"
    /// keeps its `-`. Disjoint from `text_escapes` by construction: a byte that
    /// must be escaped everywhere lives there and needs no line-start entry here.
    /// `null` iff `text_escapes` is — see `assertCoherent`.
    block_start_escapes: ?[]const u8 = null,

    /// Whether `angled` — a `<dest>` run, BRACKETS INCLUDED — spells an
    /// autolink. `null` = this format has no autolink form.
    ///
    /// A function, not a table, because it must be asked of the format's OWN
    /// scanner (the one its parser dispatches on) rather than re-derived here,
    /// so it cannot drift from what a reparse will see. There is no shared rule
    /// to hoist: the formats genuinely disagree. Markdown wants an absolute URI
    /// (a 2-32 character `scheme:`) or a CommonMark email, and silently reads
    /// anything else as raw HTML (`<foo>` is a tag!) or literal text. Djot
    /// classifies on content alone — an `@` not preceded by `:` is an email,
    /// else a `letter:` is a url — which is why `mailto:a@b.dev` is a `url` in
    /// Markdown but an `email` in djot. Both refuse a relative path.
    spellsAutolink: ?*const fn (angled: []const u8) bool = null,

    /// How a hard break is spelled *inside a table cell*, where a row is a
    /// single source line so the ordinary newline spelling (`  \n`, djot's
    /// `\`+newline) can't appear. This is a distinct alphabet from the ordinary
    /// hard break precisely because the position forbids a line end: Markdown
    /// spells it `<br>` (raw HTML is valid inside a GFM cell), and the same
    /// `<br>` round-trips 1:1 because the parser reads it back as a `hard_break`
    /// in cell context (see `markdown/inline.zig`) and the serializer re-emits it
    /// from this field (see `markdown/serializer.zig`).
    ///
    /// `null` = this format has no in-cell break, so `Editor.insertLineBreak`
    /// inside a cell is `error.UnsupportedFormat`. Djot is `null` on purpose: it
    /// has no native in-cell break, and spelling one as `<br>` would emit
    /// non-idiomatic djot that any other djot reader renders as the literal text
    /// `<br>`. Unlike the other fields, a `null` here carries no coherence
    /// obligation — it neither implies nor is implied by any other spelling, so
    /// `assertCoherent` says nothing about it.
    cell_line_break: ?[]const u8 = null,

    /// Whether this format can be authored into at all — true once it can spell
    /// any one gesture. `false` for a parse-only format (XML, HTML).
    pub fn authorable(self: *const Syntax) bool {
        return self.link_text_escapes != null or
            self.heading_marker != null or
            self.text_escapes != null or
            self.inline_delims.get(.strong) != null;
    }

    /// The delimiters for whatever node `ref` names, from whichever family
    /// table holds it — the one lookup a serializer needs, so it never has to
    /// know which family a kind belongs to. `null` = this format has no
    /// spelling for it.
    pub fn delimsFor(self: *const Syntax, ref: AST.KindRef) ?Delims {
        return switch (ref) {
            .mark => |m| self.inline_delims.get(m),
            .text_leaf => |l| self.text_leaf_delims.get(l),
            // A `markup_leaf` IS framed by a symmetric pair (`<!--`/`-->`,
            // `<![CDATA[`/`]]>`, …), but there is deliberately no table for
            // it: the only formats that spell one (XML, HTML) are parse-only
            // and carry no `Syntax` at all (see `format.zig`'s registry), no
            // editor gesture authors one, and HTML's `cdata` isn't even a
            // pair (it renders as escaped text). Each serializer's arm is the
            // SINGLE copy of those spellings, so a table here would create
            // the duplicate that `inline_delims` existed to remove. No
            // remaining `.tag` kind is spelled by a symmetric pair.
            .markup_leaf, .tag => null,
        };
    }

    /// `delimsFor` restricted to what an editor gesture may write — the
    /// serializer's question minus the emit-only spellings. See `Delims`.
    pub fn authorableDelimsFor(self: *const Syntax, ref: AST.KindRef) ?Delims {
        const d = self.delimsFor(ref) orelse return null;
        return if (d.authorable) d else null;
    }

    /// A `Syntax` literal is hand-maintained, so the invariants between its
    /// fields are checked once at startup rather than trusted at every call
    /// site — the same trust boundary `format.zig`'s registry relies on.
    pub fn assertCoherent(self: *const Syntax) void {
        // Text and destination escaping are two halves of spelling ONE link.
        // A format with one but not the other would build `[text](` and then
        // have nothing to say about what follows.
        std.debug.assert((self.link_text_escapes == null) == (self.link_dest_escapes == null));
        // The body-text and line-start alphabets are two halves of spelling ONE
        // literal run: a format that could escape mid-line specials but not
        // block markers (or vice versa) would let `insertLiteral` mint the other.
        std.debug.assert((self.text_escapes == null) == (self.block_start_escapes == null));
    }
};

test "a parse-only format spells nothing" {
    const s = Syntax{};
    try std.testing.expect(!s.authorable());
    try std.testing.expect(s.inline_delims.get(.strong) == null);
    try std.testing.expect(s.container_spelling.get(.block_quote) == null);
    try std.testing.expect(s.heading_marker == null);
    try std.testing.expect(s.text_escapes == null);
    try std.testing.expect(s.block_start_escapes == null);
    s.assertCoherent();
}