//! The AsciiDoc TCK's ASG (Abstract Semantic Graph) codec — `decode` turns one
//! expected ASG (`testdata/asciidoc-tck-corpus.json`'s `cases[].asg`, JSON
//! straight from the AsciiDoc Language Working Group's TCK) into twig's shared
//! `Document`; `encode` turns it back. The conformance harness
//! (`conformance.zig`) asserts `encode(decode(x)) == x` (structurally, via
//! `jsonValueEql` — see below for why not byte-for-byte) over the whole vendored
//! corpus, exactly as `languages/rst/doctree.zig` does for docutils' pformat.
//! There is no AsciiDoc parser yet, so this is what earns the harness its keep
//! before one exists: it proves the ASG's shapes have somewhere to live in
//! twig's vocabulary, and tallies how much of it maps to a semantic `Kind`
//! (`Coverage`) versus the `container` escape hatch.
//!
//! ── Why structural comparison, not byte-for-byte ────────────────────────────
//! rST's pformat is a bespoke text grammar with no whitespace freedom, so
//! `doctree.zig` compares bytes. The TCK's ASG is already JSON — a format with
//! no canonical key order or spacing — so pinning this codec's `encode` to the
//! TCK's own pretty-printer choices would be testing a formatting accident, not
//! the tree. `encode` instead writes ASG-shaped JSON (via the same
//! `std.json.Stringify` writer style as `ast/json.zig`), and the harness parses
//! both sides back into `std.json.Value` and compares with `jsonValueEql`,
//! which treats objects as unordered key sets and arrays as ordered.
//!
//! ── Location, and why this codec doesn't dodge it ───────────────────────────
//! `ast/ast.zig`'s rule is "this file holds MEANING, not POSITION" — position
//! belongs in `Document`'s span side-tables. rST never had to populate them
//! (`pformat` carries no positions at all); the ASG carries one on EVERY node,
//! as an inclusive `[{line,col},{line,col}]` pair (1-based). Dropping it would
//! make this codec lossy for no reason, so `decode` converts each location to a
//! byte `Span` against the case's own `.adoc` source (`offsetOfLineCol`) and
//! `encode` converts back (`lineColOfOffset`) — real use of the position half
//! of twig's architecture, not a hack bolted on for this one format.
//!
//! ── The `name` attribute, and why every block carries one ──────────────────
//! The ASG's block vocabulary is finer than twig's shared `Kind` on purpose:
//! four of its leaf blocks (`listing`, `literal`, `pass`, `stem`) are all
//! "a block whose payload is opaque text", which is exactly one `Kind`
//! (`code_block`) — the same collapse `rst/doctree.zig` made when it mapped
//! docutils' `literal_block` there too. Rather than switch on `Kind` in one
//! direction and on a name in the other, every block this codec produces
//! carries its ASG `name` in `attrs` beside `form`/`delimiter`, and `encode`
//! reads it back from that one place. A `Kind` still decides the SHAPE (leaf
//! versus parent versus break); the name only decides which of the shape's
//! several ASG spellings it was.
//!
//! ── What doesn't have a semantic `Kind` ────────────────────────────────────
//! These fall to the generic escape hatch, exactly as `doctree.zig`'s unmapped
//! docutils elements do, and count `.generic` in `Coverage`:
//! - `example`, `sidebar`, `open` and `admonition` ->
//! `Kind.container{.name=...}`. Twig has no counterpart for any of them
//! (`quote`, alone among the parent blocks, does: `Kind.block_quote`).
//! - `break` with `variant: page` -> `Kind.container{.name="page-break"}`.
//! Its sibling `variant: thematic` is `Kind.thematic_break`, but a page
//! break is not a thematic one wearing a different hat: a thematic break
//! is a rule in the document's own body, while `<<<` is an instruction to
//! the PAGINATOR that renders as nothing at all in a flowed format.
//! - the document's `attributes` dict (`:name: value` entries) -> a
//! synthetic `Kind.container{.name="document-attributes"}` marker, first
//! child of `doc` when the key was present in the source (even empty —
//! `{}`), so its mere presence (not its `Attrs` table, which collapses
//! empty-vs-absent to the same `null`, see `Builder.setAttrs`) is the
//! round-trip signal for whether to emit the JSON key at all.
//!
//! A document's title (`header.title`) DOES get a semantic `Kind.heading`
//! (`level = 0`, distinguishing it from a real section's `level >= 1`) since
//! that's exactly what it is.
const std = @import("std");
const Allocator = std.mem.Allocator;
const Writer = std.Io.Writer;
const AST = @import("../../ast/ast.zig");
const Node = AST.Node;
const Document = @import("../../document.zig");
const Span = @import("../../span.zig");
/// Which JSON shape a case's `asg` field holds — the TCK's two test levels.
/// Named to dodge the `inline` keyword, not for any deeper reason.
pub const Root = enum { document, inlines };
/// Vocabulary coverage, tallied by `decode`. Mirrors `doctree.Coverage`'s
/// purpose at 1/500th its vocabulary: `semantic` instances decoded to a kind
/// with real meaning in twig's model, `generic` instances fell to
/// `Kind.container` (see this file's doc comment for which two constructs),
/// and `text_nodes` is `semantic`'s `str` subset, broken out the same way
/// `doctree.Coverage.text_nodes` is.
pub const Coverage = struct {
semantic: u32 = 0,
generic: u32 = 0,
text_nodes: u32 = 0,
};
fn bump(coverage: ?*Coverage, comptime field: []const u8) void {
if (coverage) |c| @field(c, field) += 1;
}
/// A decode failure: an ASG shape the 13-case corpus doesn't yet exercise
/// (an ordered list, a `span` variant other than `strong`/`emphasis`/`mark`/
/// `code`, ...). Surfaced as a
/// normal error rather than `unreachable` so a TCK refresh that adds coverage
/// fails the harness loudly instead of crashing the test binary — the same
/// posture `rst/doctree.zig`'s `DecodeError` takes.
pub const DecodeError = error{UnsupportedAsgNode} || Allocator.Error;
const LineCol = struct { line: u32, col: u32 };
/// Convert a 1-based `(line, col)` to a 0-based byte offset into `source`.
/// `col` counts bytes, matching the corpus (pure ASCII throughout).
fn offsetOfLineCol(source: []const u8, line: u32, col: u32) usize {
var l: u32 = 1;
var i: usize = 0;
while (l < line) : (l += 1) {
i = (std.mem.indexOfScalarPos(u8, source, i, '\n') orelse unreachable) + 1;
}
return i + (col - 1);
}
/// The inverse of `offsetOfLineCol`.
fn lineColOfOffset(source: []const u8, offset: usize) LineCol {
var line: u32 = 1;
var line_start: usize = 0;
for (source[0..offset], 0..) |ch, i| {
if (ch == '\n') {
line += 1;
line_start = i + 1;
}
}
return .{ .line = line, .col = @intCast(offset - line_start + 1) };
}
/// An ASG `"location": [{line,col},{line,col}]` value, both endpoints
/// inclusive, converted to a half-open byte `Span`.
fn spanFromLoc(source: []const u8, loc: std.json.Value) Span {
const start = loc.array.items[0].object;
const end = loc.array.items[1].object;
const s = offsetOfLineCol(source, @intCast(start.get("line").?.integer), @intCast(start.get("col").?.integer));
const e = offsetOfLineCol(source, @intCast(end.get("line").?.integer), @intCast(end.get("col").?.integer));
return Span.init(s, e + 1);
}
fn obj(v: std.json.Value) std.json.ObjectMap {
return v.object;
}
fn arr(v: std.json.Value) []const std.json.Value {
return v.array.items;
}
fn str(v: std.json.Value) []const u8 {
return v.string;
}
// ── decode ──────────────────────────────────────────────────────────────────
/// Decode `value` (a case's `asg` field, shaped by `root`) against its own
/// `.adoc` source into an owned `Document`. `source` is COPIED nowhere further
/// than `Document.source` borrows it — keep it alive as long as the returned
/// `Document`, exactly like every other parser's `Document` contract.
pub fn decode(
allocator: Allocator,
source: []const u8,
root: Root,
value: std.json.Value,
coverage: ?*Coverage,
) DecodeError!Document {
var b = AST.Builder.init(allocator);
errdefer b.deinit();
const root_id = switch (root) {
.document => try decodeDocument(&b, source, value, coverage),
.inlines => blk: {
const ids = try decodeInlineList(&b, source, arr(value), coverage);
defer allocator.free(ids);
break :blk try b.addContainer(.doc, ids);
},
};
return b.finishDocument(source, root_id);
}
fn decodeDocument(b: *AST.Builder, source: []const u8, value: std.json.Value, coverage: ?*Coverage) DecodeError!Node.Id {
const o = obj(value);
var children = std.ArrayList(Node.Id).empty;
defer children.deinit(b.allocator);
if (o.get("attributes")) |attrs_val| {
const marker = try b.addNode(.{ .container = .{ .name = "document-attributes" } });
var entries = std.ArrayList(AST.KeyVal).empty;
defer entries.deinit(b.allocator);
var it = obj(attrs_val).iterator();
while (it.next()) |e| {
// A null value is an UNSET attribute (`:!name:`), which is not the
// same document as `:name:` with an empty value — `Attrs` already
// draws exactly that distinction, so it carries straight over.
try entries.append(b.allocator, .{
.key = e.key_ptr.*,
.value = switch (e.value_ptr.*) {
.null => null,
.string => |s| s,
else => return error.UnsupportedAsgNode,
},
});
}
try b.setAttrs(marker, .{ .entries = entries.items });
bump(coverage, "generic");
try children.append(b.allocator, marker);
}
if (o.get("header")) |header_val| {
const ho = obj(header_val);
const title_ids = try decodeInlineList(b, source, arr(ho.get("title").?), coverage);
defer b.allocator.free(title_ids);
// `1`, not the ASG's `0` — twig's shared `heading.level` is 1-based
// across every language. `writeSection` shifts back on the way out.
const heading = try b.addContainer(.{ .heading = .{ .level = 1 } }, title_ids);
b.setSpan(heading, spanFromLoc(source, ho.get("location").?));
bump(coverage, "semantic");
try children.append(b.allocator, heading);
}
if (o.get("blocks")) |blocks_val| {
for (arr(blocks_val)) |block_val| {
try children.append(b.allocator, try decodeBlock(b, source, block_val, coverage));
}
}
const id = try b.addContainer(.doc, children.items);
b.setSpan(id, spanFromLoc(source, o.get("location").?));
bump(coverage, "semantic");
return id;
}
fn decodeBlock(b: *AST.Builder, source: []const u8, value: std.json.Value, coverage: ?*Coverage) DecodeError!Node.Id {
const o = obj(value);
const name = str(o.get("name").?);
if (std.mem.eql(u8, name, "paragraph")) {
const inlines = try decodeInlineList(b, source, arr(o.get("inlines").?), coverage);
defer b.allocator.free(inlines);
const id = try b.addContainer(.para, inlines);
b.setSpan(id, spanFromLoc(source, o.get("location").?));
bump(coverage, "semantic");
return id;
}
if (std.mem.eql(u8, name, "list")) {
if (!std.mem.eql(u8, str(o.get("variant").?), "unordered")) return error.UnsupportedAsgNode;
var items = std.ArrayList(Node.Id).empty;
defer items.deinit(b.allocator);
for (arr(o.get("items").?)) |item| try items.append(b.allocator, try decodeListItem(b, source, item, coverage));
const id = try b.addContainer(.{ .bullet_list = .{ .tight = true } }, items.items);
b.setSpan(id, spanFromLoc(source, o.get("location").?));
if (o.get("marker")) |m| b.setSpelling(id, .{ .bullet = try bulletFromMarker(str(m)) });
bump(coverage, "semantic");
return id;
}
if (isVerbatimLeaf(name)) {
// An empty delimited block has NO `inlines` key (the schema's own
// `defaults` block spells the absent value as `[]`, and the TCK's
// output files always take the absent spelling). Its content span
// stays unset, which is what `encode` reads back to decide the same.
const inlines = if (o.get("inlines")) |v| arr(v) else &[_]std.json.Value{};
var text = std.ArrayList(u8).empty;
defer text.deinit(b.allocator);
var inner: ?Span = null;
for (inlines) |inl| {
const io = obj(inl);
try text.appendSlice(b.allocator, str(io.get("value").?));
const sp = spanFromLoc(source, io.get("location").?);
inner = if (inner) |s| Span.init(s.start, sp.end) else sp;
}
const id = try b.addLeaf(.{ .code_block = .{ .lang = null, .text = text.items } });
b.setSpan(id, spanFromLoc(source, o.get("location").?));
if (inner) |sp| b.setContentSpan(id, sp);
// No `name`/`form`/`delimiter` attrs: `encode` derives all three from
// the block's own source. See `blockDelimiter`.
// All four count `semantic`: `Coverage`'s split is "decoded to a kind
// with real meaning" versus "fell to `Kind.container`", and
// `code_block` is the former for every one of them.
bump(coverage, "semantic");
return id;
}
if (std.mem.eql(u8, name, "break")) {
const variant = str(o.get("variant").?);
const id = if (std.mem.eql(u8, variant, "thematic"))
try b.addNode(.thematic_break)
else if (std.mem.eql(u8, variant, "page"))
try b.addNode(.{ .container = .{ .name = "page-break" } })
else
return error.UnsupportedAsgNode;
b.setSpan(id, spanFromLoc(source, o.get("location").?));
if (std.mem.eql(u8, variant, "thematic")) bump(coverage, "semantic") else bump(coverage, "generic");
return id;
}
if (isCompoundBlock(name)) {
var children = std.ArrayList(Node.Id).empty;
defer children.deinit(b.allocator);
if (o.get("blocks")) |blocks_val| {
for (arr(blocks_val)) |block_val| {
try children.append(b.allocator, try decodeBlock(b, source, block_val, coverage));
}
}
const semantic = std.mem.eql(u8, name, "quote");
const kind: Node.Kind = if (semantic) .block_quote else .{ .container = .{ .name = name } };
const id = try b.addContainer(kind, children.items);
b.setSpan(id, spanFromLoc(source, o.get("location").?));
// No `name`/`form`/`delimiter` attrs — `name` is already in the `Kind`
// (`parentBlockName`), the other two derive from source.
if (semantic) bump(coverage, "semantic") else bump(coverage, "generic");
return id;
}
if (std.mem.eql(u8, name, "section")) {
const level: u32 = @intCast(o.get("level").?.integer);
const title_ids = try decodeInlineList(b, source, arr(o.get("title").?), coverage);
defer b.allocator.free(title_ids);
const heading = try b.addContainer(.{ .heading = .{ .level = level + 1 } }, title_ids);
bump(coverage, "semantic");
var children = std.ArrayList(Node.Id).empty;
defer children.deinit(b.allocator);
try children.append(b.allocator, heading);
if (o.get("blocks")) |blocks_val| {
for (arr(blocks_val)) |block_val| {
try children.append(b.allocator, try decodeBlock(b, source, block_val, coverage));
}
}
const id = try b.addContainer(.section, children.items);
b.setSpan(id, spanFromLoc(source, o.get("location").?));
bump(coverage, "semantic");
return id;
}
return error.UnsupportedAsgNode;
}
/// The ASG's four opaque-payload leaf blocks. All four become `code_block`:
/// twig's model of "a block whose payload is opaque text" fits every one of
/// them, and they differ only in what a renderer is meant to do with that text
/// — the same call `rst/doctree.zig` made when it mapped docutils'
/// `literal_block` onto `code_block` too. Which of the four it was rides in
/// `attrs` beside `form`/`delimiter`, so nothing is lost on the way back out.
fn isVerbatimLeaf(name: []const u8) bool {
inline for (.{ "listing", "literal", "pass", "stem" }) |n| {
if (std.mem.eql(u8, name, n)) return true;
}
return false;
}
/// The ASG's `parentBlock` names — blocks whose payload is other blocks.
fn isCompoundBlock(name: []const u8) bool {
inline for (.{ "example", "sidebar", "open", "quote", "admonition" }) |n| {
if (std.mem.eql(u8, name, n)) return true;
}
return false;
}
fn decodeListItem(b: *AST.Builder, source: []const u8, value: std.json.Value, coverage: ?*Coverage) DecodeError!Node.Id {
const o = obj(value);
const principal = try decodeInlineList(b, source, arr(o.get("principal").?), coverage);
defer b.allocator.free(principal);
const id = try b.addContainer(.list_item, principal);
b.setSpan(id, spanFromLoc(source, o.get("location").?));
if (o.get("marker")) |m| b.setSpelling(id, .{ .bullet = try bulletFromMarker(str(m)) });
bump(coverage, "semantic");
return id;
}
fn bulletFromMarker(marker: []const u8) DecodeError!Document.Spelling.Bullet {
if (std.mem.eql(u8, marker, "*")) return .star;
if (std.mem.eql(u8, marker, "-")) return .dash;
if (std.mem.eql(u8, marker, "+")) return .plus;
return error.UnsupportedAsgNode;
}
fn decodeInlineList(b: *AST.Builder, source: []const u8, items: []const std.json.Value, coverage: ?*Coverage) DecodeError![]Node.Id {
var ids = std.ArrayList(Node.Id).empty;
errdefer ids.deinit(b.allocator);
for (items) |item| try ids.append(b.allocator, try decodeInline(b, source, item, coverage));
return ids.toOwnedSlice(b.allocator);
}
fn decodeInline(b: *AST.Builder, source: []const u8, value: std.json.Value, coverage: ?*Coverage) DecodeError!Node.Id {
const o = obj(value);
const name = str(o.get("name").?);
if (std.mem.eql(u8, name, "text")) {
const id = try b.addLeaf(.{ .str = str(o.get("value").?) });
b.setSpan(id, spanFromLoc(source, o.get("location").?));
bump(coverage, "semantic");
bump(coverage, "text_nodes");
return id;
}
if (std.mem.eql(u8, name, "span")) {
const variant = str(o.get("variant").?);
// `code` (monospace) shares the `span` shape with the three
// `inline_mark` variants below but decodes to a `text_leaf{.verbatim}`
// LEAF instead: `AST.InlineMark` is deliberately scoped to exactly the
// marks djot itself spells (see `languages/djot/syntax.zig`'s doc
// comment), so monospace doesn't grow that enum for one AsciiDoc-only
// member. That does mean a `code` span whose `inlines` isn't exactly
// one `text` node — real nested formatting inside monospace — has
// nowhere to go yet.
if (std.mem.eql(u8, variant, "code")) {
const inlines = arr(o.get("inlines").?);
if (inlines.len != 1) return error.UnsupportedAsgNode;
const inner = obj(inlines[0]);
if (!std.mem.eql(u8, str(inner.get("name").?), "text")) return error.UnsupportedAsgNode;
const id = try b.addLeaf(.{ .text_leaf = .{ .kind = .verbatim, .text = str(inner.get("value").?) } });
b.setSpan(id, spanFromLoc(source, o.get("location").?));
b.setContentSpan(id, spanFromLoc(source, inner.get("location").?));
// No `form` attr: `encode` derives it from the span's own source.
// See `spanForm`.
bump(coverage, "semantic");
return id;
}
const mark = markFromVariant(variant) orelse return error.UnsupportedAsgNode;
const inlines = try decodeInlineList(b, source, arr(o.get("inlines").?), coverage);
defer b.allocator.free(inlines);
const id = try b.addContainer(.{ .inline_mark = mark }, inlines);
b.setSpan(id, spanFromLoc(source, o.get("location").?));
// No `form` attr — `encode` derives it from source. See `spanForm`.
bump(coverage, "semantic");
return id;
}
return error.UnsupportedAsgNode;
}
/// A delimited block's opening delimiter — its first source line, which IS the
/// delimiter. Derived rather than stored, for the reason `spanForm` gives.
///
/// It could not be a per-name constant even if we wanted one: the corpus
/// carries a `listing` opened with `----` and another opened with `-----`, and
/// the ASG records what the source actually wrote.
fn blockDelimiter(doc: *const Document, id: Node.Id) []const u8 {
const s = doc.text(id);
return s[0 .. std.mem.indexOfScalar(u8, s, '\n') orelse s.len];
}
/// The ASG `name` of a `code_block` — `listing`, `literal` or `pass`. All three
/// share one `Kind`, so the delimiter is the only thing that tells them apart.
/// The parent blocks need no equivalent: their name is already in
/// `Kind.container`, or they are the one with a semantic kind of its own
/// (`quote`). See `parentBlockName`.
fn codeBlockName(delimiter: []const u8) []const u8 {
return switch (delimiter[0]) {
'-' => "listing",
'.' => "literal",
'+' => "pass",
else => unreachable, // `parser.zig`'s delimiter table admits no other
};
}
/// The ASG `name` of a parent block, from its `Kind` alone.
fn parentBlockName(doc: *const Document, id: Node.Id) []const u8 {
return switch (doc.ast.nodes[id].kind) {
.block_quote => "quote",
.container => |c| c.name,
else => unreachable, // `writeBlock` routes only these two here
};
}
/// A span's ASG `form`, read back off the source rather than stored anywhere.
///
/// An unconstrained span is spelled with a DOUBLED delimiter (`**bold**` beside
/// `*bold*`), so a span's own first two source bytes settle the question, and
/// both the decoder and the parser can stay silent about it. That silence is
/// the point: `AST.Attrs` is the channel a document's REAL attributes travel
/// in, and `languages/html/serializer.zig` renders every entry it finds there
/// straight into the output — so parking codec bookkeeping in `attrs` puts
/// `<strong form="unconstrained">` in rendered HTML. Deriving costs one
/// comparison and cannot drift from what the source actually says.
fn spanForm(doc: *const Document, id: Node.Id) []const u8 {
const s = doc.text(id);
// The unconstrained spelling is exactly `DD` + at least one byte + `DD`, so
// this tests precisely that rather than sniffing the opening pair alone.
// BOTH ends have to be checked: a CONSTRAINED span whose interior merely
// begins with the delimiter opens with two delimiter bytes and closes with
// one (`**bold*` — the doubled-opener-with-no-doubled-close case, where the
// interior is `*bold`), and the minimum length rules out the `***` that
// `****` parses to.
const unconstrained = s.len >= 5 and
s[0] == s[1] and
s[s.len - 1] == s[s.len - 2] and
s[0] == s[s.len - 1];
return if (unconstrained) "unconstrained" else "constrained";
}
/// The ASG `span` variants that become an `AST.InlineMark` — everything
/// except `code`, which `decodeInline`/`writeInline` handle separately (see
/// there for why). `emphasis` doesn't match `AST.InlineMark.emph`'s own
/// spelling; `strong` and `mark` happen to.
fn markFromVariant(variant: []const u8) ?AST.InlineMark {
if (std.mem.eql(u8, variant, "strong")) return .strong;
if (std.mem.eql(u8, variant, "emphasis")) return .emph;
if (std.mem.eql(u8, variant, "mark")) return .mark;
return null;
}
/// The inverse of `markFromVariant`, total over the three marks AsciiDoc's
/// codec ever produces — `unreachable` on anything else is a bug in this
/// file's own `decodeInline`, not a document twig legitimately can't print.
fn variantFromMark(mark: AST.InlineMark) []const u8 {
return switch (mark) {
.strong => "strong",
.emph => "emphasis",
.mark => "mark",
else => unreachable,
};
}
// ── encode ──────────────────────────────────────────────────────────────────
/// Write `doc` back out as ASG-shaped JSON, `root`-dependent at the top level
/// exactly as `decode` was. This is `decode`'s exact inverse, not a general
/// AST-to-ASG printer — it switches only over the shapes `decode` itself
/// produces (see this file's doc comment), and hitting anything else is a bug
/// in this file rather than a document twig legitimately can't print.
pub fn encode(doc: *const Document, root: Root, writer: *Writer) Writer.Error!void {
var w: std.json.Stringify = .{ .writer = writer, .options = .{ .whitespace = .indent_2 } };
switch (root) {
.document => try writeDocument(&w, doc, doc.ast.root),
.inlines => {
try w.beginArray();
var it = doc.children(doc.ast.root);
while (it.next()) |c| try writeInline(&w, doc, c.id);
try w.endArray();
},
}
}
pub fn encodeAlloc(allocator: Allocator, doc: *const Document, root: Root) Allocator.Error![]u8 {
var out: Writer.Allocating = .init(allocator);
defer out.deinit();
encode(doc, root, &out.writer) catch |err| switch (err) {
error.WriteFailed => return error.OutOfMemory,
};
return out.toOwnedSlice();
}
fn writePoint(w: *std.json.Stringify, source: []const u8, offset: usize) Writer.Error!void {
const lc = lineColOfOffset(source, offset);
try w.beginObject();
try w.objectField("line");
try w.write(lc.line);
try w.objectField("col");
try w.write(lc.col);
try w.endObject();
}
/// Write `"location": [...]` from `id`'s span, converting the half-open byte
/// `Span` back to the ASG's inclusive `(line, col)` pair.
fn writeLoc(w: *std.json.Stringify, doc: *const Document, id: Node.Id) Writer.Error!void {
const span = doc.span(id);
// An EMPTY span has no location to write: the ASG's endpoints are both
// inclusive, so a zero-width extent cannot be spelled at all. This is the
// degenerate case only — an empty document, or a document of nothing but
// blank lines — and `location` is optional throughout the schema, so
// omitting it is legal rather than a dodge.
if (span.end <= span.start) return;
try w.objectField("location");
try w.beginArray();
try writePoint(w, doc.source, span.start);
try writePoint(w, doc.source, span.end - 1);
try w.endArray();
}
fn attrGet(attrs: AST.Attrs, key: []const u8) ?[]const u8 {
const kv = attrs.find(key) orelse return null;
return kv.value;
}
fn bulletMarker(sp: ?Document.Spelling) []const u8 {
return switch (sp.?.bullet) {
.dash => "-",
.plus => "+",
.star => "*",
};
}
fn writeDocument(w: *std.json.Stringify, doc: *const Document, id: Node.Id) Writer.Error!void {
try w.beginObject();
try w.objectField("name");
try w.write("document");
try w.objectField("type");
try w.write("block");
var attrs_id: ?Node.Id = null;
var header_id: ?Node.Id = null;
var has_body = false;
{
var it = doc.children(id);
while (it.next()) |c| {
switch (doc.ast.nodes[c.id].kind) {
.container => |cnt| if (std.mem.eql(u8, cnt.name, "document-attributes")) {
attrs_id = c.id;
continue;
},
// The document title, in the shared tree's 1-based numbering
// (the ASG calls this level 0). A section's own title is never
// seen here — it hangs off the `section` container, not the
// document root — so this only ever matches the header.
.heading => |h| if (h.level == 1) {
header_id = c.id;
continue;
},
else => {},
}
has_body = true;
}
}
if (attrs_id) |aid| {
try w.objectField("attributes");
try w.beginObject();
for (doc.ast.attrsOf(aid).entries) |kv| {
try w.objectField(kv.key);
try w.write(kv.value); // null — an unset attribute — writes as JSON null
}
try w.endObject();
}
if (header_id) |hid| {
try w.objectField("header");
try w.beginObject();
try w.objectField("title");
try w.beginArray();
var it = doc.children(hid);
while (it.next()) |c| try writeInline(w, doc, c.id);
try w.endArray();
try writeLoc(w, doc, hid);
try w.endObject();
}
if (has_body) {
try w.objectField("blocks");
try w.beginArray();
var it = doc.children(id);
if (attrs_id != null) _ = it.next();
if (header_id != null) _ = it.next();
while (it.next()) |c| try writeBlock(w, doc, c.id);
try w.endArray();
}
try writeLoc(w, doc, id);
try w.endObject();
}
fn writeBlock(w: *std.json.Stringify, doc: *const Document, id: Node.Id) Writer.Error!void {
switch (doc.ast.nodes[id].kind) {
.para => {
try w.beginObject();
try w.objectField("name");
try w.write("paragraph");
try w.objectField("type");
try w.write("block");
try w.objectField("inlines");
try w.beginArray();
var it = doc.children(id);
while (it.next()) |c| try writeInline(w, doc, c.id);
try w.endArray();
try writeLoc(w, doc, id);
try w.endObject();
},
.bullet_list => {
try w.beginObject();
try w.objectField("name");
try w.write("list");
try w.objectField("type");
try w.write("block");
try w.objectField("variant");
try w.write("unordered");
try w.objectField("marker");
try w.write(bulletMarker(doc.spelling(id)));
try w.objectField("items");
try w.beginArray();
var it = doc.children(id);
while (it.next()) |c| try writeListItem(w, doc, c.id);
try w.endArray();
try writeLoc(w, doc, id);
try w.endObject();
},
.code_block => |cb| {
try w.beginObject();
const delimiter = blockDelimiter(doc, id);
try w.objectField("name");
try w.write(codeBlockName(delimiter));
try w.objectField("type");
try w.write("block");
try w.objectField("form");
try w.write("delimited");
try w.objectField("delimiter");
try w.write(delimiter);
if (cb.text.len > 0) {
try w.objectField("inlines");
try w.beginArray();
try w.beginObject();
try w.objectField("type");
try w.write("string");
try w.objectField("name");
try w.write("text");
try w.objectField("value");
try w.write(cb.text);
const cs = doc.contentSpan(id).?;
try w.objectField("location");
try w.beginArray();
try writePoint(w, doc.source, cs.start);
try writePoint(w, doc.source, cs.end - 1);
try w.endArray();
try w.endObject();
try w.endArray();
}
try writeLoc(w, doc, id);
try w.endObject();
},
.section => {
try w.beginObject();
try w.objectField("name");
try w.write("section");
try w.objectField("type");
try w.write("block");
var it = doc.children(id);
const heading_id = it.next().?.id;
try w.objectField("title");
try w.beginArray();
var hit = doc.children(heading_id);
while (hit.next()) |c| try writeInline(w, doc, c.id);
try w.endArray();
try w.objectField("level");
// Back to the ASG's 0-based numbering — the shared tree stores
// 1-based levels. See `decodeBlock`'s section arm.
try w.write(doc.ast.nodes[heading_id].kind.heading.level - 1);
try writeLoc(w, doc, id);
var probe = it;
if (probe.next() != null) {
try w.objectField("blocks");
try w.beginArray();
while (it.next()) |c| try writeBlock(w, doc, c.id);
try w.endArray();
}
try w.endObject();
},
.thematic_break => {
try w.beginObject();
try w.objectField("name");
try w.write("break");
try w.objectField("type");
try w.write("block");
try w.objectField("variant");
try w.write("thematic");
try writeLoc(w, doc, id);
try w.endObject();
},
// A page break has no `Kind` of its own — twig's `thematic_break` is a
// horizontal rule in the document's own body, while `<<<` is an
// instruction to the PAGINATOR and renders as nothing at all in a
// flowed format. See this file's doc comment.
.container => |cnt| if (std.mem.eql(u8, cnt.name, "page-break")) {
try w.beginObject();
try w.objectField("name");
try w.write("break");
try w.objectField("type");
try w.write("block");
try w.objectField("variant");
try w.write("page");
try writeLoc(w, doc, id);
try w.endObject();
} else try writeParentBlock(w, doc, id),
.block_quote => try writeParentBlock(w, doc, id),
else => unreachable,
}
}
/// One `parentBlock` — an example, sidebar, open block, quote or admonition.
/// Which it is comes from the `Kind` (`parentBlockName`), since two of them
/// share `Kind.container` and one has a semantic kind of its own; see
/// `parser.zig`'s `kindForCompound`.
///
/// `variant` is the one field here still read from `attrs`, and only
/// admonitions have one. Nothing sets it today — no corpus case is an
/// admonition and the parser doesn't produce them — so it never renders. When
/// admonitions do land, they need a channel that ISN'T `attrs`, for the reason
/// `spanForm` spells out.
fn writeParentBlock(w: *std.json.Stringify, doc: *const Document, id: Node.Id) Writer.Error!void {
try w.beginObject();
try w.objectField("name");
try w.write(parentBlockName(doc, id));
try w.objectField("type");
try w.write("block");
if (attrGet(doc.ast.attrsOf(id), "variant")) |v| {
try w.objectField("variant");
try w.write(v);
}
try w.objectField("form");
try w.write("delimited");
try w.objectField("delimiter");
try w.write(blockDelimiter(doc, id));
if (doc.ast.nodes[id].first_child != null) {
try w.objectField("blocks");
try w.beginArray();
var it = doc.children(id);
while (it.next()) |c| try writeBlock(w, doc, c.id);
try w.endArray();
}
try writeLoc(w, doc, id);
try w.endObject();
}
fn writeListItem(w: *std.json.Stringify, doc: *const Document, id: Node.Id) Writer.Error!void {
try w.beginObject();
try w.objectField("name");
try w.write("listItem");
try w.objectField("type");
try w.write("block");
try w.objectField("marker");
try w.write(bulletMarker(doc.spelling(id)));
try w.objectField("principal");
try w.beginArray();
var it = doc.children(id);
while (it.next()) |c| try writeInline(w, doc, c.id);
try w.endArray();
try writeLoc(w, doc, id);
try w.endObject();
}
fn writeInline(w: *std.json.Stringify, doc: *const Document, id: Node.Id) Writer.Error!void {
switch (doc.ast.nodes[id].kind) {
.str => |s| {
try w.beginObject();
try w.objectField("name");
try w.write("text");
try w.objectField("type");
try w.write("string");
try w.objectField("value");
try w.write(s);
try writeLoc(w, doc, id);
try w.endObject();
},
.inline_mark => |m| {
try w.beginObject();
try w.objectField("name");
try w.write("span");
try w.objectField("type");
try w.write("inline");
try w.objectField("variant");
try w.write(variantFromMark(m));
try w.objectField("form");
try w.write(spanForm(doc, id));
try w.objectField("inlines");
try w.beginArray();
var it = doc.children(id);
while (it.next()) |c| try writeInline(w, doc, c.id);
try w.endArray();
try writeLoc(w, doc, id);
try w.endObject();
},
// A monospace `code` span — see `decodeInline`'s doc comment for why
// this is a leaf rather than an `inline_mark`. Its interior's own
// location comes from `contentSpan`, exactly the split
// `writeBlock`'s `.code_block` arm already makes between a node's own
// span and its inner text's.
.text_leaf => |leaf| {
std.debug.assert(leaf.kind == .verbatim);
try w.beginObject();
try w.objectField("name");
try w.write("span");
try w.objectField("type");
try w.write("inline");
try w.objectField("variant");
try w.write("code");
try w.objectField("form");
try w.write(spanForm(doc, id));
try w.objectField("inlines");
try w.beginArray();
try w.beginObject();
try w.objectField("name");
try w.write("text");
try w.objectField("type");
try w.write("string");
try w.objectField("value");
try w.write(leaf.text);
const cs = doc.contentSpan(id).?;
try w.objectField("location");
try w.beginArray();
try writePoint(w, doc.source, cs.start);
try writePoint(w, doc.source, cs.end - 1);
try w.endArray();
try w.endObject();
try w.endArray();
try writeLoc(w, doc, id);
try w.endObject();
},
else => unreachable,
}
}
// ── comparison ──────────────────────────────────────────────────────────────
/// Structural equality over `std.json.Value` trees: objects compare as
/// unordered key sets, arrays compare element-by-element in order. See this
/// file's doc comment for why the harness compares this way rather than bytes.
pub fn jsonValueEql(a: std.json.Value, b: std.json.Value) bool {
if (@as(std.meta.Tag(std.json.Value), a) != @as(std.meta.Tag(std.json.Value), b)) return false;
return switch (a) {
.null => true,
.bool => |x| x == b.bool,
.integer => |x| x == b.integer,
.float => |x| x == b.float,
.number_string => |x| std.mem.eql(u8, x, b.number_string),
.string => |x| std.mem.eql(u8, x, b.string),
.array => |x| arr_blk: {
const y = b.array;
if (x.items.len != y.items.len) break :arr_blk false;
for (x.items, y.items) |ea, eb| {
if (!jsonValueEql(ea, eb)) break :arr_blk false;
}
break :arr_blk true;
},
.object => |x| obj_blk: {
const y = b.object;
if (x.count() != y.count()) break :obj_blk false;
var it = x.iterator();
while (it.next()) |e| {
const other = y.get(e.key_ptr.*) orelse break :obj_blk false;
if (!jsonValueEql(e.value_ptr.*, other)) break :obj_blk false;
}
break :obj_blk true;
},
};
}