Parser API (src/abi.zig)
August 15, 2026 · View on GitHub
There is no public C ABI. MD4X is a Zig library;
src/abi.zigis the single source of truth for the shared parser types, enums, flags, and theParsercallback table.
- Detail types (
Attribute,Block*Detail,Span*Detail) are ordinary Zig structs with compiler-chosen layout — slices instead of pointer +*_size/*_countpairs, andboolinstead ofc_intfor the two-state members. An absent value is the empty slice; the parser never distinguished null from empty.- The type codes are real Zig enums —
BlockType,SpanType,TextType,Align— and the details reach callbacks only through the tagged unionsBlockDetail/SpanDetail, so a renderer resolves them with an exhaustiveswitchrather than an unchecked@ptrCastof a?*anyopaque. The enums keep the numeric values and declaration order of the C enumerations they replace.- The callback table is the plain Zig
Parserstruct — noextern, nocallconv(.c), and noabi_version/syntaxfield orMD_RENDERERalias.- 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_logis 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-bitMD_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 — seedocs/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)
| Type | HTML | Union 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)
| Type | HTML | Union 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)
| Type | Description |
|---|---|
.normal | Normal text |
.nullchar | NULL character (replace with U+FFFD) |
.br | Hard line break (<br>) |
.softbr | Soft line break |
.entity | HTML entity ( , Ӓ, ካ) |
.code | Text inside code block/span (\n for newlines, no BR events) |
.html | Raw HTML text (\n for newlines in block-level HTML) |
.latexmath | Text 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.