Parser API (src/abi.zig)

August 15, 2026 · View on GitHub

There is no public C ABI. MD4X is a Zig library; src/abi.zig is the single source of truth for the shared parser types, enums, flags, and the Parser callback table.

  • Detail types (Attribute, Block*Detail, Span*Detail) are ordinary Zig structs with compiler-chosen layout — slices instead of pointer + *_size/*_count pairs, and bool instead of c_int for the two-state members. An absent value is the empty slice; the parser never distinguished null from empty.
  • The type codes are real Zig enumsBlockType, SpanType, TextType, Align — and the details reach callbacks only through the tagged unions BlockDetail / SpanDetail, so a renderer resolves them with an exhaustive switch rather than an unchecked @ptrCast of a ?*anyopaque. The enums keep the numeric values and declaration order of the C enumerations they replace.
  • The callback table is the plain Zig Parser struct — no extern, no callconv(.c), and no abi_version / syntax field or MD_RENDERER alias.
  • The five SAX callbacks are required — non-optional and un-defaulted, so an incomplete callback table is a compile error rather than a null-function-pointer call at parse time. Only debug_log is optional.

Core function:

pub fn md_parse(
    text: [*c]const MD_CHAR,
    size: MD_SIZE,
    parser: *const Parser,
    userdata: ?*anyopaque,
) c_int;

Returns 0 on success, -1 on runtime error (e.g. memory failure), or the non-zero return value of any callback that aborted parsing.

There is no parser-flags parameter — not here and not on any renderer entry point (see renderers.md). md4x has exactly one dialect, and there is no flag word to select another with.

MD_CHAR is u8; MD_SIZE and MD_OFFSET are c_uint. UTF-8 is the only supported encoding (the MD4X_USE_ASCII / MD4X_USE_UTF16 build variants were dropped with the C sources).

The Parser struct holds the callbacks — and nothing else:

/// 0 continues the parse; non-zero aborts the enclosing emitter.
pub const CallbackResult = i32;

pub const Parser = struct {
    // Required — non-optional, no default.
    enter_block: *const fn (*const BlockDetail, ?*anyopaque) CallbackResult,
    leave_block: *const fn (*const BlockDetail, ?*anyopaque) CallbackResult,
    enter_span: *const fn (*const SpanDetail, ?*anyopaque) CallbackResult,
    leave_span: *const fn (*const SpanDetail, ?*anyopaque) CallbackResult,
    text: *const fn (TextType, []const MD_CHAR, ?*anyopaque) CallbackResult,
    debug_log: ?*const fn ([]const u8, ?*anyopaque) void = null,  // Optional
};

All five SAX callbacks must be supplied. The emission path calls them unconditionally, so they are non-optional and carry no default: Parser{} does not compile, and neither does md_parse(text, size, &.{}, null). Every callback table must therefore name all five explicitly (only debug_log defaults). This is deliberate — while the fields were nullable, an omitted callback was a null-function-pointer call: a panic in Debug/ReleaseSafe and undefined behavior in the shipping ReleaseFast build. debug_log is the one genuinely optional callback and stays nullable; the parser guards it.

The detail arrives as a const pointer to the tagged union (the unions are large and this is a hot path). There is no separate type parameter — the block or span type is the union's active tag, so a callback recovers it with switch (detail.*) or std.meta.activeTag(detail.*). userdata stays ?*anyopaque: it is a genuine type-erased user pointer.

pub const BlockDetail = union(BlockType) {
    doc: void,   quote: void,             ul: BlockUlDetail,        ol: BlockOlDetail,
    li: BlockLiDetail,                    hr: void,                 h: BlockHDetail,
    code: BlockCodeDetail,                html: void,               p: void,
    table: BlockTableDetail,              thead: void,              tbody: void,
    tr: void,    th: BlockTdDetail,       td: BlockTdDetail,        frontmatter: void,
    component: BlockComponentDetail,      template: BlockTemplateDetail,
    alert: BlockAlertDetail,              footnote_def_section: void,
    footnote_def: BlockFootnoteDefDetail,
};

pub const SpanDetail = union(SpanType) {
    em: SpanAttrsDetail,     strong: SpanAttrsDetail, a: SpanADetail,   img: SpanImgDetail,
    code: SpanAttrsDetail,   del: SpanAttrsDetail,    latexmath: void,
    latexmath_display: void, component: SpanComponentDetail,
    span: SpanSpanDetail,
    mark: SpanAttrsDetail,   footnote_ref: SpanFootnoteRefDetail,
};

BlockDetail.default(ty) returns the all-defaults value of the arm named by a runtime BlockType — the emission path uses it to materialize a detail before filling in the fields the type actually carries.

Architecture

SAX-like callback design — No AST construction. Streaming for efficiency and low memory.

  • Callbacks are invoked in nested order (block > span > text)
  • Strings passed to callbacks are not null-terminated — always use the size parameter
  • Any callback may abort parsing by returning non-zero

Abort-code contract: at the intermediate block/span/text boundaries, md_parse propagates a negative callback code verbatim, but returns 0 for a positive one (md4c parity — those boundaries test ret < 0). OOM and a callback returning -1 are intentionally unified as -1 in the emission path.

Doc-level exception: md_process_doc's own enter_block(.doc) / leave_block(.doc) bookends test != 0, not < 0, and md_parse returns that value verbatim. So a callback aborting on the .doc block propagates in both directions: md_parse returns 5 for a +5 and -7 for a -7. This too is genuine md4c parity (upstream MD_ENTER_BLOCK aborts on != 0) — do not "fix" the two != 0 tests into < 0.

Both halves are pinned by the abort-matrix native tests in src/md4x.zig (zig build test) — do not change them.

That contract is why CallbackResult is a plain i32 rather than a Zig error union: the code has to carry an arbitrary caller-chosen integer through unchanged, and OOM must stay indistinguishable from a callback's -1.

Linear time guarantee — Protections against pathological inputs:

  • Code span mark limits (32 backticks max)
  • Table column limits (128 max)
  • Link reference definition abuse limits
  • Block components, template slots and alerts: at most 65 536 records of each kind per document (types.MAX_BLOCK_INFO_RECORDS). Each one keeps its name/props/title source offsets in a side array whose index travels through the 16-bit MD_BLOCK.bits.data, so the cap is where that index would wrap. Past it the opener simply stops being recognized and the line renders as literal text — see docs/markdown-syntax.md
  • Inline {...} attributes: the document's {} pairing is computed once per parse (one linear pass, lazily on the first candidate) and then queried by binary search, so a candidate never re-scans the document — unbalanced or deeply nested braces stay linear

Callback sequence example for * foo **bar [link](http://example.com) baz**:

enter_block(.doc)
  enter_block(.ul)
    enter_block(.li)
      text("foo ")
      enter_span(.strong)
        text("bar ")
        enter_span(.a)
          text("link")
        leave_span(.a)
        text(" baz")
      leave_span(.strong)
    leave_block(.li)
  leave_block(.ul)
leave_block(.doc)

Encoding

MD4X assumes UTF-8. Unicode matters for: word boundary classification (emphasis), case-insensitive link reference matching (case-folding), entity translation (left to renderer). The tables live in the generated src/unicode_tables.zig (Unicode 18.0).

Block Types (BlockType / BlockDetail)

TypeHTMLUnion payload
.doc<body>void
.quote<blockquote>void
.ul<ul>BlockUlDetail
.ol<ol>BlockOlDetail
.li<li>BlockLiDetail
.hr<hr>void
.h<h1><h6>BlockHDetail
.code<pre><code>BlockCodeDetail
.html(raw HTML)void
.p<p>void
.table<table>BlockTableDetail
.thead<thead>void
.tbody<tbody>void
.tr<tr>void
.th<th>BlockTdDetail
.td<td>BlockTdDetail
.frontmatter(suppressed)void
.component(dynamic tag)BlockComponentDetail
.template<template>BlockTemplateDetail
.alert<blockquote>BlockAlertDetail
.footnote_def_section<section class="footnotes"><ol>void
.footnote_def<li id="fn-N">BlockFootnoteDefDetail

The two footnote blocks are emitted after every other block, at the end of the document, in order of first reference — see markdown-syntax.md for the syntax and renderers.md for what each renderer makes of them.

Span Types (SpanType / SpanDetail)

TypeHTMLUnion payload
.em<em>SpanAttrsDetail
.strong<strong>SpanAttrsDetail
.a<a>SpanADetail
.img<img>SpanImgDetail
.code<code>SpanAttrsDetail
.del<del>SpanAttrsDetail
.latexmath(inline math)void
.latexmath_display(display math)void
.component(dynamic tag)SpanComponentDetail
.span<span>SpanSpanDetail
.mark<mark>SpanAttrsDetail
.footnote_ref<sup><a>SpanFootnoteRefDetail

.footnote_ref is self-contained: enter_span and leave_span fire back to back with no text callback between them, so a renderer must emit everything it wants from the detail alone.

The SpanAttrsDetail spans used to receive either a detail or a null pointer, depending on whether a trailing {...} was present. That distinction is gone: they always carry a SpanAttrsDetail, and an empty raw_attrs means "no attributes". No consumer ever told the two apart (every guard was detail != null and raw_attrs.len > 0).

Text Types (TextType)

TypeDescription
.normalNormal text
.nullcharNULL character (replace with U+FFFD)
.brHard line break (<br>)
.softbrSoft line break
.entityHTML entity (&nbsp;, &#1234;, &#x12AB;)
.codeText inside code block/span (\n for newlines, no BR events)
.htmlRaw HTML text (\n for newlines in block-level HTML)
.latexmathText inside LaTeX equation (processed like code spans)

Alignment (Align)

.default, .left, .center, .right — the BlockTdDetail.@"align" value.

Detail Structs

All are plain Zig structs in src/abi.zig — compiler-chosen layout, every field defaulted, so an unset detail is just .{}. Absent strings/arrays are the empty slice, never a null pointer (field defaults omitted below for brevity).

pub const BlockUlDetail = struct {
    is_tight: bool,         // True for a tight list, false for a loose one
    mark: MD_CHAR,          // Bullet character: '-', '+', '*'
};

pub const BlockOlDetail = struct {
    start: c_uint,          // Start index of ordered list
    is_tight: bool,         // True for a tight list, false for a loose one
    mark_delimiter: MD_CHAR, // '.' or ')'
};

pub const BlockLiDetail = struct {
    is_task: bool,              // True for a `[ ]` / `[x]` task item
    task_mark: MD_CHAR,         // 'x', 'X', or ' ' (if is_task)
    task_mark_offset: MD_OFFSET, // Offset of char between '[' and ']'
};

pub const BlockHDetail = struct {
    level: c_uint,          // Header level (1-6)
};

pub const BlockCodeDetail = struct {
    info: Attribute,        // Full info string
    lang: Attribute,        // First word of info string (language)
    fence_char: MD_CHAR,    // Fence character, or zero for indented code
    filename: Attribute, // `[filename]` from the info string
    meta: []const MD_CHAR,  // Raw metadata remainder; empty when absent.
                            // The backing buffer carries a NUL at meta.len
    highlights: []const c_uint, // Line numbers from `{1-3,5}`; empty when absent
};

pub const BlockTableDetail = struct {
    col_count: c_uint,      // Number of columns
    head_row_count: c_uint, // Header rows (currently always 1)
    body_row_count: c_uint, // Body rows
};

pub const BlockTdDetail = struct {
    @"align": Align,        // .default, .left, .center, or .right
};

pub const SpanAttrsDetail = struct {
    raw_attrs: []const MD_CHAR, // Raw attrs from trailing {...}. Not NUL-terminated
};

pub const SpanADetail = struct {
    href: Attribute,
    title: Attribute,
    raw_attrs: []const MD_CHAR,
    is_autolink: bool,
};

pub const SpanImgDetail = struct {
    src: Attribute,
    title: Attribute,
    raw_attrs: []const MD_CHAR,
};

pub const SpanSpanDetail = struct {
    raw_attrs: []const MD_CHAR, // Raw attrs from {...}. Not NUL-terminated
};

pub const SpanComponentDetail = struct {
    tag_name: Attribute,        // Component name (e.g. "badge", "icon-star")
    raw_props: []const MD_CHAR, // Raw props from {...}. Not NUL-terminated
};

pub const BlockComponentDetail = struct {
    tag_name: Attribute,        // Component name (e.g. "alert", "card")
    raw_props: []const MD_CHAR, // Raw props from {...}
    title: []const MD_CHAR,     // Title after name (e.g. "STOP" in :::danger STOP)
};

pub const BlockTemplateDetail = struct {
    name: Attribute,        // Slot name (e.g. "header", "footer")
};

pub const BlockAlertDetail = struct {
    type_name: Attribute, // Alert type (e.g. "NOTE", "WARNING")
};

pub const BlockFootnoteDefDetail = struct {
    id: c_uint,             // 1-based id, assigned in first-reference order
    ref_count: c_uint,      // How many references resolved to this definition
    label: Attribute,       // Raw label text, e.g. "1" or "note"
};

pub const SpanFootnoteRefDetail = struct {
    id: c_uint,             // 1-based id of the referenced footnote
    ref_id: c_uint,         // 1-based ordinal of THIS reference among that
                            // footnote's references (for a unique backref anchor)
    label: Attribute,       // Raw label text, e.g. "1" or "note"
};

SpanADetail and SpanImgDetail are no longer layout-compatible (they are auto-layout structs now). Nothing relies on that any more either: the parser's shared link/image builder projects an SpanADetail onto the .img arm explicitly instead of handing over a pointer for the renderer to blind-cast.

Attribute

String attribute for non-text-flow content (titles, URLs, etc.) that may contain mixed substrings (normal text + entities):

pub const Attribute = struct {
    text: []const MD_CHAR = &.{},
    substr_types: []const TextType = &.{},      // One entry per substring
    substr_offsets: []const MD_OFFSET = &.{},   // substr_types.len + 1 entries

    /// text.len as the MD_SIZE the offset tables are expressed in.
    pub fn size(self: Attribute) MD_SIZE { ... }
};

Invariants: substr_offsets.len == substr_types.len + 1, substr_offsets[0] == 0, substr_offsets[substr_types.len] == size(). Only .normal, .entity, and .nullchar substrings appear.

An unset attribute is the default value — empty text with both tables empty (the only case where the len + 1 invariant does not hold, since there is no substring table at all). It replaces the old text == NULL test: the builder never produces a non-empty text with a zero size, so "empty" and "absent" were already the same thing. Walk the substrings with a bounded loop:

const total = attr.size();
var i: usize = 0;
while (i < attr.substr_types.len and attr.substr_offsets[i] < total) : (i += 1) {
    const ttype = attr.substr_types[i];
    const part  = attr.text[attr.substr_offsets[i]..attr.substr_offsets[i + 1]];
    // ...
}

No parser flags

There is no parser flag word. md4x has exactly one dialect and every extension is unconditionally on, so there is nothing to configure: no MD_FLAG_* / MD_DIALECT_* constants, no Parser.flags field, and no parser-flags parameter on any entry point.

See compatibility.md for what that one dialect is, and .agents/github-parity.md for why there is only one.