const std = @import("std");
const builtin = @import("builtin");
const build_options = @import("build_options");
const twig = @import("root.zig");
const Allocator = std.mem.Allocator;
pub const TwigStatus = enum(c_int) {
ok = 0,
invalid_argument = 1,
parse_error = 2,
out_of_memory = 3,
unsupported_format = 4,
/// A locator resolved to no node (an out-of-bounds index path, or a
/// selector with zero matches). Editor-only.
not_found = 5,
/// A selector locator matched more than one node, so the intended target
/// is ambiguous — refine it, add `:nth(k)`, or use an index path.
/// Editor-only.
ambiguous = 6,
/// The target node has no editable span/interior: a leaf given to
/// `replace_content`, or a node whose kind the parser doesn't span yet
/// (e.g. some Markdown inline nodes). Editor-only.
not_editable = 7,
/// The edit produced a document that no longer parses; it was rolled back
/// and nothing changed. Editor-only.
edit_conflict = 8,
/// A `metadata` node's body contains `</script`, which can't be emitted
/// into a raw-text `<script>` HTML data island without breaking out of the
/// element (an injection vector). The HTML printer refused. Render/
/// serialize-to-HTML only.
unsafe_metadata = 9,
internal_error = 255,
};
pub const TwigFormat = enum(c_int) {
djot = 1,
markdown = 2,
xml = 3,
html = 4,
};
/// A byte range `[start, end)` into the source, C-ABI shape of `Span`. Used by
/// `twig_document_query` for each matched node's whole extent (`span`) and
/// interior (`content_span`).
pub const TwigSpan = extern struct {
start: usize,
end: usize,
};
/// One node matched by `twig_document_query`, the C-ABI shape of
/// `Select.Match` plus the node's kind name. `content_span` is only
/// meaningful when `has_content_span` is non-zero (a leaf, or a container the
/// parser left without a known interior, reports `has_content_span == 0` and
/// a zeroed `content_span`). `kind` is a NUL-terminated `Node.Kind` tag name
/// (e.g. `"heading"`, `"code_block"`) in static, library-owned storage — it
/// is never freed and stays valid for the process lifetime.
pub const TwigQueryMatch = extern struct {
node_id: u32,
span: TwigSpan,
content_span: TwigSpan,
has_content_span: c_int,
kind: [*:0]const u8,
};
/// The sentinel `node_id` for "no such node" in a `TwigFlatNode` link field
/// (`parent`/`first_child`/`next_sibling`) — the root has no parent, a leaf no
/// child, a last sibling no next. A real id is a `[]Node` index, always `<`
/// this value.
pub const TWIG_NO_NODE: u32 = std.math.maxInt(u32);
/// The byte-level effect of an edit, C-ABI shape of `twig.Editor.Change`.
/// `old` is the range of the pre-edit source that was replaced; `new` is the
/// range the replacement occupies in the post-edit source (same start). See
/// `twig_editor_edit_range` / `twig_editor_last_change`.
pub const TwigChange = extern struct {
old: TwigSpan,
new: TwigSpan,
};
/// One node in the editor's current tree, C-ABI shape for the flat-arena
/// snapshot `twig_editor_nodes` returns — the JSON-free read path. `id` is the
/// node's index in the arena; `parent`/`first_child`/`next_sibling` are ids or
/// `TWIG_NO_NODE`. `content_span` is meaningful only when `has_content_span`.
/// `level` is a heading's level (0 otherwise). `kind` is static, library-owned
/// storage (never freed). `text`/`destination` borrow the *node's* payload in
/// the current parse (the AST owns its own copies, not the source) and stay
/// valid until the next successful edit or `twig_editor_destroy`; each is NULL
/// when the kind carries no such payload.
pub const TwigFlatNode = extern struct {
id: u32,
parent: u32,
first_child: u32,
next_sibling: u32,
span: TwigSpan,
content_span: TwigSpan,
has_content_span: c_int,
level: u32,
kind: [*:0]const u8,
text_ptr: ?[*]const u8,
text_len: usize,
destination_ptr: ?[*]const u8,
destination_len: usize,
};
pub const TwigDocument = opaque {};
const ParsedDocument = union(TwigFormat) {
djot: twig.Djot.Document,
markdown: twig.Markdown.Document,
xml: twig.AST,
html: twig.AST,
fn deinit(self: *ParsedDocument) void {
switch (self.*) {
.djot => |*doc| doc.deinit(),
.markdown => |*doc| doc.deinit(),
.xml => |*ast| ast.deinit(),
.html => |*ast| ast.deinit(),
}
}
};
/// A parsed document plus the caller-borrowed output buffers each accessor
/// caches on it. Every buffer follows the same contract: it is owned by the
/// handle, replaced on the next call to the same accessor, and freed when the
/// handle is destroyed — so a pointer handed out stays valid until the next
/// same-accessor call on this handle or `twig_document_destroy`, whichever
/// comes first. The buffers are independent: rendering HTML never invalidates
/// a serialize/ast-json/query result and vice versa.
const DocumentHandle = struct {
parsed: ParsedDocument,
rendered: []u8 = &.{},
serialized: []u8 = &.{},
ast_json: []u8 = &.{},
query_matches: []TwigQueryMatch = &.{},
};
fn activeAllocator() Allocator {
return if (builtin.cpu.arch.isWasm())
std.heap.wasm_allocator
else
std.heap.c_allocator;
}
fn asHandle(doc: *TwigDocument) *DocumentHandle {
return @ptrCast(@alignCast(doc));
}
fn sliceOf(ptr: ?[*]const u8, len: usize) ?[]const u8 {
if (ptr) |p| return p[0..len];
if (len == 0) return &.{};
return null;
}
/// Map a raw `int` format code to a `TwigFormat`, or `null` if it names no
/// known format (the caller turns that into `unsupported_format`).
fn intToFormat(format: c_int) ?TwigFormat {
return switch (format) {
@intFromEnum(TwigFormat.djot) => .djot,
@intFromEnum(TwigFormat.markdown) => .markdown,
@intFromEnum(TwigFormat.xml) => .xml,
@intFromEnum(TwigFormat.html) => .html,
else => null,
};
}
pub export fn twig_version() u32 {
return (@as(u32, build_options.version_major) << 16) |
(@as(u32, build_options.version_minor) << 8) |
@as(u32, build_options.version_patch);
}
pub export fn twig_version_string() [*:0]const u8 {
const s = std.fmt.comptimePrint("{d}.{d}.{d}", .{
build_options.version_major,
build_options.version_minor,
build_options.version_patch,
});
return s;
}
/// The C ABI contract version, independent of the semantic `twig_version`.
///
/// It is bumped ONLY on a breaking ABI change — an `extern struct`'s layout
/// changing, or an existing enum value being renumbered. Purely additive
/// changes do NOT bump it: appending a new `TwigFormat`/`TwigStatus`/
/// `TwigNodeKind` value at the end, or adding a new `twig_*` function (e.g. a
/// future `twig_editor_undo`), leaves every existing symbol binary-compatible,
/// so an older consumer keeps working. See the "ABI stability" contract in
/// `bindings/c/include/twig.h`.
///
/// A consumer records the `TWIG_ABI_VERSION` it compiled against and can call
/// `twig_abi_version` at load time to confirm the library it linked speaks the
/// same layout.
pub const TWIG_ABI_VERSION: u32 = 1;
pub export fn twig_abi_version() u32 {
return TWIG_ABI_VERSION;
}
// Freeze the canonical 64-bit C-ABI layout of every `extern struct` the
// bindings mirror. A field reordered, retyped, inserted, or removed shifts an
// offset or the size and fails the build here — turning a silent, memory-
// corrupting drift between this file and `twig.h`/`ffi.rs` into a compile
// error. Any *intentional* change to these numbers must also bump
// `TWIG_ABI_VERSION` and the mirrored `assert!`s in `ffi.rs`.
//
// Gated on 64-bit because the offsets below are the LP64/LLP64 layout (the
// shipped desktop/mobile targets); on a 32-bit target (e.g. `wasm32`) C-ABI
// rules still make Zig and the bindings agree, they just pack tighter, so the
// absolute numbers wouldn't apply.
comptime {
if (@sizeOf(usize) == 8) {
const assert = std.debug.assert;
assert(@sizeOf(TwigSpan) == 16);
assert(@offsetOf(TwigSpan, "start") == 0);
assert(@offsetOf(TwigSpan, "end") == 8);
assert(@sizeOf(TwigQueryMatch) == 56);
assert(@offsetOf(TwigQueryMatch, "node_id") == 0);
assert(@offsetOf(TwigQueryMatch, "span") == 8);
assert(@offsetOf(TwigQueryMatch, "content_span") == 24);
assert(@offsetOf(TwigQueryMatch, "has_content_span") == 40);
assert(@offsetOf(TwigQueryMatch, "kind") == 48);
assert(@sizeOf(TwigChange) == 32);
assert(@offsetOf(TwigChange, "old") == 0);
assert(@offsetOf(TwigChange, "new") == 16);
assert(@sizeOf(TwigFlatNode) == 96);
assert(@offsetOf(TwigFlatNode, "id") == 0);
assert(@offsetOf(TwigFlatNode, "parent") == 4);
assert(@offsetOf(TwigFlatNode, "first_child") == 8);
assert(@offsetOf(TwigFlatNode, "next_sibling") == 12);
assert(@offsetOf(TwigFlatNode, "span") == 16);
assert(@offsetOf(TwigFlatNode, "content_span") == 32);
assert(@offsetOf(TwigFlatNode, "has_content_span") == 48);
assert(@offsetOf(TwigFlatNode, "level") == 52);
assert(@offsetOf(TwigFlatNode, "kind") == 56);
assert(@offsetOf(TwigFlatNode, "text_ptr") == 64);
assert(@offsetOf(TwigFlatNode, "text_len") == 72);
assert(@offsetOf(TwigFlatNode, "destination_ptr") == 80);
assert(@offsetOf(TwigFlatNode, "destination_len") == 88);
assert(@sizeOf(TwigKeyVal) == 32);
assert(@offsetOf(TwigKeyVal, "key") == 0);
assert(@offsetOf(TwigKeyVal, "key_len") == 8);
assert(@offsetOf(TwigKeyVal, "value") == 16);
assert(@offsetOf(TwigKeyVal, "value_len") == 24);
}
}
pub export fn twig_parse(
input_ptr: ?[*]const u8,
input_len: usize,
format: c_int,
out_doc: ?*?*TwigDocument,
) TwigStatus {
const out = out_doc orelse return .invalid_argument;
out.* = null;
const source = sliceOf(input_ptr, input_len) orelse return .invalid_argument;
const target = intToFormat(format) orelse return .unsupported_format;
const allocator = activeAllocator();
const parsed: ParsedDocument = switch (target) {
.djot => .{
.djot = twig.Djot.parse(allocator, source) catch |err| switch (err) {
error.OutOfMemory => return .out_of_memory,
},
},
.markdown => .{
.markdown = twig.Markdown.parse(allocator, source, .{}) catch |err| switch (err) {
error.OutOfMemory => return .out_of_memory,
},
},
.xml => .{
.xml = twig.Xml.parse(allocator, source) catch |err| switch (err) {
error.OutOfMemory => return .out_of_memory,
else => return .parse_error,
},
},
.html => .{
.html = twig.Html.parse(allocator, source) catch |err| switch (err) {
error.OutOfMemory => return .out_of_memory,
},
},
};
const handle = allocator.create(DocumentHandle) catch return .out_of_memory;
handle.* = .{ .parsed = parsed };
out.* = @ptrCast(handle);
return .ok;
}
pub export fn twig_document_destroy(doc: ?*TwigDocument) void {
const raw = doc orelse return;
const allocator = activeAllocator();
const handle = asHandle(raw);
if (handle.rendered.len != 0) allocator.free(handle.rendered);
if (handle.serialized.len != 0) allocator.free(handle.serialized);
if (handle.ast_json.len != 0) allocator.free(handle.ast_json);
if (handle.query_matches.len != 0) allocator.free(handle.query_matches);
handle.parsed.deinit();
allocator.destroy(handle);
}
/// The shared `AST` underlying a parsed document, regardless of which
/// language produced it — `Djot.Document`/`Markdown.Document` wrap it
/// alongside their own side tables, `Xml.parse`/`Html.parse` return it bare.
fn astOf(parsed: *const ParsedDocument) *const twig.AST {
return switch (parsed.*) {
.djot => |*doc| &doc.ast,
.markdown => |*doc| &doc.ast,
.xml => |*ast| ast,
.html => |*ast| ast,
};
}
fn renderHtml(allocator: Allocator, parsed: *const ParsedDocument) twig.Html.RenderAllocError![]u8 {
return switch (parsed.*) {
.djot => |*doc| twig.Djot.html.renderAlloc(allocator, doc, .{}),
.markdown => |*doc| twig.Markdown.html.renderAlloc(allocator, doc, .{}),
.xml => |*ast| twig.Html.serializeAlloc(allocator, ast, null),
.html => |*ast| twig.Html.serializeAlloc(allocator, ast, null),
};
}
pub export fn twig_document_render_html(
doc: ?*TwigDocument,
out_ptr: ?*?[*]const u8,
out_len: ?*usize,
) TwigStatus {
const raw = doc orelse return .invalid_argument;
const ptr_out = out_ptr orelse return .invalid_argument;
const len_out = out_len orelse return .invalid_argument;
const allocator = activeAllocator();
const handle = asHandle(raw);
const rendered = renderHtml(allocator, &handle.parsed) catch |err| switch (err) {
error.OutOfMemory => return .out_of_memory,
error.UnsafeMetadata => return .unsafe_metadata,
};
if (handle.rendered.len != 0) allocator.free(handle.rendered);
handle.rendered = rendered;
ptr_out.* = if (rendered.len == 0) null else rendered.ptr;
len_out.* = rendered.len;
return .ok;
}
/// Serialize `parsed` as `target`'s own source syntax, mirroring
/// `twig convert`'s two paths (see `cli/actions.zig`'s `convertSource`):
/// - `target` == the document's own format: round-trip through that
/// format's `Document`-aware canonical serializer, which resolves djot/
/// Markdown reference/footnote side tables.
/// - `target` != it: cross-format conversion through the target's bare-`AST`
/// serializer (`serializeAstAlloc`), which rebuilds any side tables it
/// needs from the tree alone.
/// Returns `null` when `target` has no serializer for the requested direction
/// (today: converting *into* XML from another format — XML's serializer only
/// understands the generic-markup kinds its own parser produces), which the
/// caller turns into `unsupported_format`.
fn serializeDocument(
allocator: Allocator,
parsed: *const ParsedDocument,
target: TwigFormat,
) twig.Html.RenderAllocError!?[]u8 {
if (std.meta.activeTag(parsed.*) == target) {
return switch (parsed.*) {
.djot => |*doc| try twig.Djot.serializer.serializeAlloc(allocator, doc),
.markdown => |*doc| try twig.Markdown.serializer.serializeAlloc(allocator, doc),
.xml => |*ast| try twig.Xml.serializeAlloc(allocator, ast),
.html => |*ast| try twig.Html.serializeAlloc(allocator, ast, null),
};
}
const ast = astOf(parsed);
return switch (target) {
.djot => try twig.Djot.serializer.serializeAstAlloc(allocator, ast),
.markdown => try twig.Markdown.serializer.serializeAstAlloc(allocator, ast),
// The generic HTML printer renders the full shared vocabulary from a
// bare AST, so cross-format-to-HTML works from any source (this is the
// side-table-free path; `twig_document_render_html` is the richer
// djot/Markdown HTML rendering).
.html => try twig.Html.serializeAlloc(allocator, ast, null),
// No cross-format serializer into XML — see this function's doc comment.
.xml => null,
};
}
/// Serialize a parsed document to `format`'s own source syntax (round-trip
/// when `format` matches the document's own format, cross-format conversion
/// otherwise).
///
/// The returned bytes are borrowed from `doc` and remain valid until the next
/// `twig_document_serialize` call on that same handle, or until the handle is
/// destroyed.
pub export fn twig_document_serialize(
doc: ?*TwigDocument,
format: c_int,
out_ptr: ?*?[*]const u8,
out_len: ?*usize,
) TwigStatus {
const raw = doc orelse return .invalid_argument;
const ptr_out = out_ptr orelse return .invalid_argument;
const len_out = out_len orelse return .invalid_argument;
const target = intToFormat(format) orelse return .unsupported_format;
const allocator = activeAllocator();
const handle = asHandle(raw);
const serialized = (serializeDocument(allocator, &handle.parsed, target) catch |err| switch (err) {
error.OutOfMemory => return .out_of_memory,
error.UnsafeMetadata => return .unsafe_metadata,
}) orelse return .unsupported_format;
if (handle.serialized.len != 0) allocator.free(handle.serialized);
handle.serialized = serialized;
ptr_out.* = if (serialized.len == 0) null else serialized.ptr;
len_out.* = serialized.len;
return .ok;
}
/// Encode a parsed document's shared `AST` as pretty-printed JSON — the same
/// stable encoding `twig convert -o ast` produces (see `ast/json.zig`).
///
/// The returned bytes are borrowed from `doc` and remain valid until the next
/// `twig_document_ast_json` call on that same handle, or until the handle is
/// destroyed.
pub export fn twig_document_ast_json(
doc: ?*TwigDocument,
out_ptr: ?*?[*]const u8,
out_len: ?*usize,
) TwigStatus {
const raw = doc orelse return .invalid_argument;
const ptr_out = out_ptr orelse return .invalid_argument;
const len_out = out_len orelse return .invalid_argument;
const allocator = activeAllocator();
const handle = asHandle(raw);
const json = twig.ast_json.encodeAlloc(allocator, astOf(&handle.parsed)) catch |err| switch (err) {
error.OutOfMemory => return .out_of_memory,
};
if (handle.ast_json.len != 0) allocator.free(handle.ast_json);
handle.ast_json = json;
ptr_out.* = if (json.len == 0) null else json.ptr;
len_out.* = json.len;
return .ok;
}
/// Resolve a CSS-lite selector (see `ast/select.zig` for the grammar — e.g.
/// `heading[level=2]`, `link[dest^="http"]`, `code`, `list > item`) against a
/// parsed document and return one `TwigQueryMatch` per matching node, in
/// document order. This is the general replacement for the old bespoke
/// code-span scan: a `code` / `verbatim` / `raw_block` / `raw_inline` selector
/// recovers those spans, and every other node kind is reachable too.
///
/// A malformed selector returns `invalid_argument`. The returned matches are
/// borrowed from `doc` and remain valid until the next `twig_document_query`
/// call on that same handle, or until the handle is destroyed.
pub export fn twig_document_query(
doc: ?*TwigDocument,
selector_ptr: ?[*]const u8,
selector_len: usize,
out_ptr: ?*?[*]const TwigQueryMatch,
out_len: ?*usize,
) TwigStatus {
const raw = doc orelse return .invalid_argument;
const ptr_out = out_ptr orelse return .invalid_argument;
const len_out = out_len orelse return .invalid_argument;
const selector_src = sliceOf(selector_ptr, selector_len) orelse return .invalid_argument;
const allocator = activeAllocator();
const handle = asHandle(raw);
const out = buildQueryMatches(allocator, astOf(&handle.parsed), selector_src) catch |err| switch (err) {
error.OutOfMemory => return .out_of_memory,
error.InvalidSelector => return .invalid_argument,
};
if (handle.query_matches.len != 0) allocator.free(handle.query_matches);
handle.query_matches = out;
ptr_out.* = if (out.len == 0) null else out.ptr;
len_out.* = out.len;
return .ok;
}
/// Parse `selector_src`, resolve it against `ast`, and return a freshly
/// allocated `[]TwigQueryMatch` in document order (caller owns it). Shared by
/// `twig_document_query` and `twig_editor_query`.
fn buildQueryMatches(
allocator: Allocator,
ast: *const twig.AST,
selector_src: []const u8,
) error{ OutOfMemory, InvalidSelector }![]TwigQueryMatch {
var selector = try twig.Select.parse(allocator, selector_src);
defer selector.deinit();
const matches = try twig.Select.resolveAll(allocator, ast, &selector);
defer allocator.free(matches);
if (matches.len == 0) return &.{};
const buf = try allocator.alloc(TwigQueryMatch, matches.len);
for (matches, buf) |m, *slot| {
slot.* = .{
.node_id = m.id,
.span = .{ .start = m.span.start, .end = m.span.end },
.content_span = if (m.content_span) |cs|
.{ .start = cs.start, .end = cs.end }
else
.{ .start = 0, .end = 0 },
.has_content_span = if (m.content_span != null) 1 else 0,
.kind = @tagName(std.meta.activeTag(ast.nodes[m.id].kind)).ptr,
};
}
return buf;
}
// ── Editor ─────────────────────────────────────────────────────────────────
// A separate handle from `TwigDocument`: the span-splice editor
// (`twig.Editor`) owns evolving source bytes plus a bare-AST reparse of them,
// where `TwigDocument` holds a one-shot parse with its language's side tables.
// Editing reparses after every successful edit, so node ids/paths are only
// valid against the tree *as of the last edit* — which is why every op here is
// addressed by a fresh locator string (an index path or a unique selector),
// resolved against the current tree, exactly like `twig edit`.
pub const TwigEditor = opaque {};
const EditorHandle = struct {
editor: twig.Editor,
/// The document's format — the reparse callback captures it, but the
/// range-oriented ops (`wrap_range`/`toggle_inline`/`set_block`) also need
/// it to pick format-specific delimiters, so it's kept explicitly.
format: TwigFormat,
/// The parse configuration the editor's reparse callback borrows (via
/// `twig.Editor.ParseFn`'s `ctx`). Stored ON the handle so its address is
/// stable for the handle's whole lifetime — the editor holds `&this`.
parse_config: EditorParseConfig = .{},
/// Caller-borrowed output buffers, same contract as `DocumentHandle`'s.
ast_json: []u8 = &.{},
query_matches: []TwigQueryMatch = &.{},
/// The last `twig_editor_nodes` snapshot (P2).
flat_nodes: []TwigFlatNode = &.{},
/// The last `twig_editor_nodes_at` ancestor chain (P3). Independent of
/// `query_matches` so a hit-test doesn't invalidate a prior query.
ancestor_matches: []TwigQueryMatch = &.{},
};
fn asEditor(ed: *TwigEditor) *EditorHandle {
return @ptrCast(@alignCast(ed));
}
/// The one edit each `twig_editor_*` op performs, dispatched by `applyEdit`
/// onto the matching `twig.Editor` method.
const EditOp = enum { replace, replace_content, insert_before, insert_after, insert_child, delete, delete_smart, unwrap };
// Per-format `source -> bare AST` reparse callbacks the editor drives. Djot and
// Markdown's `Document` side tables are irrelevant to editing (it only touches
// spans/structure), so these free those maps and hand back the bare `AST`;
// XML/HTML already parse to a bare `AST`. Mirrors `cli/format.zig`'s
// `parseToAst*` adapters.
/// Markdown extension bitmask accepted by `twig_editor_create_ext`
/// (`TWIG_MD_*` in `twig.h`); other formats ignore it.
const TWIG_MD_DIRECTIVES: u32 = 1 << 0;
const TWIG_MD_MATH: u32 = 1 << 1;
/// The editor reparse callback's opaque `ctx` (`twig.Editor.ParseFn`), carried
/// on each `EditorHandle` and forwarded to every reparse so an edited Markdown
/// document keeps the extension flags it was created with. Only the Markdown
/// adapter reads it; djot/xml/html ignore it.
const EditorParseConfig = struct { markdown: twig.Markdown.ParseOptions = .{} };
fn markdownOptionsFromFlags(flags: u32) twig.Markdown.ParseOptions {
var opts: twig.Markdown.ParseOptions = .{};
opts.directives = (flags & TWIG_MD_DIRECTIVES) != 0;
opts.math = (flags & TWIG_MD_MATH) != 0;
return opts;
}
fn parseToAstDjot(ctx: *const anyopaque, allocator: Allocator, source: []const u8) anyerror!twig.AST {
_ = ctx;
var doc = try twig.Djot.parse(allocator, source);
doc.references.deinit(allocator);
doc.auto_references.deinit(allocator);
doc.footnotes.deinit(allocator);
return doc.ast;
}
fn parseToAstMarkdown(ctx: *const anyopaque, allocator: Allocator, source: []const u8) anyerror!twig.AST {
const cfg: *const EditorParseConfig = @ptrCast(@alignCast(ctx));
var doc = try twig.Markdown.parse(allocator, source, cfg.markdown);
doc.link_references.deinit(allocator);
doc.footnotes.deinit(allocator);
return doc.ast;
}
fn parseToAstXml(ctx: *const anyopaque, allocator: Allocator, source: []const u8) anyerror!twig.AST {
_ = ctx;
return twig.Xml.parse(allocator, source);
}
fn parseToAstHtml(ctx: *const anyopaque, allocator: Allocator, source: []const u8) anyerror!twig.AST {
_ = ctx;
return twig.Html.parse(allocator, source);
}
fn parseFnFor(format: TwigFormat) twig.Editor.ParseFn {
return switch (format) {
.djot => parseToAstDjot,
.markdown => parseToAstMarkdown,
.xml => parseToAstXml,
.html => parseToAstHtml,
};
}
/// Create an editor over a private copy of `input`, parsed as `format` with
/// default options. On the initial parse failing, returns `parse_error` (or
/// `out_of_memory`).
pub export fn twig_editor_create(
input_ptr: ?[*]const u8,
input_len: usize,
format: c_int,
out_editor: ?*?*TwigEditor,
) TwigStatus {
return twig_editor_create_ext(input_ptr, input_len, format, 0, out_editor);
}
/// Like `twig_editor_create`, plus `md_flags` — a bitmask of `TWIG_MD_*`
/// Markdown extensions (`TWIG_MD_DIRECTIVES`, `TWIG_MD_MATH`) to enable for a
/// Markdown parse (ignored for other formats). The editor reparses with the
/// same flags after every edit, so a directive-bearing document stays
/// parseable — required before `twig_editor_filter` can match `directive[…]`
/// selectors.
pub export fn twig_editor_create_ext(
input_ptr: ?[*]const u8,
input_len: usize,
format: c_int,
md_flags: u32,
out_editor: ?*?*TwigEditor,
) TwigStatus {
const out = out_editor orelse return .invalid_argument;
out.* = null;
const source = sliceOf(input_ptr, input_len) orelse return .invalid_argument;
const target = intToFormat(format) orelse return .unsupported_format;
const allocator = activeAllocator();
const handle = allocator.create(EditorHandle) catch return .out_of_memory;
// Set the config BEFORE `Editor.init` (which reads it via the ctx pointer on
// its initial parse); the editor stores `&handle.parse_config`, stable for
// the handle's lifetime.
handle.* = .{ .editor = undefined, .format = target, .parse_config = .{ .markdown = markdownOptionsFromFlags(md_flags) } };
handle.editor = twig.Editor.init(allocator, source, &handle.parse_config, parseFnFor(target)) catch |err| {
allocator.destroy(handle);
return switch (err) {
error.OutOfMemory => .out_of_memory,
else => .parse_error,
};
};
out.* = @ptrCast(handle);
return .ok;
}
pub export fn twig_editor_destroy(ed: ?*TwigEditor) void {
const raw = ed orelse return;
const allocator = activeAllocator();
const handle = asEditor(raw);
if (handle.ast_json.len != 0) allocator.free(handle.ast_json);
if (handle.query_matches.len != 0) allocator.free(handle.query_matches);
if (handle.flat_nodes.len != 0) allocator.free(handle.flat_nodes);
if (handle.ancestor_matches.len != 0) allocator.free(handle.ancestor_matches);
handle.editor.deinit();
allocator.destroy(handle);
}
const LocatorError = error{ OutOfMemory, InvalidLocator, NotFound, Ambiguous };
/// A locator is an index path when it's made only of digits and dots (so an
/// empty string — the root — counts); anything else is a selector. Mirrors the
/// CLI's `isIndexPath`.
fn isIndexPath(s: []const u8) bool {
for (s) |c| if (!std.ascii.isDigit(c) and c != '.') return false;
return true;
}
fn parsePath(allocator: Allocator, path_str: []const u8) LocatorError![]const usize {
if (path_str.len == 0) return &.{};
var list: std.ArrayList(usize) = .empty;
errdefer list.deinit(allocator);
var it = std.mem.splitScalar(u8, path_str, '.');
while (it.next()) |seg| {
const n = std.fmt.parseInt(usize, seg, 10) catch return error.InvalidLocator;
list.append(allocator, n) catch return error.OutOfMemory;
}
return list.toOwnedSlice(allocator) catch error.OutOfMemory;
}
/// Resolve a locator (index path or unique selector) to a single node id
/// against `ast`, mirroring the CLI's `resolveLocator` but reporting via
/// `LocatorError` instead of printing.
fn resolveLocator(allocator: Allocator, ast: *const twig.AST, locator: []const u8) LocatorError!twig.AST.Node.Id {
if (isIndexPath(locator)) {
const path = try parsePath(allocator, locator);
defer if (path.len > 0) allocator.free(path);
return ast.getIdByPath(path) catch return error.NotFound;
}
var selector = twig.Select.parse(allocator, locator) catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
error.InvalidSelector => return error.InvalidLocator,
};
defer selector.deinit();
const matches = try twig.Select.resolveAll(allocator, ast, &selector);
defer allocator.free(matches);
if (matches.len == 0) return error.NotFound;
if (matches.len > 1) return error.Ambiguous;
return matches[0].id;
}
/// Resolve `locator` against the editor's current tree and apply `op`. Every
/// failure maps to a status: a malformed locator to `invalid_argument`, a
/// resolvable-but-missing one to `not_found`/`ambiguous`, an uneditable target
/// to `not_editable`, and a reparse-breaking edit (rolled back) to
/// `edit_conflict`.
fn applyEdit(
ed: ?*TwigEditor,
locator_ptr: ?[*]const u8,
locator_len: usize,
op: EditOp,
child_index: usize,
text_ptr: ?[*]const u8,
text_len: usize,
) TwigStatus {
const raw = ed orelse return .invalid_argument;
const locator = sliceOf(locator_ptr, locator_len) orelse return .invalid_argument;
const text = sliceOf(text_ptr, text_len) orelse return .invalid_argument;
const allocator = activeAllocator();
const handle = asEditor(raw);
const id = resolveLocator(allocator, handle.editor.astView(), locator) catch |err| switch (err) {
error.OutOfMemory => return .out_of_memory,
error.InvalidLocator => return .invalid_argument,
error.NotFound => return .not_found,
error.Ambiguous => return .ambiguous,
};
const result = switch (op) {
.replace => handle.editor.replaceNodeById(id, text),
.replace_content => handle.editor.replaceContentById(id, text),
.insert_before => handle.editor.insertBeforeById(id, text),
.insert_after => handle.editor.insertAfterById(id, text),
.insert_child => handle.editor.insertChildById(id, child_index, text),
.delete => handle.editor.deleteNodeById(id),
.delete_smart => handle.editor.deleteNodeSmartById(id),
.unwrap => handle.editor.unwrapNodeById(id),
};
result catch |err| switch (err) {
error.OutOfMemory => return .out_of_memory,
error.NoNodeSpan, error.NoContentSpan => return .not_editable,
// Any other error is the parser rejecting the edited document; the
// editor already rolled the edit back.
else => return .edit_conflict,
};
return .ok;
}
/// Replace the whole source of the located node with `text`.
pub export fn twig_editor_replace(
ed: ?*TwigEditor,
locator_ptr: ?[*]const u8,
locator_len: usize,
text_ptr: ?[*]const u8,
text_len: usize,
) TwigStatus {
return applyEdit(ed, locator_ptr, locator_len, .replace, 0, text_ptr, text_len);
}
/// Replace the interior (between-delimiters content) of the located container.
pub export fn twig_editor_replace_content(
ed: ?*TwigEditor,
locator_ptr: ?[*]const u8,
locator_len: usize,
text_ptr: ?[*]const u8,
text_len: usize,
) TwigStatus {
return applyEdit(ed, locator_ptr, locator_len, .replace_content, 0, text_ptr, text_len);
}
/// Insert `text` immediately before the located node.
pub export fn twig_editor_insert_before(
ed: ?*TwigEditor,
locator_ptr: ?[*]const u8,
locator_len: usize,
text_ptr: ?[*]const u8,
text_len: usize,
) TwigStatus {
return applyEdit(ed, locator_ptr, locator_len, .insert_before, 0, text_ptr, text_len);
}
/// Insert `text` immediately after the located node.
pub export fn twig_editor_insert_after(
ed: ?*TwigEditor,
locator_ptr: ?[*]const u8,
locator_len: usize,
text_ptr: ?[*]const u8,
text_len: usize,
) TwigStatus {
return applyEdit(ed, locator_ptr, locator_len, .insert_after, 0, text_ptr, text_len);
}
/// Insert `text` as the `child_index`-th child of the located container (past
/// the child count appends).
pub export fn twig_editor_insert_child(
ed: ?*TwigEditor,
locator_ptr: ?[*]const u8,
locator_len: usize,
child_index: usize,
text_ptr: ?[*]const u8,
text_len: usize,
) TwigStatus {
return applyEdit(ed, locator_ptr, locator_len, .insert_child, child_index, text_ptr, text_len);
}
/// Delete the located node (removes exactly its span; no whitespace cleanup).
pub export fn twig_editor_delete(
ed: ?*TwigEditor,
locator_ptr: ?[*]const u8,
locator_len: usize,
) TwigStatus {
return applyEdit(ed, locator_ptr, locator_len, .delete, 0, null, 0);
}
/// Delete the located node, tidying surrounding blank lines for a whole-line
/// (block) node; an inline node degrades to the exact-span delete.
pub export fn twig_editor_delete_smart(
ed: ?*TwigEditor,
locator_ptr: ?[*]const u8,
locator_len: usize,
) TwigStatus {
return applyEdit(ed, locator_ptr, locator_len, .delete_smart, 0, null, 0);
}
/// Unwrap the located node: replace it with its interior (drop the wrapper,
/// keep the children) — e.g. peel a `:::vis{…}` container. A node with no
/// interior (a leaf, or an empty container) is removed.
pub export fn twig_editor_unwrap(
ed: ?*TwigEditor,
locator_ptr: ?[*]const u8,
locator_len: usize,
) TwigStatus {
return applyEdit(ed, locator_ptr, locator_len, .unwrap, 0, null, 0);
}
/// Prune the document in place (`twig.Filter`): remove every node matching the
/// `drop` selector except those also matching `keep` (pass `keep_ptr == NULL`
/// to spare nothing), then — if `unwrap_kept` is non-zero — unwrap the
/// survivors. The edited bytes are then available via `twig_editor_source`.
/// A malformed selector returns `invalid_argument`; a reparse-breaking edit
/// (rolled back) `edit_conflict`.
pub export fn twig_editor_filter(
ed: ?*TwigEditor,
drop_ptr: ?[*]const u8,
drop_len: usize,
keep_ptr: ?[*]const u8,
keep_len: usize,
unwrap_kept: c_int,
) TwigStatus {
const raw = ed orelse return .invalid_argument;
const handle = asEditor(raw);
const drop = sliceOf(drop_ptr, drop_len) orelse return .invalid_argument;
// `keep` is optional: a NULL pointer means "no keep" (a zero-length keep
// with a non-NULL pointer is a caller error, surfacing as invalid_argument
// when the empty selector fails to parse).
const keep: ?[]const u8 = if (keep_ptr) |p| p[0..keep_len] else null;
const allocator = activeAllocator();
twig.Filter.apply(allocator, &handle.editor, .{
.drop = drop,
.keep = keep,
.unwrap_kept = unwrap_kept != 0,
}) catch |err| switch (err) {
error.OutOfMemory => return .out_of_memory,
error.InvalidSelector => return .invalid_argument,
error.FilterDidNotConverge => return .internal_error,
error.NoNodeSpan, error.NoContentSpan => return .not_editable,
else => return .edit_conflict,
};
return .ok;
}
/// The editor's current (edited) source bytes.
///
/// Unlike the other accessors, these bytes are borrowed directly from the
/// editor and remain valid until the next *successful* edit on this handle (a
/// failed edit leaves them untouched), or until the handle is destroyed.
pub export fn twig_editor_source(
ed: ?*TwigEditor,
out_ptr: ?*?[*]const u8,
out_len: ?*usize,
) TwigStatus {
const raw = ed orelse return .invalid_argument;
const ptr_out = out_ptr orelse return .invalid_argument;
const len_out = out_len orelse return .invalid_argument;
const bytes = asEditor(raw).editor.sourceBytes();
ptr_out.* = if (bytes.len == 0) null else bytes.ptr;
len_out.* = bytes.len;
return .ok;
}
/// Encode the editor's current tree as pretty-printed JSON — the live
/// counterpart of `twig_document_ast_json`, for inspecting the document
/// between edits.
///
/// The returned bytes are borrowed from `ed` and remain valid until the next
/// `twig_editor_ast_json` call on that same handle, or until it is destroyed.
pub export fn twig_editor_ast_json(
ed: ?*TwigEditor,
out_ptr: ?*?[*]const u8,
out_len: ?*usize,
) TwigStatus {
const raw = ed orelse return .invalid_argument;
const ptr_out = out_ptr orelse return .invalid_argument;
const len_out = out_len orelse return .invalid_argument;
const allocator = activeAllocator();
const handle = asEditor(raw);
const json = twig.ast_json.encodeAlloc(allocator, handle.editor.astView()) catch |err| switch (err) {
error.OutOfMemory => return .out_of_memory,
};
if (handle.ast_json.len != 0) allocator.free(handle.ast_json);
handle.ast_json = json;
ptr_out.* = if (json.len == 0) null else json.ptr;
len_out.* = json.len;
return .ok;
}
/// Resolve a selector against the editor's current tree — the live counterpart
/// of `twig_document_query`, e.g. to find a node's spans/kind before editing.
///
/// The returned matches are borrowed from `ed` and remain valid until the next
/// `twig_editor_query` call on that same handle, or until it is destroyed.
pub export fn twig_editor_query(
ed: ?*TwigEditor,
selector_ptr: ?[*]const u8,
selector_len: usize,
out_ptr: ?*?[*]const TwigQueryMatch,
out_len: ?*usize,
) TwigStatus {
const raw = ed orelse return .invalid_argument;
const ptr_out = out_ptr orelse return .invalid_argument;
const len_out = out_len orelse return .invalid_argument;
const selector_src = sliceOf(selector_ptr, selector_len) orelse return .invalid_argument;
const allocator = activeAllocator();
const handle = asEditor(raw);
const out = buildQueryMatches(allocator, handle.editor.astView(), selector_src) catch |err| switch (err) {
error.OutOfMemory => return .out_of_memory,
error.InvalidSelector => return .invalid_argument,
};
if (handle.query_matches.len != 0) allocator.free(handle.query_matches);
handle.query_matches = out;
ptr_out.* = if (out.len == 0) null else out.ptr;
len_out.* = out.len;
return .ok;
}
// ── Offset-addressed editing & read-back (P0–P3) ─────────────────────────────
// The rich-text-editor surface: a caret speaks byte offsets, not locator
// strings. `edit_range` is the raw splice (`Editor.replaceAtSpan`) a keystroke
// maps onto; `node_at`/`nodes_at` hit-test an offset back to nodes; `nodes`
// hands out the whole tree as a flat array so a renderer needn't parse JSON.
fn spanC(s: twig.Span) TwigSpan {
return .{ .start = s.start, .end = s.end };
}
fn changeC(c: twig.Editor.Change) TwigChange {
return .{ .old = spanC(c.old), .new = spanC(c.new) };
}
/// The node's static kind-tag name, matching `TwigQueryMatch.kind`.
fn kindName(node: *const twig.AST.Node) [*:0]const u8 {
return @tagName(std.meta.activeTag(node.kind)).ptr;
}
/// A heading's level, or 0 for any other kind.
fn kindLevel(node: *const twig.AST.Node) u32 {
return switch (node.kind) {
.heading => |h| h.level,
else => 0,
};
}
/// The node's primary text payload (a `str`'s bytes, a `code_block`'s body, …),
/// or `null` for kinds that carry none. Borrows the AST-owned payload.
fn kindText(node: *const twig.AST.Node) ?[]const u8 {
return switch (node.kind) {
.str, .symb, .verbatim, .inline_math, .display_math, .url, .email, .footnote_reference => |s| s,
.comment, .doctype, .cdata => |s| s,
// Each payload is a distinct anonymous struct type, so Zig can't merge
// these captures into one prong — but each exposes a `.text` field.
.code_block => |p| p.text,
.raw_block => |p| p.text,
.raw_inline => |p| p.text,
.metadata => |p| p.text,
.smart_punctuation => |p| p.text,
else => null,
};
}
/// A link/image destination, or `null`.
fn kindDestination(node: *const twig.AST.Node) ?[]const u8 {
return switch (node.kind) {
.link => |l| l.destination,
.image => |l| l.destination,
else => null,
};
}
/// Splice `[start, end)` of the current source with `text` and reparse — the
/// offset-addressed primitive (`Editor.replaceAtSpan`) behind a caret editor:
/// a keystroke is `edit_range(caret, caret, "x")`, backspace
/// `edit_range(caret-1, caret, "")`, a selection replace `edit_range(a, b, s)`.
/// `start`/`end` are byte offsets into the *current* source with
/// `start <= end <= len`; a bad range is `invalid_argument`. On a reparse-
/// breaking edit the splice is rolled back and `edit_conflict` returned. On
/// success, if `out_change` is non-NULL it receives the byte effect (also
/// retrievable via `twig_editor_last_change`).
pub export fn twig_editor_edit_range(
ed: ?*TwigEditor,
start: usize,
end: usize,
text_ptr: ?[*]const u8,
text_len: usize,
out_change: ?*TwigChange,
) TwigStatus {
const raw = ed orelse return .invalid_argument;
const text = sliceOf(text_ptr, text_len) orelse return .invalid_argument;
const handle = asEditor(raw);
if (start > end or end > handle.editor.sourceBytes().len) return .invalid_argument;
handle.editor.replaceAtSpan(twig.Span.init(start, end), text) catch |err| switch (err) {
error.OutOfMemory => return .out_of_memory,
// Any parse error means the edited document didn't reparse; it was
// rolled back.
else => return .edit_conflict,
};
if (out_change) |slot| slot.* = changeC(handle.editor.last_change.?);
return .ok;
}
/// Write the byte effect of the last successful edit into `out_change`. Lets
/// the locator ops (`twig_editor_replace`, `_delete`, …) report their change
/// too, so a caret/selection can re-anchor without re-diffing. Returns
/// `not_found` if no edit has succeeded yet (nothing to report).
pub export fn twig_editor_last_change(
ed: ?*TwigEditor,
out_change: ?*TwigChange,
) TwigStatus {
const raw = ed orelse return .invalid_argument;
const slot = out_change orelse return .invalid_argument;
const change = asEditor(raw).editor.last_change orelse return .not_found;
slot.* = changeC(change);
return .ok;
}
/// Undo the last edit step, restoring the previous source and reparsing. On
/// success, if `out_change` is non-NULL it receives the byte effect of the undo
/// (current → restored) so a caret can re-anchor. Returns `not_found` when
/// there's nothing to undo. History is per-editor and spans every op that
/// funnels through `replaceAtSpan` (splices and the locator ops alike).
pub export fn twig_editor_undo(
ed: ?*TwigEditor,
out_change: ?*TwigChange,
) TwigStatus {
const raw = ed orelse return .invalid_argument;
const change = (asEditor(raw).editor.undo() catch |err| switch (err) {
error.OutOfMemory => return .out_of_memory,
else => return .edit_conflict,
}) orelse return .not_found;
if (out_change) |slot| slot.* = changeC(change);
return .ok;
}
/// Redo the most recently undone edit step, symmetric to `twig_editor_undo`.
/// Returns `not_found` when the redo stack is empty (nothing was undone, or a
/// fresh edit has since invalidated it).
pub export fn twig_editor_redo(
ed: ?*TwigEditor,
out_change: ?*TwigChange,
) TwigStatus {
const raw = ed orelse return .invalid_argument;
const change = (asEditor(raw).editor.redo() catch |err| switch (err) {
error.OutOfMemory => return .out_of_memory,
else => return .edit_conflict,
}) orelse return .not_found;
if (out_change) |slot| slot.* = changeC(change);
return .ok;
}
/// Fold the most recent edit into the undo step before it, so a caret editor can
/// coalesce a run of keystrokes into one undo. Call immediately after an
/// `edit_range` that continues a run (same kind, no intervening caret move). A
/// no-op unless there are at least two steps to merge.
pub export fn twig_editor_coalesce_last(ed: ?*TwigEditor) TwigStatus {
const raw = ed orelse return .invalid_argument;
asEditor(raw).editor.coalesceLastUndo();
return .ok;
}
/// Snapshot the editor's current tree as a flat array of `TwigFlatNode`, one
/// per arena node, indexed so `array[i].id == i`. The JSON-free read path for
/// a renderer: one call, one buffer, walked via the `parent`/`first_child`/
/// `next_sibling` id links (`TWIG_NO_NODE` where absent). The root is the node
/// whose `parent == TWIG_NO_NODE`.
///
/// The returned array is borrowed from `ed` and stays valid until the next
/// `twig_editor_nodes` call on this handle or `twig_editor_destroy`. The
/// `text`/`destination` pointers within it additionally require no *successful
/// edit* to have happened since (a reparse frees the payloads they borrow).
pub export fn twig_editor_nodes(
ed: ?*TwigEditor,
out_ptr: ?*?[*]const TwigFlatNode,
out_len: ?*usize,
) TwigStatus {
const raw = ed orelse return .invalid_argument;
const ptr_out = out_ptr orelse return .invalid_argument;
const len_out = out_len orelse return .invalid_argument;
const allocator = activeAllocator();
const handle = asEditor(raw);
const ast = handle.editor.astView();
const nodes = ast.nodes;
const buf = allocator.alloc(TwigFlatNode, nodes.len) catch return .out_of_memory;
for (nodes, 0..) |node, i| {
const text = kindText(&node);
const dest = kindDestination(&node);
buf[i] = .{
.id = @intCast(i),
.parent = TWIG_NO_NODE, // filled in the parent pass below
.first_child = node.first_child orelse TWIG_NO_NODE,
.next_sibling = node.next_sibling orelse TWIG_NO_NODE,
.span = spanC(node.span),
.content_span = if (node.content_span) |cs| spanC(cs) else .{ .start = 0, .end = 0 },
.has_content_span = if (node.content_span != null) 1 else 0,
.level = kindLevel(&node),
.kind = kindName(&node),
.text_ptr = if (text) |t| t.ptr else null,
.text_len = if (text) |t| t.len else 0,
.destination_ptr = if (dest) |d| d.ptr else null,
.destination_len = if (dest) |d| d.len else 0,
};
}
// Parent pass: a `Node` stores children, not its parent, so derive it by
// stamping each node's children with its own id (one linear walk).
for (nodes, 0..) |node, i| {
var child = node.first_child;
while (child) |cid| {
buf[cid].parent = @intCast(i);
child = nodes[cid].next_sibling;
}
}
if (handle.flat_nodes.len != 0) allocator.free(handle.flat_nodes);
handle.flat_nodes = buf;
ptr_out.* = if (buf.len == 0) null else buf.ptr;
len_out.* = buf.len;
return .ok;
}
/// The deepest node whose span contains byte `offset` (half-open `[start, end)`,
/// with `offset == source.len` treated as inside the root) — mouse hit-testing
/// and "what's my cursor context". Descends from the root into the last child
/// that still contains the offset. Nodes with an unset `(0,0)` span (some
/// parsers leave inline spans unpopulated) can't be descended into and are
/// skipped. Fills `out_match` and returns `ok`, or `not_found` if no node
/// covers the offset (`invalid_argument` if `offset > source.len`).
pub export fn twig_editor_node_at(
ed: ?*TwigEditor,
offset: usize,
out_match: ?*TwigQueryMatch,
) TwigStatus {
const raw = ed orelse return .invalid_argument;
const slot = out_match orelse return .invalid_argument;
const handle = asEditor(raw);
const ast = handle.editor.astView();
if (offset > handle.editor.sourceBytes().len) return .invalid_argument;
const found = deepestContaining(ast, offset, handle.editor.sourceBytes().len) orelse return .not_found;
slot.* = flatMatch(ast, found);
return .ok;
}
/// The chain of nodes containing byte `offset`, root-first down to the deepest
/// (the same node `twig_editor_node_at` returns) — the ancestor path for
/// breadcrumbs or context-scoped edits. Same borrow contract as
/// `twig_editor_query`, but on an independent buffer. Returns `not_found` (and
/// a zero-length result) if nothing covers the offset.
pub export fn twig_editor_nodes_at(
ed: ?*TwigEditor,
offset: usize,
out_ptr: ?*?[*]const TwigQueryMatch,
out_len: ?*usize,
) TwigStatus {
const raw = ed orelse return .invalid_argument;
const ptr_out = out_ptr orelse return .invalid_argument;
const len_out = out_len orelse return .invalid_argument;
const allocator = activeAllocator();
const handle = asEditor(raw);
const ast = handle.editor.astView();
if (offset > handle.editor.sourceBytes().len) return .invalid_argument;
const len = handle.editor.sourceBytes().len;
// Rebuild the exact root→deepest descent path, so the chain's last element
// is always the node `twig_editor_node_at` returns. The root is included as
// the outermost breadcrumb even when its own span is unset.
const deepest = deepestContaining(ast, offset, len) orelse {
if (handle.ancestor_matches.len != 0) allocator.free(handle.ancestor_matches);
handle.ancestor_matches = &.{};
ptr_out.* = null;
len_out.* = 0;
return .not_found;
};
var chain: std.ArrayList(TwigQueryMatch) = .empty;
defer chain.deinit(allocator);
var cur = ast.root;
chain.append(allocator, flatMatch(ast, cur)) catch return .out_of_memory;
while (cur != deepest) {
cur = childContaining(ast, cur, offset, len) orelse break;
chain.append(allocator, flatMatch(ast, cur)) catch return .out_of_memory;
}
if (handle.ancestor_matches.len != 0) allocator.free(handle.ancestor_matches);
handle.ancestor_matches = chain.toOwnedSlice(allocator) catch return .out_of_memory;
ptr_out.* = handle.ancestor_matches.ptr;
len_out.* = handle.ancestor_matches.len;
return .ok;
}
/// True if `offset` falls in node span `s` (half-open), treating a whole-source
/// end position as inside, and an unset `(0,0)` span as containing nothing.
fn spanContains(s: twig.Span, offset: usize, source_len: usize) bool {
if (s.start == 0 and s.end == 0) return false;
if (offset == source_len) return offset >= s.start and s.end >= source_len;
return offset >= s.start and offset < s.end;
}
/// The child of `id` whose span contains `offset` — the last such child, so an
/// offset on a boundary resolves into the later sibling. `null` if none.
fn childContaining(ast: *const twig.AST, id: twig.AST.Node.Id, offset: usize, source_len: usize) ?twig.AST.Node.Id {
var found: ?twig.AST.Node.Id = null;
var it = ast.children(id);
while (it.next()) |child| {
if (spanContains(child.span, offset, source_len)) found = child.id;
}
return found;
}
/// The deepest node containing `offset`, descending from the root. The root's
/// own span may be unset `(0,0)` (some parsers don't span the `doc` node); when
/// so, entry is the root's child that owns the offset, and descent continues
/// fully from there. `null` if no node covers the offset at all.
fn deepestContaining(ast: *const twig.AST, offset: usize, source_len: usize) ?twig.AST.Node.Id {
var cur = ast.root;
if (!spanContains(ast.nodes[cur].span, offset, source_len)) {
cur = childContaining(ast, cur, offset, source_len) orelse return null;
}
while (childContaining(ast, cur, offset, source_len)) |child| cur = child;
return cur;
}
/// Build a `TwigQueryMatch` for a node id from the flat arena.
fn flatMatch(ast: *const twig.AST, id: twig.AST.Node.Id) TwigQueryMatch {
const node = &ast.nodes[id];
return .{
.node_id = id,
.span = spanC(node.span),
.content_span = if (node.content_span) |cs| spanC(cs) else .{ .start = 0, .end = 0 },
.has_content_span = if (node.content_span != null) 1 else 0,
.kind = kindName(node),
};
}
// ── Range-oriented rich-text ops (the toolbar, P5) ───────────────────────────
// wrap_range / toggle_inline / set_block: a caret editor's Bold / Italic / Code
// buttons and its H1 / Body switch. The format-specific knowledge (which
// delimiters mark a `strong`, how a heading is spelled) lives HERE, at the
// boundary that knows the format; the `twig.Editor` engine stays language-
// agnostic (it's handed delimiter bytes and a `Node.Kind` tag).
/// The inline mark kinds `twig_editor_wrap_range` / `_toggle_inline` accept
/// (C ABI enum; values are the wire contract, mirrored by `TwigInlineKind` in
/// `twig.h`).
const TwigInlineKind = enum(c_int) {
strong = 0,
emph = 1,
verbatim = 2,
mark = 3,
superscript = 4,
subscript = 5,
insert = 6,
delete = 7,
};
/// The block kinds `twig_editor_set_block` converts to.
const TwigBlockKind = enum(c_int) {
paragraph = 0,
heading = 1,
};
/// Map a raw C `int` to a `TwigInlineKind`, or `null` if it names none.
fn inlineKindFromInt(v: c_int) ?TwigInlineKind {
return switch (v) {
0 => .strong,
1 => .emph,
2 => .verbatim,
3 => .mark,
4 => .superscript,
5 => .subscript,
6 => .insert,
7 => .delete,
else => null,
};
}
/// Map a raw C `int` to a `TwigBlockKind`, or `null` if it names none.
fn blockKindFromInt(v: c_int) ?TwigBlockKind {
return switch (v) {
0 => .paragraph,
1 => .heading,
else => null,
};
}
const Delims = struct { open: []const u8, close: []const u8 };
/// The source delimiters that mark `kind` in `format`, or `null` when the
/// format has no lightweight spelling for it (Markdown has only strong / emph /
/// verbatim; XML and HTML have no lightweight inline markup at all). Values are
/// exactly what each format's serializer emits, so a wrap round-trips.
fn inlineDelims(format: TwigFormat, kind: TwigInlineKind) ?Delims {
return switch (format) {
.markdown => switch (kind) {
.strong => .{ .open = "**", .close = "**" },
.emph => .{ .open = "*", .close = "*" },
.verbatim => .{ .open = "`", .close = "`" },
else => null,
},
.djot => switch (kind) {
.strong => .{ .open = "*", .close = "*" },
.emph => .{ .open = "_", .close = "_" },
.verbatim => .{ .open = "`", .close = "`" },
.mark => .{ .open = "{=", .close = "=}" },
.superscript => .{ .open = "^", .close = "^" },
.subscript => .{ .open = "~", .close = "~" },
.insert => .{ .open = "{+", .close = "+}" },
.delete => .{ .open = "{-", .close = "-}" },
},
else => null,
};
}
/// The `Node.Kind` tag an inline kind detects as, for `toggleInline`'s
/// "already marked?" test.
fn inlineKindTag(kind: TwigInlineKind) twig.Editor.KindTag {
return switch (kind) {
.strong => .strong,
.emph => .emph,
.verbatim => .verbatim,
.mark => .mark,
.superscript => .superscript,
.subscript => .subscript,
.insert => .insert,
.delete => .delete,
};
}
/// Wrap `[start, end)` of the source with `kind`'s delimiters — the
/// unconditional half of the inline toolbar (always adds a mark). `start <=
/// end <= ` source length, else `invalid_argument`; a kind the format can't
/// spell is `unsupported_format`; a reparse-breaking result rolls back to
/// `edit_conflict`. On success fills `out_change` if non-NULL.
pub export fn twig_editor_wrap_range(
ed: ?*TwigEditor,
start: usize,
end: usize,
kind: c_int,
out_change: ?*TwigChange,
) TwigStatus {
const raw = ed orelse return .invalid_argument;
const handle = asEditor(raw);
if (start > end or end > handle.editor.sourceBytes().len) return .invalid_argument;
const ik = inlineKindFromInt(kind) orelse return .invalid_argument;
const d = inlineDelims(handle.format, ik) orelse return .unsupported_format;
handle.editor.wrapRange(twig.Span.init(start, end), d.open, d.close) catch |err| switch (err) {
error.OutOfMemory => return .out_of_memory,
else => return .edit_conflict,
};
if (out_change) |slot| slot.* = changeC(handle.editor.last_change.?);
return .ok;
}
/// Toggle `kind` over `[start, end)`: strip the mark if the range already *is*
/// a node of `kind` (its whole span or its interior), else wrap it — a rich
/// editor's Cmd-B. Same argument/format/rollback rules as
/// `twig_editor_wrap_range`; a matched-but-unrecoverable mark is `not_editable`.
pub export fn twig_editor_toggle_inline(
ed: ?*TwigEditor,
start: usize,
end: usize,
kind: c_int,
out_change: ?*TwigChange,
) TwigStatus {
const raw = ed orelse return .invalid_argument;
const handle = asEditor(raw);
if (start > end or end > handle.editor.sourceBytes().len) return .invalid_argument;
const ik = inlineKindFromInt(kind) orelse return .invalid_argument;
const d = inlineDelims(handle.format, ik) orelse return .unsupported_format;
handle.editor.toggleInline(twig.Span.init(start, end), inlineKindTag(ik), d.open, d.close) catch |err| switch (err) {
error.OutOfMemory => return .out_of_memory,
error.NoNodeSpan, error.NoContentSpan => return .not_editable,
else => return .edit_conflict,
};
if (out_change) |slot| slot.* = changeC(handle.editor.last_change.?);
return .ok;
}
/// The innermost `heading`/`para` block on the descent to `offset`, or `null`.
fn innermostBlock(ast: *const twig.AST, offset: usize, source_len: usize) ?twig.AST.Node.Id {
var result: ?twig.AST.Node.Id = null;
var cur = ast.root;
while (true) {
switch (std.meta.activeTag(ast.nodes[cur].kind)) {
.heading, .para => result = cur,
else => {},
}
cur = childContaining(ast, cur, offset, source_len) orelse break;
}
return result;
}
/// Convert the block at `offset` to `block_kind` (a `level`-N heading, or a
/// paragraph) by rewriting its leading marker while keeping its inline content
/// verbatim — the block half of the toolbar (H1 / Body). Works for Djot and
/// Markdown (both spell headings `#`…); other formats are `unsupported_format`.
/// `not_found` if no `heading`/`para` covers `offset`; `invalid_argument` for a
/// heading `level` outside 1–6 or an `offset` past the source.
pub export fn twig_editor_set_block(
ed: ?*TwigEditor,
offset: usize,
block_kind: c_int,
level: u32,
out_change: ?*TwigChange,
) TwigStatus {
const raw = ed orelse return .invalid_argument;
const handle = asEditor(raw);
switch (handle.format) {
.markdown, .djot => {},
else => return .unsupported_format,
}
const bk = blockKindFromInt(block_kind) orelse return .invalid_argument;
if (bk == .heading and (level < 1 or level > 6)) return .invalid_argument;
const src = handle.editor.sourceBytes();
if (offset > src.len) return .invalid_argument;
const ast = handle.editor.astView();
const block = innermostBlock(ast, offset, src.len) orelse return .not_found;
const node = ast.nodes[block];
const cs = node.content_span orelse return .not_editable;
const content = src[cs.start..cs.end];
// Rewrite [block start, end-of-text): the leading `#`-marker region (a
// heading) or nothing (a paragraph), plus the text — but NOT any trailing
// newline the block span includes (Djot blocks do), so we don't fuse with
// the next block. Rebuilding from `content_span` also collapses a setext
// heading's underline line away for free.
var end = node.span.end;
if (end > node.span.start and src[end - 1] == '\n') end -= 1;
if (end > node.span.start and src[end - 1] == '\r') end -= 1;
const allocator = activeAllocator();
const prefix_len: usize = if (bk == .heading) level + 1 else 0; // "#"*level + " "
const buf = allocator.alloc(u8, prefix_len + content.len) catch return .out_of_memory;
defer allocator.free(buf);
if (bk == .heading) {
@memset(buf[0..level], '#');
buf[level] = ' ';
}
@memcpy(buf[prefix_len..], content);
handle.editor.replaceAtSpan(twig.Span.init(node.span.start, end), buf) catch |err| switch (err) {
error.OutOfMemory => return .out_of_memory,
else => return .edit_conflict,
};
if (out_change) |slot| slot.* = changeC(handle.editor.last_change.?);
return .ok;
}
// ── Builder ──────────────────────────────────────────────────────────────────
// Programmatic construction of a document, the write-path mirror of `twig_parse`
// (which reads a document from source). Wraps `twig.AST.Builder`: build the tree
// bottom-up — add children, then the container from their ids — where every
// `twig_builder_add*` call returns the new node's id through `out_id`. Then
// render / serialize / query / dump the subtree rooted at any id, on demand,
// WITHOUT consuming the builder (via `Builder.view`), so a build can be inspected
// and extended freely. Mirrors fig's `fig_value_*` value-construction surface.
//
// Two contracts, inherited from `twig.AST.Builder`:
// * Every string handed in is COPIED — the caller's buffers need not outlive
// the call, and a built tree borrows no source.
// * Each node id must be placed in exactly one parent (`set_children`); reusing
// an id in two parents corrupts the sibling chain (asserted in safe builds).
pub const TwigBuilder = opaque {};
/// The full shared `Node.Kind` vocabulary as stable C ABI codes, in
/// `ast.zig` declaration order. Used by `twig_builder_add` (void-payload kinds)
/// and `twig_builder_add_text` (single-string-payload kinds) to pick a kind;
/// the kinds with richer payloads have their own dedicated `twig_builder_add_*`
/// constructors and are not selectable through those two entry points.
pub const TwigNodeKind = enum(c_int) {
doc = 0,
para = 1,
heading = 2,
thematic_break = 3,
section = 4,
div = 5,
code_block = 6,
raw_block = 7,
metadata = 8,
block_quote = 9,
bullet_list = 10,
ordered_list = 11,
task_list = 12,
definition_list = 13,
table = 14,
list_item = 15,
task_list_item = 16,
definition_list_item = 17,
term = 18,
definition = 19,
row = 20,
cell = 21,
caption = 22,
footnote = 23,
reference = 24,
str = 25,
soft_break = 26,
hard_break = 27,
non_breaking_space = 28,
symb = 29,
verbatim = 30,
raw_inline = 31,
inline_math = 32,
display_math = 33,
url = 34,
email = 35,
footnote_reference = 36,
smart_punctuation = 37,
emph = 38,
strong = 39,
link = 40,
image = 41,
span = 42,
mark = 43,
superscript = 44,
subscript = 45,
insert = 46,
delete = 47,
double_quoted = 48,
single_quoted = 49,
directive = 50,
element = 51,
comment = 52,
doctype = 53,
processing_instruction = 54,
cdata = 55,
};
pub const TwigBulletStyle = enum(c_int) { dash = 0, plus = 1, star = 2 };
pub const TwigOrderedNumbering = enum(c_int) { decimal = 0, lower_alpha = 1, upper_alpha = 2, lower_roman = 3, upper_roman = 4 };
pub const TwigOrderedDelim = enum(c_int) { period = 0, paren_after = 1, paren_both = 2 };
pub const TwigAlignment = enum(c_int) { default = 0, left = 1, right = 2, center = 3 };
pub const TwigSmartPunctuation = enum(c_int) {
left_single_quote = 0,
right_single_quote = 1,
left_double_quote = 2,
right_double_quote = 3,
ellipses = 4,
em_dash = 5,
en_dash = 6,
};
pub const TwigDirectiveForm = enum(c_int) { text = 0, leaf = 1, container = 2 };
/// One attribute pair for `twig_builder_set_attrs`. A NULL `value` is a *bare*
/// attribute (HTML `disabled`), distinct from a present-but-empty value
/// (`value` non-NULL, `value_len == 0`, i.e. `disabled=""`). The strings are
/// copied, so they need not outlive the call.
pub const TwigKeyVal = extern struct {
key: ?[*]const u8,
key_len: usize,
value: ?[*]const u8,
value_len: usize,
};
const BuilderHandle = struct {
builder: twig.AST.Builder,
/// Caller-borrowed output buffers, same contract as `DocumentHandle`'s:
/// owned by the handle, replaced on the next call to the same accessor,
/// freed on destroy.
rendered: []u8 = &.{},
serialized: []u8 = &.{},
ast_json: []u8 = &.{},
query_matches: []TwigQueryMatch = &.{},
};
fn asBuilder(b: *TwigBuilder) *BuilderHandle {
return @ptrCast(@alignCast(b));
}
/// Write the id a builder call produced to `out_id`, mapping allocation failure
/// to a status. Node construction can only fail on OOM.
fn emitNode(out_id: ?*u32, result: Allocator.Error!twig.AST.Node.Id) TwigStatus {
const out = out_id orelse return .invalid_argument;
const id = result catch return .out_of_memory;
out.* = id;
return .ok;
}
pub export fn twig_builder_create(out_builder: ?*?*TwigBuilder) TwigStatus {
const out = out_builder orelse return .invalid_argument;
out.* = null;
const allocator = activeAllocator();
const handle = allocator.create(BuilderHandle) catch return .out_of_memory;
handle.* = .{ .builder = twig.AST.Builder.init(allocator) };
out.* = @ptrCast(handle);
return .ok;
}
pub export fn twig_builder_destroy(b: ?*TwigBuilder) void {
const raw = b orelse return;
const allocator = activeAllocator();
const handle = asBuilder(raw);
if (handle.rendered.len != 0) allocator.free(handle.rendered);
if (handle.serialized.len != 0) allocator.free(handle.serialized);
if (handle.ast_json.len != 0) allocator.free(handle.ast_json);
if (handle.query_matches.len != 0) allocator.free(handle.query_matches);
handle.builder.deinit();
allocator.destroy(handle);
}
// ── constructors ─────────────────────────────────────────────────────────────
// Grouped by payload shape: `add` for the void-payload kinds (children attached
// later via `set_children`), `add_text` for the single-string-payload kinds, and
// a dedicated constructor for each kind carrying a richer payload.
/// The void-payload `Node.Kind` for a `TwigNodeKind` code, or `null` if the code
/// names a kind with a payload (which needs its own `twig_builder_add_*`) or is
/// unknown. Any of these may still be given children via `twig_builder_set_children`.
fn voidKind(kind: c_int) ?twig.AST.Node.Kind {
return switch (kind) {
@intFromEnum(TwigNodeKind.doc) => .doc,
@intFromEnum(TwigNodeKind.para) => .para,
@intFromEnum(TwigNodeKind.thematic_break) => .thematic_break,
@intFromEnum(TwigNodeKind.section) => .section,
@intFromEnum(TwigNodeKind.div) => .div,
@intFromEnum(TwigNodeKind.block_quote) => .block_quote,
@intFromEnum(TwigNodeKind.definition_list) => .definition_list,
@intFromEnum(TwigNodeKind.table) => .table,
@intFromEnum(TwigNodeKind.list_item) => .list_item,
@intFromEnum(TwigNodeKind.definition_list_item) => .definition_list_item,
@intFromEnum(TwigNodeKind.term) => .term,
@intFromEnum(TwigNodeKind.definition) => .definition,
@intFromEnum(TwigNodeKind.caption) => .caption,
@intFromEnum(TwigNodeKind.soft_break) => .soft_break,
@intFromEnum(TwigNodeKind.hard_break) => .hard_break,
@intFromEnum(TwigNodeKind.non_breaking_space) => .non_breaking_space,
@intFromEnum(TwigNodeKind.emph) => .emph,
@intFromEnum(TwigNodeKind.strong) => .strong,
@intFromEnum(TwigNodeKind.span) => .span,
@intFromEnum(TwigNodeKind.mark) => .mark,
@intFromEnum(TwigNodeKind.superscript) => .superscript,
@intFromEnum(TwigNodeKind.subscript) => .subscript,
@intFromEnum(TwigNodeKind.insert) => .insert,
@intFromEnum(TwigNodeKind.delete) => .delete,
@intFromEnum(TwigNodeKind.double_quoted) => .double_quoted,
@intFromEnum(TwigNodeKind.single_quoted) => .single_quoted,
else => null,
};
}
/// Add a void-payload node (`para`, `emph`, `block_quote`, `table`, …). Attach
/// its children afterward with `twig_builder_set_children`. A `kind` code that
/// names a payload-bearing kind (use that kind's own constructor) or is unknown
/// returns `invalid_argument`.
pub export fn twig_builder_add(b: ?*TwigBuilder, kind: c_int, out_id: ?*u32) TwigStatus {
const handle = asBuilder(b orelse return .invalid_argument);
const node_kind = voidKind(kind) orelse return .invalid_argument;
return emitNode(out_id, handle.builder.addNode(node_kind));
}
/// Add a single-string-payload inline/leaf node. `kind` must be one of the
/// string kinds (`str`, `symb`, `verbatim`, `inline_math`, `display_math`,
/// `url`, `email`, `footnote_reference`, `comment`, `doctype`, `cdata`); any
/// other code returns `invalid_argument`. The text is copied.
pub export fn twig_builder_add_text(
b: ?*TwigBuilder,
kind: c_int,
text_ptr: ?[*]const u8,
text_len: usize,
out_id: ?*u32,
) TwigStatus {
const handle = asBuilder(b orelse return .invalid_argument);
const text = sliceOf(text_ptr, text_len) orelse return .invalid_argument;
const node_kind: twig.AST.Node.Kind = switch (kind) {
@intFromEnum(TwigNodeKind.str) => .{ .str = text },
@intFromEnum(TwigNodeKind.symb) => .{ .symb = text },
@intFromEnum(TwigNodeKind.verbatim) => .{ .verbatim = text },
@intFromEnum(TwigNodeKind.inline_math) => .{ .inline_math = text },
@intFromEnum(TwigNodeKind.display_math) => .{ .display_math = text },
@intFromEnum(TwigNodeKind.url) => .{ .url = text },
@intFromEnum(TwigNodeKind.email) => .{ .email = text },
@intFromEnum(TwigNodeKind.footnote_reference) => .{ .footnote_reference = text },
@intFromEnum(TwigNodeKind.comment) => .{ .comment = text },
@intFromEnum(TwigNodeKind.doctype) => .{ .doctype = text },
@intFromEnum(TwigNodeKind.cdata) => .{ .cdata = text },
else => return .invalid_argument,
};
return emitNode(out_id, handle.builder.addNode(node_kind));
}
pub export fn twig_builder_add_heading(b: ?*TwigBuilder, level: u32, out_id: ?*u32) TwigStatus {
const handle = asBuilder(b orelse return .invalid_argument);
return emitNode(out_id, handle.builder.addNode(.{ .heading = .{ .level = level } }));
}
/// Add a `code_block`. `has_lang == 0` means no info-string language (a NULL
/// `code_block.lang`); otherwise `lang_ptr[0..lang_len]` is the language. Both
/// strings are copied.
pub export fn twig_builder_add_code_block(
b: ?*TwigBuilder,
lang_ptr: ?[*]const u8,
lang_len: usize,
has_lang: c_int,
text_ptr: ?[*]const u8,
text_len: usize,
out_id: ?*u32,
) TwigStatus {
const handle = asBuilder(b orelse return .invalid_argument);
const text = sliceOf(text_ptr, text_len) orelse return .invalid_argument;
const lang: ?[]const u8 = if (has_lang != 0) (sliceOf(lang_ptr, lang_len) orelse return .invalid_argument) else null;
return emitNode(out_id, handle.builder.addNode(.{ .code_block = .{ .lang = lang, .text = text } }));
}
pub export fn twig_builder_add_raw_block(
b: ?*TwigBuilder,
format_ptr: ?[*]const u8,
format_len: usize,
text_ptr: ?[*]const u8,
text_len: usize,
out_id: ?*u32,
) TwigStatus {
const handle = asBuilder(b orelse return .invalid_argument);
const format = sliceOf(format_ptr, format_len) orelse return .invalid_argument;
const text = sliceOf(text_ptr, text_len) orelse return .invalid_argument;
return emitNode(out_id, handle.builder.addNode(.{ .raw_block = .{ .format = format, .text = text } }));
}
pub export fn twig_builder_add_metadata(
b: ?*TwigBuilder,
lang_ptr: ?[*]const u8,
lang_len: usize,
text_ptr: ?[*]const u8,
text_len: usize,
out_id: ?*u32,
) TwigStatus {
const handle = asBuilder(b orelse return .invalid_argument);
const lang = sliceOf(lang_ptr, lang_len) orelse return .invalid_argument;
const text = sliceOf(text_ptr, text_len) orelse return .invalid_argument;
return emitNode(out_id, handle.builder.addNode(.{ .metadata = .{ .lang = lang, .text = text } }));
}
pub export fn twig_builder_add_raw_inline(
b: ?*TwigBuilder,
format_ptr: ?[*]const u8,
format_len: usize,
text_ptr: ?[*]const u8,
text_len: usize,
out_id: ?*u32,
) TwigStatus {
const handle = asBuilder(b orelse return .invalid_argument);
const format = sliceOf(format_ptr, format_len) orelse return .invalid_argument;
const text = sliceOf(text_ptr, text_len) orelse return .invalid_argument;
return emitNode(out_id, handle.builder.addNode(.{ .raw_inline = .{ .format = format, .text = text } }));
}
fn smartPunctOf(kind: c_int) ?twig.AST.SmartPunctuationKind {
return switch (kind) {
@intFromEnum(TwigSmartPunctuation.left_single_quote) => .left_single_quote,
@intFromEnum(TwigSmartPunctuation.right_single_quote) => .right_single_quote,
@intFromEnum(TwigSmartPunctuation.left_double_quote) => .left_double_quote,
@intFromEnum(TwigSmartPunctuation.right_double_quote) => .right_double_quote,
@intFromEnum(TwigSmartPunctuation.ellipses) => .ellipses,
@intFromEnum(TwigSmartPunctuation.em_dash) => .em_dash,
@intFromEnum(TwigSmartPunctuation.en_dash) => .en_dash,
else => null,
};
}
/// Add a `smart_punctuation` node. `punct_kind` is a `TwigSmartPunctuation`
/// code; `text` is the source spelling it stands for (e.g. `"---"` for an
/// em dash), copied.
pub export fn twig_builder_add_smart_punctuation(
b: ?*TwigBuilder,
punct_kind: c_int,
text_ptr: ?[*]const u8,
text_len: usize,
out_id: ?*u32,
) TwigStatus {
const handle = asBuilder(b orelse return .invalid_argument);
const pk = smartPunctOf(punct_kind) orelse return .invalid_argument;
const text = sliceOf(text_ptr, text_len) orelse return .invalid_argument;
return emitNode(out_id, handle.builder.addNode(.{ .smart_punctuation = .{ .kind = pk, .text = text } }));
}
/// Add a `link`. `has_destination`/`has_reference` gate the two optional
/// fields (a NULL field when 0). Inline children (the link text) are attached
/// with `twig_builder_set_children`. Strings are copied.
pub export fn twig_builder_add_link(
b: ?*TwigBuilder,
dest_ptr: ?[*]const u8,
dest_len: usize,
has_destination: c_int,
ref_ptr: ?[*]const u8,
ref_len: usize,
has_reference: c_int,
out_id: ?*u32,
) TwigStatus {
const handle = asBuilder(b orelse return .invalid_argument);
const dest: ?[]const u8 = if (has_destination != 0) (sliceOf(dest_ptr, dest_len) orelse return .invalid_argument) else null;
const ref: ?[]const u8 = if (has_reference != 0) (sliceOf(ref_ptr, ref_len) orelse return .invalid_argument) else null;
return emitNode(out_id, handle.builder.addNode(.{ .link = .{ .destination = dest, .reference = ref } }));
}
/// Add an `image` — like `twig_builder_add_link`, but the children are the alt
/// text.
pub export fn twig_builder_add_image(
b: ?*TwigBuilder,
dest_ptr: ?[*]const u8,
dest_len: usize,
has_destination: c_int,
ref_ptr: ?[*]const u8,
ref_len: usize,
has_reference: c_int,
out_id: ?*u32,
) TwigStatus {
const handle = asBuilder(b orelse return .invalid_argument);
const dest: ?[]const u8 = if (has_destination != 0) (sliceOf(dest_ptr, dest_len) orelse return .invalid_argument) else null;
const ref: ?[]const u8 = if (has_reference != 0) (sliceOf(ref_ptr, ref_len) orelse return .invalid_argument) else null;
return emitNode(out_id, handle.builder.addNode(.{ .image = .{ .destination = dest, .reference = ref } }));
}
fn directiveFormOf(form: c_int) ?twig.AST.DirectiveForm {
return switch (form) {
@intFromEnum(TwigDirectiveForm.text) => .text,
@intFromEnum(TwigDirectiveForm.leaf) => .leaf,
@intFromEnum(TwigDirectiveForm.container) => .container,
else => null,
};
}
pub export fn twig_builder_add_directive(
b: ?*TwigBuilder,
form: c_int,
name_ptr: ?[*]const u8,
name_len: usize,
out_id: ?*u32,
) TwigStatus {
const handle = asBuilder(b orelse return .invalid_argument);
const f = directiveFormOf(form) orelse return .invalid_argument;
const name = sliceOf(name_ptr, name_len) orelse return .invalid_argument;
return emitNode(out_id, handle.builder.addNode(.{ .directive = .{ .form = f, .name = name } }));
}
pub export fn twig_builder_add_element(
b: ?*TwigBuilder,
name_ptr: ?[*]const u8,
name_len: usize,
out_id: ?*u32,
) TwigStatus {
const handle = asBuilder(b orelse return .invalid_argument);
const name = sliceOf(name_ptr, name_len) orelse return .invalid_argument;
return emitNode(out_id, handle.builder.addNode(.{ .element = .{ .name = name } }));
}
pub export fn twig_builder_add_processing_instruction(
b: ?*TwigBuilder,
target_ptr: ?[*]const u8,
target_len: usize,
data_ptr: ?[*]const u8,
data_len: usize,
out_id: ?*u32,
) TwigStatus {
const handle = asBuilder(b orelse return .invalid_argument);
const target = sliceOf(target_ptr, target_len) orelse return .invalid_argument;
const data = sliceOf(data_ptr, data_len) orelse return .invalid_argument;
return emitNode(out_id, handle.builder.addNode(.{ .processing_instruction = .{ .target = target, .data = data } }));
}
pub export fn twig_builder_add_footnote(
b: ?*TwigBuilder,
label_ptr: ?[*]const u8,
label_len: usize,
out_id: ?*u32,
) TwigStatus {
const handle = asBuilder(b orelse return .invalid_argument);
const label = sliceOf(label_ptr, label_len) orelse return .invalid_argument;
return emitNode(out_id, handle.builder.addNode(.{ .footnote = .{ .label = label } }));
}
pub export fn twig_builder_add_reference(
b: ?*TwigBuilder,
label_ptr: ?[*]const u8,
label_len: usize,
dest_ptr: ?[*]const u8,
dest_len: usize,
out_id: ?*u32,
) TwigStatus {
const handle = asBuilder(b orelse return .invalid_argument);
const label = sliceOf(label_ptr, label_len) orelse return .invalid_argument;
const dest = sliceOf(dest_ptr, dest_len) orelse return .invalid_argument;
return emitNode(out_id, handle.builder.addNode(.{ .reference = .{ .label = label, .destination = dest } }));
}
fn bulletStyleOf(style: c_int) ?twig.AST.BulletListStyle {
return switch (style) {
@intFromEnum(TwigBulletStyle.dash) => .dash,
@intFromEnum(TwigBulletStyle.plus) => .plus,
@intFromEnum(TwigBulletStyle.star) => .star,
else => null,
};
}
pub export fn twig_builder_add_bullet_list(
b: ?*TwigBuilder,
style: c_int,
tight: c_int,
out_id: ?*u32,
) TwigStatus {
const handle = asBuilder(b orelse return .invalid_argument);
const s = bulletStyleOf(style) orelse return .invalid_argument;
return emitNode(out_id, handle.builder.addNode(.{ .bullet_list = .{ .style = s, .tight = tight != 0 } }));
}
fn numberingOf(numbering: c_int) ?twig.AST.OrderedListStyle.Numbering {
return switch (numbering) {
@intFromEnum(TwigOrderedNumbering.decimal) => .decimal,
@intFromEnum(TwigOrderedNumbering.lower_alpha) => .lower_alpha,
@intFromEnum(TwigOrderedNumbering.upper_alpha) => .upper_alpha,
@intFromEnum(TwigOrderedNumbering.lower_roman) => .lower_roman,
@intFromEnum(TwigOrderedNumbering.upper_roman) => .upper_roman,
else => null,
};
}
fn delimOf(delim: c_int) ?twig.AST.OrderedListStyle.Delim {
return switch (delim) {
@intFromEnum(TwigOrderedDelim.period) => .period,
@intFromEnum(TwigOrderedDelim.paren_after) => .paren_after,
@intFromEnum(TwigOrderedDelim.paren_both) => .paren_both,
else => null,
};
}
/// Add an `ordered_list`. `has_start == 0` leaves the start number implicit (a
/// NULL `ordered_list.start`); otherwise `start` is the explicit first number.
pub export fn twig_builder_add_ordered_list(
b: ?*TwigBuilder,
numbering: c_int,
delim: c_int,
tight: c_int,
start: u32,
has_start: c_int,
out_id: ?*u32,
) TwigStatus {
const handle = asBuilder(b orelse return .invalid_argument);
const num = numberingOf(numbering) orelse return .invalid_argument;
const del = delimOf(delim) orelse return .invalid_argument;
return emitNode(out_id, handle.builder.addNode(.{ .ordered_list = .{
.style = .{ .numbering = num, .delim = del },
.tight = tight != 0,
.start = if (has_start != 0) start else null,
} }));
}
pub export fn twig_builder_add_task_list(b: ?*TwigBuilder, tight: c_int, out_id: ?*u32) TwigStatus {
const handle = asBuilder(b orelse return .invalid_argument);
return emitNode(out_id, handle.builder.addNode(.{ .task_list = .{ .tight = tight != 0 } }));
}
pub export fn twig_builder_add_task_list_item(b: ?*TwigBuilder, checked: c_int, out_id: ?*u32) TwigStatus {
const handle = asBuilder(b orelse return .invalid_argument);
return emitNode(out_id, handle.builder.addNode(.{ .task_list_item = .{ .checked = checked != 0 } }));
}
pub export fn twig_builder_add_row(b: ?*TwigBuilder, head: c_int, out_id: ?*u32) TwigStatus {
const handle = asBuilder(b orelse return .invalid_argument);
return emitNode(out_id, handle.builder.addNode(.{ .row = .{ .head = head != 0 } }));
}
fn alignmentOf(alignment: c_int) ?twig.AST.Alignment {
return switch (alignment) {
@intFromEnum(TwigAlignment.default) => .default,
@intFromEnum(TwigAlignment.left) => .left,
@intFromEnum(TwigAlignment.right) => .right,
@intFromEnum(TwigAlignment.center) => .center,
else => null,
};
}
pub export fn twig_builder_add_cell(
b: ?*TwigBuilder,
head: c_int,
alignment: c_int,
out_id: ?*u32,
) TwigStatus {
const handle = asBuilder(b orelse return .invalid_argument);
const a = alignmentOf(alignment) orelse return .invalid_argument;
return emitNode(out_id, handle.builder.addNode(.{ .cell = .{ .head = head != 0, .alignment = a } }));
}
// ── structure & attributes ───────────────────────────────────────────────────
/// Set `parent`'s children to `ids` (in order), replacing any it had. Every id
/// — `parent` and each child — must name a node already added to this builder,
/// else `invalid_argument`. Each child id should appear in exactly one
/// `set_children` call across the whole build (a node has a single sibling
/// link); reusing one corrupts the tree.
pub export fn twig_builder_set_children(
b: ?*TwigBuilder,
parent: u32,
ids_ptr: ?[*]const u32,
ids_len: usize,
) TwigStatus {
const handle = asBuilder(b orelse return .invalid_argument);
const count = handle.builder.nodes.items.len;
if (parent >= count) return .invalid_argument;
// u32 and Node.Id are the same type, so the C array is already the slice the
// builder wants — no copy. Every id must name an existing node.
const ids: []const u32 = if (ids_len == 0) &.{} else (ids_ptr orelse return .invalid_argument)[0..ids_len];
for (ids) |id| if (id >= count) return .invalid_argument;
handle.builder.setChildren(parent, ids);
return .ok;
}
/// Attach `{...}` attributes to `id` (see `TwigKeyVal`), replacing any it had;
/// `kvs_len == 0` clears them. `id` must name an existing node. The keys/values
/// are copied.
pub export fn twig_builder_set_attrs(
b: ?*TwigBuilder,
id: u32,
kvs_ptr: ?[*]const TwigKeyVal,
kvs_len: usize,
) TwigStatus {
const handle = asBuilder(b orelse return .invalid_argument);
if (id >= handle.builder.nodes.items.len) return .invalid_argument;
if (kvs_len == 0) {
handle.builder.setAttrs(id, .{}) catch return .out_of_memory;
return .ok;
}
const c_kvs = (kvs_ptr orelse return .invalid_argument)[0..kvs_len];
const allocator = activeAllocator();
// `setAttrs` copies each key/value into owned storage, so this decode array
// (borrowing the caller's bytes) is only needed for the duration of the call.
const entries = allocator.alloc(twig.AST.KeyVal, kvs_len) catch return .out_of_memory;
defer allocator.free(entries);
for (c_kvs, entries) |c, *e| {
const key = sliceOf(c.key, c.key_len) orelse return .invalid_argument;
const value: ?[]const u8 = if (c.value) |vp| vp[0..c.value_len] else null;
e.* = .{ .key = key, .value = value };
}
handle.builder.setAttrs(id, .{ .entries = entries }) catch return .out_of_memory;
return .ok;
}
// ── render / serialize / inspect the built tree ───────────────────────────────
// Each takes an explicit `root` id and operates on the subtree under it via a
// non-consuming `Builder.view`, so a build can be rendered/queried and then
// extended. Output buffers follow the borrowed-until-next-same-call contract.
/// Serialize a built `AST` (subtree rooted at the view's root) to `target`'s own
/// source syntax. Mirrors `serializeDocument`'s cross-format arm, but always
/// from a bare AST — a built tree has no djot/Markdown side tables. Any error is
/// mapped by the caller (OOM / unsafe-metadata / otherwise unsupported).
fn serializeBuiltAst(allocator: Allocator, ast: *const twig.AST, target: TwigFormat) anyerror![]u8 {
return switch (target) {
.djot => twig.Djot.serializer.serializeAstAlloc(allocator, ast),
.markdown => twig.Markdown.serializer.serializeAstAlloc(allocator, ast),
.html => twig.Html.serializeAlloc(allocator, ast, null),
.xml => twig.Xml.serializeAlloc(allocator, ast),
};
}
/// Render the subtree rooted at `root` to HTML via the generic whole-vocabulary
/// printer (no djot/Markdown side tables — a built tree has none). Borrowed
/// output, valid until the next `twig_builder_render_html` on this handle or its
/// destruction.
pub export fn twig_builder_render_html(
b: ?*TwigBuilder,
root: u32,
out_ptr: ?*?[*]const u8,
out_len: ?*usize,
) TwigStatus {
const handle = asBuilder(b orelse return .invalid_argument);
const ptr_out = out_ptr orelse return .invalid_argument;
const len_out = out_len orelse return .invalid_argument;
if (root >= handle.builder.nodes.items.len) return .invalid_argument;
const allocator = activeAllocator();
const ast = handle.builder.view(root);
const rendered = twig.Html.serializeAlloc(allocator, &ast, null) catch |err| switch (err) {
error.OutOfMemory => return .out_of_memory,
error.UnsafeMetadata => return .unsafe_metadata,
};
if (handle.rendered.len != 0) allocator.free(handle.rendered);
handle.rendered = rendered;
ptr_out.* = if (rendered.len == 0) null else rendered.ptr;
len_out.* = rendered.len;
return .ok;
}
/// Serialize the subtree rooted at `root` to `format`'s source syntax.
/// `unsupported_format` when the target has no serializer for the built tree
/// (e.g. serializing semantic kinds into XML). Borrowed output, valid until the
/// next `twig_builder_serialize` on this handle or its destruction.
pub export fn twig_builder_serialize(
b: ?*TwigBuilder,
root: u32,
format: c_int,
out_ptr: ?*?[*]const u8,
out_len: ?*usize,
) TwigStatus {
const handle = asBuilder(b orelse return .invalid_argument);
const ptr_out = out_ptr orelse return .invalid_argument;
const len_out = out_len orelse return .invalid_argument;
const target = intToFormat(format) orelse return .unsupported_format;
if (root >= handle.builder.nodes.items.len) return .invalid_argument;
const allocator = activeAllocator();
const ast = handle.builder.view(root);
const serialized = serializeBuiltAst(allocator, &ast, target) catch |err| switch (err) {
error.OutOfMemory => return .out_of_memory,
error.UnsafeMetadata => return .unsafe_metadata,
// Any other serializer error means the built tree can't be represented
// in the target format (e.g. XML's shape requirements).
else => return .unsupported_format,
};
if (handle.serialized.len != 0) allocator.free(handle.serialized);
handle.serialized = serialized;
ptr_out.* = if (serialized.len == 0) null else serialized.ptr;
len_out.* = serialized.len;
return .ok;
}
/// Encode the subtree rooted at `root` as pretty-printed JSON (the same stable
/// encoding as `twig_document_ast_json`). Borrowed output, valid until the next
/// `twig_builder_ast_json` on this handle or its destruction.
pub export fn twig_builder_ast_json(
b: ?*TwigBuilder,
root: u32,
out_ptr: ?*?[*]const u8,
out_len: ?*usize,
) TwigStatus {
const handle = asBuilder(b orelse return .invalid_argument);
const ptr_out = out_ptr orelse return .invalid_argument;
const len_out = out_len orelse return .invalid_argument;
if (root >= handle.builder.nodes.items.len) return .invalid_argument;
const allocator = activeAllocator();
const ast = handle.builder.view(root);
const json = twig.ast_json.encodeAlloc(allocator, &ast) catch |err| switch (err) {
error.OutOfMemory => return .out_of_memory,
};
if (handle.ast_json.len != 0) allocator.free(handle.ast_json);
handle.ast_json = json;
ptr_out.* = if (json.len == 0) null else json.ptr;
len_out.* = json.len;
return .ok;
}
/// Resolve a selector against the subtree rooted at `root`. Same grammar and
/// borrowed-output contract as `twig_document_query`; a malformed selector
/// returns `invalid_argument`.
pub export fn twig_builder_query(
b: ?*TwigBuilder,
root: u32,
selector_ptr: ?[*]const u8,
selector_len: usize,
out_ptr: ?*?[*]const TwigQueryMatch,
out_len: ?*usize,
) TwigStatus {
const handle = asBuilder(b orelse return .invalid_argument);
const ptr_out = out_ptr orelse return .invalid_argument;
const len_out = out_len orelse return .invalid_argument;
const selector_src = sliceOf(selector_ptr, selector_len) orelse return .invalid_argument;
if (root >= handle.builder.nodes.items.len) return .invalid_argument;
const allocator = activeAllocator();
const ast = handle.builder.view(root);
const out = buildQueryMatches(allocator, &ast, selector_src) catch |err| switch (err) {
error.OutOfMemory => return .out_of_memory,
error.InvalidSelector => return .invalid_argument,
};
if (handle.query_matches.len != 0) allocator.free(handle.query_matches);
handle.query_matches = out;
ptr_out.* = if (out.len == 0) null else out.ptr;
len_out.* = out.len;
return .ok;
}
test "twig_parse + twig_document_render_html renders markdown" {
const source = "# hi\n";
var doc: ?*TwigDocument = null;
try std.testing.expectEqual(
TwigStatus.ok,
twig_parse(source.ptr, source.len, @intFromEnum(TwigFormat.markdown), &doc),
);
defer twig_document_destroy(doc);
var ptr: ?[*]const u8 = null;
var len: usize = 0;
try std.testing.expectEqual(TwigStatus.ok, twig_document_render_html(doc, &ptr, &len));
try std.testing.expectEqualStrings("<h1>hi</h1>\n", ptr.?[0..len]);
}
test "twig_parse accepts HTML input" {
const source = "<p>hi</p>";
var doc: ?*TwigDocument = null;
try std.testing.expectEqual(
TwigStatus.ok,
twig_parse(source.ptr, source.len, @intFromEnum(TwigFormat.html), &doc),
);
defer twig_document_destroy(doc);
var ptr: ?[*]const u8 = null;
var len: usize = 0;
try std.testing.expectEqual(TwigStatus.ok, twig_document_render_html(doc, &ptr, &len));
try std.testing.expect(std.mem.indexOf(u8, ptr.?[0..len], "hi") != null);
}
test "twig_parse rejects an unknown format code" {
const source = "x";
var doc: ?*TwigDocument = null;
try std.testing.expectEqual(
TwigStatus.unsupported_format,
twig_parse(source.ptr, source.len, 99, &doc),
);
try std.testing.expectEqual(@as(?*TwigDocument, null), doc);
}
test "twig_document_serialize round-trips markdown and rejects xml-target cross-conversion" {
const source = "# hi\n";
var doc: ?*TwigDocument = null;
try std.testing.expectEqual(
TwigStatus.ok,
twig_parse(source.ptr, source.len, @intFromEnum(TwigFormat.markdown), &doc),
);
defer twig_document_destroy(doc);
var ptr: ?[*]const u8 = null;
var len: usize = 0;
try std.testing.expectEqual(
TwigStatus.ok,
twig_document_serialize(doc, @intFromEnum(TwigFormat.markdown), &ptr, &len),
);
try std.testing.expect(std.mem.indexOf(u8, ptr.?[0..len], "# hi") != null);
// Markdown -> XML has no serializer (see `serializeDocument`).
try std.testing.expectEqual(
TwigStatus.unsupported_format,
twig_document_serialize(doc, @intFromEnum(TwigFormat.xml), &ptr, &len),
);
}
test "twig_document_serialize cross-converts markdown to djot" {
const source = "This is *markdown*.\n";
var doc: ?*TwigDocument = null;
try std.testing.expectEqual(
TwigStatus.ok,
twig_parse(source.ptr, source.len, @intFromEnum(TwigFormat.markdown), &doc),
);
defer twig_document_destroy(doc);
var ptr: ?[*]const u8 = null;
var len: usize = 0;
try std.testing.expectEqual(
TwigStatus.ok,
twig_document_serialize(doc, @intFromEnum(TwigFormat.djot), &ptr, &len),
);
// Markdown `*markdown*` (emphasis) renders djot-style with underscores.
try std.testing.expect(std.mem.indexOf(u8, ptr.?[0..len], "_markdown_") != null);
}
test "twig_document_ast_json dumps the shared AST as JSON" {
const source = "hello\n";
var doc: ?*TwigDocument = null;
try std.testing.expectEqual(
TwigStatus.ok,
twig_parse(source.ptr, source.len, @intFromEnum(TwigFormat.djot), &doc),
);
defer twig_document_destroy(doc);
var ptr: ?[*]const u8 = null;
var len: usize = 0;
try std.testing.expectEqual(TwigStatus.ok, twig_document_ast_json(doc, &ptr, &len));
try std.testing.expect(std.mem.indexOf(u8, ptr.?[0..len], "\"kind\": \"doc\"") != null);
}
test "twig_document_query finds nodes by selector and reports kind + spans" {
const source = "See `x` and\n\n```\nblock\n```\n\nprose\n";
var doc: ?*TwigDocument = null;
try std.testing.expectEqual(
TwigStatus.ok,
twig_parse(source.ptr, source.len, @intFromEnum(TwigFormat.markdown), &doc),
);
defer twig_document_destroy(doc);
// The inline code span (`verbatim`) recovers what the old code-span scan did.
const selector = "verbatim";
var ptr: ?[*]const TwigQueryMatch = null;
var len: usize = 0;
try std.testing.expectEqual(
TwigStatus.ok,
twig_document_query(doc, selector.ptr, selector.len, &ptr, &len),
);
try std.testing.expect(len == 1);
const m = ptr.?[0];
try std.testing.expectEqualStrings("verbatim", std.mem.span(m.kind));
try std.testing.expect(m.span.start < m.span.end);
// The matched span is `x`, not the surrounding prose.
try std.testing.expect(!std.mem.containsAtLeast(u8, source[m.span.start..m.span.end], 1, "prose"));
}
test "twig_document_query rejects a malformed selector" {
const source = "hi\n";
var doc: ?*TwigDocument = null;
try std.testing.expectEqual(
TwigStatus.ok,
twig_parse(source.ptr, source.len, @intFromEnum(TwigFormat.markdown), &doc),
);
defer twig_document_destroy(doc);
const bad = "list >";
var ptr: ?[*]const TwigQueryMatch = null;
var len: usize = 0;
try std.testing.expectEqual(
TwigStatus.invalid_argument,
twig_document_query(doc, bad.ptr, bad.len, &ptr, &len),
);
}
// ── editor tests ────────────────────────────────────────────────────────────
// XML is the vehicle (real spans + `content_span`, and the only language that
// can fail to parse — exercising rollback), matching `ast/editor.zig`'s tests.
const EditorFixture = struct {
ed: *TwigEditor,
fn init(source: [:0]const u8) !EditorFixture {
return initFmt(source, .xml);
}
fn initFmt(source: [:0]const u8, format: TwigFormat) !EditorFixture {
var ed: ?*TwigEditor = null;
try std.testing.expectEqual(
TwigStatus.ok,
twig_editor_create(source.ptr, source.len, @intFromEnum(format), &ed),
);
return .{ .ed = ed.? };
}
fn deinit(self: *EditorFixture) void {
twig_editor_destroy(self.ed);
}
fn expectSource(self: *EditorFixture, expected: []const u8) !void {
var ptr: ?[*]const u8 = null;
var len: usize = 0;
try std.testing.expectEqual(TwigStatus.ok, twig_editor_source(self.ed, &ptr, &len));
try std.testing.expectEqualStrings(expected, if (len == 0) "" else ptr.?[0..len]);
}
};
test "twig_editor: replace_content by index path, losslessly" {
var fx = try EditorFixture.init("<a><b>hi</b></a>");
defer fx.deinit();
const path = "0.0";
const text = "bye";
try std.testing.expectEqual(
TwigStatus.ok,
twig_editor_replace_content(fx.ed, path.ptr, path.len, text.ptr, text.len),
);
try fx.expectSource("<a><b>bye</b></a>");
}
test "twig_editor: insert_child by index and delete" {
var fx = try EditorFixture.init("<r><a/><c/></r>");
defer fx.deinit();
const root = "0";
const b = "<b/>";
try std.testing.expectEqual(
TwigStatus.ok,
twig_editor_insert_child(fx.ed, root.ptr, root.len, 1, b.ptr, b.len),
);
try fx.expectSource("<r><a/><b/><c/></r>");
// Delete the node now at path 0.1 (the freshly inserted <b/>).
const b_path = "0.1";
try std.testing.expectEqual(
TwigStatus.ok,
twig_editor_delete(fx.ed, b_path.ptr, b_path.len),
);
try fx.expectSource("<r><a/><c/></r>");
}
test "twig_editor: insert_before / insert_after" {
var fx = try EditorFixture.init("<r><a/></r>");
defer fx.deinit();
const a = "0.0";
const x = "<x/>";
try std.testing.expectEqual(
TwigStatus.ok,
twig_editor_insert_after(fx.ed, a.ptr, a.len, x.ptr, x.len),
);
try fx.expectSource("<r><a/><x/></r>");
const a2 = "0.0";
const y = "<y/>";
try std.testing.expectEqual(
TwigStatus.ok,
twig_editor_insert_before(fx.ed, a2.ptr, a2.len, y.ptr, y.len),
);
try fx.expectSource("<r><y/><a/><x/></r>");
}
test "twig_editor: a selector locator resolves and edits the target node" {
var fx = try EditorFixture.initFmt("# One\n\n## Two\n", .markdown);
defer fx.deinit();
// Address the second heading by its text instead of a path. (`doc` also
// contains "Two", but it isn't a `heading`, so the match is unambiguous.)
const sel = "heading(\"Two\")";
const text = "## Renamed";
try std.testing.expectEqual(
TwigStatus.ok,
twig_editor_replace(fx.ed, sel.ptr, sel.len, text.ptr, text.len),
);
try fx.expectSource("# One\n\n## Renamed\n");
}
test "twig_editor: locator errors map to distinct statuses" {
var fx = try EditorFixture.init("<r><a/><a/></r>");
defer fx.deinit();
const text = "x";
// No node at this path.
const oob = "0.9";
try std.testing.expectEqual(
TwigStatus.not_found,
twig_editor_replace(fx.ed, oob.ptr, oob.len, text.ptr, text.len),
);
// Two <a> elements match -> ambiguous.
const amb = "element";
try std.testing.expectEqual(
TwigStatus.ambiguous,
twig_editor_replace(fx.ed, amb.ptr, amb.len, text.ptr, text.len),
);
// Malformed selector.
const bad = "element(";
try std.testing.expectEqual(
TwigStatus.invalid_argument,
twig_editor_replace(fx.ed, bad.ptr, bad.len, text.ptr, text.len),
);
// Document is untouched by the failed edits.
try fx.expectSource("<r><a/><a/></r>");
}
test "twig_editor: a reparse-breaking edit rolls back as edit_conflict" {
var fx = try EditorFixture.init("<a>ok</a>");
defer fx.deinit();
// Replacing <a>'s interior with "<b>" makes `<a><b></a>` — malformed.
const root = "0";
const broken = "<b>";
try std.testing.expectEqual(
TwigStatus.edit_conflict,
twig_editor_replace_content(fx.ed, root.ptr, root.len, broken.ptr, broken.len),
);
try fx.expectSource("<a>ok</a>");
}
test "twig_editor: replace_content on a leaf is not_editable" {
var fx = try EditorFixture.init("<a>hi</a>");
defer fx.deinit();
// Path 0.0 is the "hi" text node, a leaf: no interior to splice.
const leaf = "0.0";
const text = "x";
try std.testing.expectEqual(
TwigStatus.not_editable,
twig_editor_replace_content(fx.ed, leaf.ptr, leaf.len, text.ptr, text.len),
);
}
test "twig_editor: ast_json and query reflect the current tree" {
var fx = try EditorFixture.init("<r><a/></r>");
defer fx.deinit();
// Insert a second element, then confirm a query sees both.
const root = "0";
const b = "<b/>";
try std.testing.expectEqual(
TwigStatus.ok,
twig_editor_insert_child(fx.ed, root.ptr, root.len, 1, b.ptr, b.len),
);
// Three elements now: the root <r> plus <a/> and <b/>.
const sel = "element";
var qptr: ?[*]const TwigQueryMatch = null;
var qlen: usize = 0;
try std.testing.expectEqual(
TwigStatus.ok,
twig_editor_query(fx.ed, sel.ptr, sel.len, &qptr, &qlen),
);
try std.testing.expect(qlen == 3);
var jptr: ?[*]const u8 = null;
var jlen: usize = 0;
try std.testing.expectEqual(TwigStatus.ok, twig_editor_ast_json(fx.ed, &jptr, &jlen));
try std.testing.expect(std.mem.indexOf(u8, jptr.?[0..jlen], "\"kind\": \"doc\"") != null);
}
// ── builder tests ─────────────────────────────────────────────────────────────
/// Add a node, asserting success, and return its id.
fn bAdd(b: *TwigBuilder, kind: TwigNodeKind) !u32 {
var id: u32 = undefined;
try std.testing.expectEqual(TwigStatus.ok, twig_builder_add(b, @intFromEnum(kind), &id));
return id;
}
fn bAddText(b: *TwigBuilder, kind: TwigNodeKind, text: []const u8) !u32 {
var id: u32 = undefined;
try std.testing.expectEqual(TwigStatus.ok, twig_builder_add_text(b, @intFromEnum(kind), text.ptr, text.len, &id));
return id;
}
fn bSetChildren(b: *TwigBuilder, parent: u32, ids: []const u32) !void {
try std.testing.expectEqual(TwigStatus.ok, twig_builder_set_children(b, parent, ids.ptr, ids.len));
}
test "twig_builder: build a small doc and render/serialize/query/dump it" {
var b: ?*TwigBuilder = null;
try std.testing.expectEqual(TwigStatus.ok, twig_builder_create(&b));
defer twig_builder_destroy(b);
const bld = b.?;
// # Title\n\nhello *world*
const title_text = try bAddText(bld, .str, "Title");
var heading: u32 = undefined;
try std.testing.expectEqual(TwigStatus.ok, twig_builder_add_heading(bld, 1, &heading));
try bSetChildren(bld, heading, &.{title_text});
const hello = try bAddText(bld, .str, "hello ");
const world = try bAddText(bld, .str, "world");
const emph = try bAdd(bld, .emph);
try bSetChildren(bld, emph, &.{world});
const para = try bAdd(bld, .para);
try bSetChildren(bld, para, &.{ hello, emph });
const doc = try bAdd(bld, .doc);
try bSetChildren(bld, doc, &.{ heading, para });
// Render to HTML via the generic printer.
var ptr: ?[*]const u8 = null;
var len: usize = 0;
try std.testing.expectEqual(TwigStatus.ok, twig_builder_render_html(bld, doc, &ptr, &len));
const html = ptr.?[0..len];
try std.testing.expect(std.mem.indexOf(u8, html, "<h1>Title</h1>") != null);
try std.testing.expect(std.mem.indexOf(u8, html, "<em>world</em>") != null);
// Serialize to Markdown.
try std.testing.expectEqual(
TwigStatus.ok,
twig_builder_serialize(bld, doc, @intFromEnum(TwigFormat.markdown), &ptr, &len),
);
const md = ptr.?[0..len];
try std.testing.expect(std.mem.indexOf(u8, md, "# Title") != null);
try std.testing.expect(std.mem.indexOf(u8, md, "*world*") != null);
// Query the built subtree.
var qptr: ?[*]const TwigQueryMatch = null;
var qlen: usize = 0;
const sel = "heading";
try std.testing.expectEqual(
TwigStatus.ok,
twig_builder_query(bld, doc, sel.ptr, sel.len, &qptr, &qlen),
);
try std.testing.expect(qlen == 1);
try std.testing.expectEqualStrings("heading", std.mem.span(qptr.?[0].kind));
// AST JSON dump.
try std.testing.expectEqual(TwigStatus.ok, twig_builder_ast_json(bld, doc, &ptr, &len));
try std.testing.expect(std.mem.indexOf(u8, ptr.?[0..len], "\"kind\": \"doc\"") != null);
}
test "twig_builder: element with attributes renders as an HTML tag" {
var b: ?*TwigBuilder = null;
try std.testing.expectEqual(TwigStatus.ok, twig_builder_create(&b));
defer twig_builder_destroy(b);
const bld = b.?;
const inner = try bAddText(bld, .str, "hi");
var div: u32 = undefined;
const name = "section";
try std.testing.expectEqual(TwigStatus.ok, twig_builder_add_element(bld, name.ptr, name.len, &div));
try bSetChildren(bld, div, &.{inner});
// class="note" plus a bare (valueless) attribute `hidden`.
const kvs = [_]TwigKeyVal{
.{ .key = "class", .key_len = 5, .value = "note", .value_len = 4 },
.{ .key = "hidden", .key_len = 6, .value = null, .value_len = 0 },
};
try std.testing.expectEqual(TwigStatus.ok, twig_builder_set_attrs(bld, div, &kvs, kvs.len));
var ptr: ?[*]const u8 = null;
var len: usize = 0;
try std.testing.expectEqual(TwigStatus.ok, twig_builder_render_html(bld, div, &ptr, &len));
const html = ptr.?[0..len];
try std.testing.expect(std.mem.indexOf(u8, html, "<section") != null);
try std.testing.expect(std.mem.indexOf(u8, html, "class=\"note\"") != null);
try std.testing.expect(std.mem.indexOf(u8, html, "hidden") != null);
}
test "twig_builder: invalid kind codes and out-of-range ids are rejected" {
var b: ?*TwigBuilder = null;
try std.testing.expectEqual(TwigStatus.ok, twig_builder_create(&b));
defer twig_builder_destroy(b);
const bld = b.?;
var id: u32 = undefined;
// `heading` carries a payload — not selectable via the void-kind `add`.
try std.testing.expectEqual(
TwigStatus.invalid_argument,
twig_builder_add(bld, @intFromEnum(TwigNodeKind.heading), &id),
);
// `para` is void, not a string kind — not selectable via `add_text`.
const t = "x";
try std.testing.expectEqual(
TwigStatus.invalid_argument,
twig_builder_add_text(bld, @intFromEnum(TwigNodeKind.para), t.ptr, t.len, &id),
);
// A completely unknown code.
try std.testing.expectEqual(TwigStatus.invalid_argument, twig_builder_add(bld, 9999, &id));
// set_children with a child id that doesn't exist yet.
const p = try bAdd(bld, .para);
const bogus = [_]u32{4242};
try std.testing.expectEqual(
TwigStatus.invalid_argument,
twig_builder_set_children(bld, p, &bogus, bogus.len),
);
// A root id past the end can't be rendered.
var ptr: ?[*]const u8 = null;
var len: usize = 0;
try std.testing.expectEqual(
TwigStatus.invalid_argument,
twig_builder_render_html(bld, 4242, &ptr, &len),
);
}