Skip to main content

twig/
lib.rs

1mod error;
2mod ffi;
3
4use std::ops::Range;
5use std::os::raw::{c_char, c_int};
6use std::ptr::NonNull;
7
8pub use error::Error;
9pub use ffi::TwigSpan as Span;
10
11#[derive(Clone, Copy, Debug, Eq, PartialEq)]
12pub enum Format {
13    Djot,
14    Markdown,
15    Xml,
16    Html,
17}
18
19impl From<Format> for ffi::TwigFormat {
20    fn from(value: Format) -> Self {
21        match value {
22            Format::Djot => ffi::TwigFormat::Djot,
23            Format::Markdown => ffi::TwigFormat::Markdown,
24            Format::Xml => ffi::TwigFormat::Xml,
25            Format::Html => ffi::TwigFormat::Html,
26        }
27    }
28}
29
30/// One node returned by [`Document::query`]: its AST id, byte spans, and kind.
31#[derive(Clone, Debug, Eq, PartialEq)]
32pub struct QueryMatch {
33    /// The node's id in the shared AST.
34    pub node_id: u32,
35    /// The node's whole byte range in the source.
36    pub span: Range<usize>,
37    /// The node's interior byte range (between its delimiters), or `None` for
38    /// a leaf / a container with no known interior.
39    pub content_span: Option<Range<usize>>,
40    /// The node-kind name (e.g. `"heading"`, `"code_block"`).
41    pub kind: String,
42}
43
44/// The byte-level effect of an [`Editor`] edit: `old` is the range of the
45/// pre-edit source that was replaced, `new` the range the replacement now
46/// occupies in the post-edit source (they share a start). An insertion has an
47/// empty `old`; a deletion an empty `new`. Everything a caret/selection needs
48/// to re-anchor across an edit without re-diffing: shift any offset `>= old.end`
49/// by `new.len() - old.len()`.
50#[derive(Clone, Debug, Eq, PartialEq)]
51pub struct Change {
52    pub old: Range<usize>,
53    pub new: Range<usize>,
54}
55
56impl Change {
57    /// The net change in source length (`new.len() - old.len()`).
58    pub fn delta(&self) -> isize {
59        self.new.len() as isize - self.old.len() as isize
60    }
61
62    fn from_ffi(c: ffi::TwigChange) -> Self {
63        Change {
64            old: c.old_span.start..c.old_span.end,
65            new: c.new_span.start..c.new_span.end,
66        }
67    }
68}
69
70/// One node of an [`Editor::nodes`] snapshot — the flat AST arena as owned Rust
71/// data (the JSON-free read path). `id` indexes the snapshot; `parent`,
72/// `first_child`, and `next_sibling` link the tree (`None` where absent).
73/// `text` is the node's primary payload (a `str`'s bytes, a `code_block`'s
74/// body, …) and `destination` a link/image target, each `None` when the kind
75/// carries no such payload.
76#[derive(Clone, Debug, Eq, PartialEq)]
77pub struct FlatNode {
78    pub id: NodeId,
79    pub parent: Option<NodeId>,
80    pub first_child: Option<NodeId>,
81    pub next_sibling: Option<NodeId>,
82    pub span: Range<usize>,
83    pub content_span: Option<Range<usize>>,
84    /// A heading's level; `None` for every other kind.
85    pub level: Option<u32>,
86    pub kind: String,
87    pub text: Option<String>,
88    pub destination: Option<String>,
89}
90
91/// An inline mark for [`Editor::wrap_range`] / [`Editor::toggle_inline`] — a
92/// rich editor's Bold / Italic / Code / … buttons. Markdown spells only
93/// [`InlineKind::Strong`], [`InlineKind::Emph`], and [`InlineKind::Verbatim`];
94/// Djot spells all of them. An unsupported kind yields [`Error::UnsupportedFormat`].
95#[derive(Clone, Copy, Debug, Eq, PartialEq)]
96pub enum InlineKind {
97    Strong,
98    Emph,
99    Verbatim,
100    Mark,
101    Superscript,
102    Subscript,
103    Insert,
104    Delete,
105}
106
107impl InlineKind {
108    fn to_c(self) -> c_int {
109        match self {
110            InlineKind::Strong => 0,
111            InlineKind::Emph => 1,
112            InlineKind::Verbatim => 2,
113            InlineKind::Mark => 3,
114            InlineKind::Superscript => 4,
115            InlineKind::Subscript => 5,
116            InlineKind::Insert => 6,
117            InlineKind::Delete => 7,
118        }
119    }
120}
121
122/// A block target for [`Editor::set_block`] — the toolbar's H1…H6 / Body switch.
123#[derive(Clone, Copy, Debug, Eq, PartialEq)]
124pub enum BlockKind {
125    Paragraph,
126    /// A heading of the given level (1–6; out of range is [`Error::InvalidArgument`]).
127    Heading(u32),
128}
129
130impl BlockKind {
131    /// `(block_kind_code, level)` for the C ABI.
132    fn to_c(self) -> (c_int, u32) {
133        match self {
134            BlockKind::Paragraph => (0, 0),
135            BlockKind::Heading(level) => (1, level),
136        }
137    }
138}
139
140#[derive(Clone, Copy, Debug, Eq, PartialEq)]
141pub struct Version {
142    pub major: u8,
143    pub minor: u8,
144    pub patch: u8,
145}
146
147pub fn version() -> Version {
148    let packed = unsafe { ffi::twig_version() };
149    Version {
150        major: (packed >> 16) as u8,
151        minor: (packed >> 8) as u8,
152        patch: packed as u8,
153    }
154}
155
156/// The C ABI contract version this crate was **compiled** against — the
157/// compile-time counterpart to [`abi_version`] (which reports the **linked
158/// library's**). This crate builds and links its own vendored copy of the Zig
159/// source, so the two always agree; the pair is exposed so a consumer embedding
160/// a separately-built library can verify layout compatibility at load time.
161pub const ABI_VERSION: u32 = ffi::TWIG_ABI_VERSION;
162
163/// The C ABI contract version of the linked library. This crate is written
164/// against [`ABI_VERSION`]; the two agreeing is what makes the `#[repr(C)]`
165/// mirrors in `ffi` sound. It is bumped only on a breaking ABI change (a struct
166/// layout change or a renumbered enum value), never on an additive one (a new
167/// format code or a new function).
168pub fn abi_version() -> u32 {
169    unsafe { ffi::twig_abi_version() }
170}
171
172pub fn version_string() -> &'static str {
173    let ptr = unsafe { ffi::twig_version_string() };
174    unsafe { std::ffi::CStr::from_ptr(ptr) }
175        .to_str()
176        .unwrap_or("")
177}
178
179#[derive(Debug)]
180pub struct Document {
181    raw: NonNull<ffi::TwigDocument>,
182}
183
184impl Document {
185    pub fn parse(input: &[u8], format: Format) -> Result<Self, Error> {
186        let mut raw = std::ptr::null_mut();
187        let ffi_format: ffi::TwigFormat = format.into();
188        let status = unsafe { ffi::twig_parse(input.as_ptr(), input.len(), ffi_format as i32, &mut raw) };
189        Error::from_status(status)?;
190        let raw = NonNull::new(raw).ok_or(Error::Internal)?;
191        Ok(Self { raw })
192    }
193
194    pub fn parse_str(input: &str, format: Format) -> Result<Self, Error> {
195        Self::parse(input.as_bytes(), format)
196    }
197
198    /// Render the document to HTML. For Djot/Markdown this is the rich
199    /// rendering path that resolves reference/footnote side tables.
200    pub fn render_html(&mut self) -> Result<Vec<u8>, Error> {
201        let raw = self.raw.as_ptr();
202        collect_bytes(|ptr, len| unsafe { ffi::twig_document_render_html(raw, ptr, len) })
203    }
204
205    /// Serialize the document to `format`'s own source syntax: a round-trip
206    /// when `format` matches the document's own format, cross-format
207    /// conversion otherwise (e.g. parse Markdown, serialize as Djot). Returns
208    /// [`Error::UnsupportedFormat`] when the requested direction has no
209    /// serializer (today: converting into XML from another format).
210    pub fn serialize(&mut self, format: Format) -> Result<Vec<u8>, Error> {
211        let raw = self.raw.as_ptr();
212        let ffi_format: ffi::TwigFormat = format.into();
213        collect_bytes(|ptr, len| unsafe {
214            ffi::twig_document_serialize(raw, ffi_format as i32, ptr, len)
215        })
216    }
217
218    /// Encode the document's AST as pretty-printed JSON (the same encoding as
219    /// `twig convert -o ast`).
220    pub fn ast_json(&mut self) -> Result<Vec<u8>, Error> {
221        let raw = self.raw.as_ptr();
222        collect_bytes(|ptr, len| unsafe { ffi::twig_document_ast_json(raw, ptr, len) })
223    }
224
225    /// Resolve a CSS-lite selector (e.g. `heading[level=2]`,
226    /// `link[dest^="http"]`, `code`, `list > item`) against the document,
227    /// returning one [`QueryMatch`] per matching node in document order. A
228    /// malformed selector yields [`Error::InvalidArgument`].
229    ///
230    /// This is the general replacement for scanning code spans by hand: a
231    /// `verbatim` / `code_block` / `raw_inline` / `raw_block` selector recovers
232    /// those, and every other node kind is reachable too.
233    pub fn query(&mut self, selector: &str) -> Result<Vec<QueryMatch>, Error> {
234        let raw = self.raw.as_ptr();
235        collect_matches(|ptr, len| unsafe {
236            ffi::twig_document_query(raw, selector.as_ptr(), selector.len(), ptr, len)
237        })
238    }
239}
240
241impl Drop for Document {
242    fn drop(&mut self) {
243        unsafe { ffi::twig_document_destroy(self.raw.as_ptr()) }
244    }
245}
246
247/// Markdown extensions to enable for an [`Editor`] parse (see
248/// [`Editor::new_ext`]). Ignored for non-Markdown formats. Both default off,
249/// matching the library.
250#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
251pub struct MarkdownExtensions {
252    /// Generic directives: `:name`, `::name`, `:::name`.
253    pub directives: bool,
254    /// `$...$` / `$$...$$` math.
255    pub math: bool,
256}
257
258impl MarkdownExtensions {
259    fn to_flags(self) -> u32 {
260        let mut flags = 0;
261        if self.directives {
262            flags |= ffi::TWIG_MD_DIRECTIVES;
263        }
264        if self.math {
265            flags |= ffi::TWIG_MD_MATH;
266        }
267        flags
268    }
269}
270
271/// A span-splice editor over a document: applies lossless, in-place edits and
272/// reparses after each one, so node addressing stays valid as the document
273/// evolves. Every op is addressed by a `locator` — a dot-separated index path
274/// (`"0.3.1"`) or a selector that must match exactly one node
275/// (`heading("Status")`). A failed edit leaves the document unchanged.
276#[derive(Debug)]
277pub struct Editor {
278    raw: NonNull<ffi::TwigEditor>,
279}
280
281impl Editor {
282    /// Create an editor over a private copy of `input`, parsed as `format` with
283    /// default options.
284    pub fn new(input: &[u8], format: Format) -> Result<Self, Error> {
285        let mut raw = std::ptr::null_mut();
286        let ffi_format: ffi::TwigFormat = format.into();
287        let status =
288            unsafe { ffi::twig_editor_create(input.as_ptr(), input.len(), ffi_format as i32, &mut raw) };
289        Error::from_status(status)?;
290        let raw = NonNull::new(raw).ok_or(Error::Internal)?;
291        Ok(Self { raw })
292    }
293
294    pub fn new_str(input: &str, format: Format) -> Result<Self, Error> {
295        Self::new(input.as_bytes(), format)
296    }
297
298    /// Like [`Editor::new`], plus Markdown `extensions` to enable (ignored for
299    /// other formats). The editor reparses with these after every edit, so a
300    /// directive-bearing document stays parseable — needed before
301    /// [`Editor::filter`] can match `directive[...]` selectors.
302    pub fn new_ext(input: &[u8], format: Format, extensions: MarkdownExtensions) -> Result<Self, Error> {
303        let mut raw = std::ptr::null_mut();
304        let ffi_format: ffi::TwigFormat = format.into();
305        let status = unsafe {
306            ffi::twig_editor_create_ext(
307                input.as_ptr(),
308                input.len(),
309                ffi_format as i32,
310                extensions.to_flags(),
311                &mut raw,
312            )
313        };
314        Error::from_status(status)?;
315        let raw = NonNull::new(raw).ok_or(Error::Internal)?;
316        Ok(Self { raw })
317    }
318
319    /// Replace the whole source of the located node with `text`.
320    pub fn replace(&mut self, locator: &str, text: &str) -> Result<(), Error> {
321        self.apply(locator, text, |ed, loc, loc_len, txt, txt_len| unsafe {
322            ffi::twig_editor_replace(ed, loc, loc_len, txt, txt_len)
323        })
324    }
325
326    /// Replace the interior (between-delimiters content) of the located
327    /// container.
328    pub fn replace_content(&mut self, locator: &str, text: &str) -> Result<(), Error> {
329        self.apply(locator, text, |ed, loc, loc_len, txt, txt_len| unsafe {
330            ffi::twig_editor_replace_content(ed, loc, loc_len, txt, txt_len)
331        })
332    }
333
334    /// Insert `text` immediately before the located node.
335    pub fn insert_before(&mut self, locator: &str, text: &str) -> Result<(), Error> {
336        self.apply(locator, text, |ed, loc, loc_len, txt, txt_len| unsafe {
337            ffi::twig_editor_insert_before(ed, loc, loc_len, txt, txt_len)
338        })
339    }
340
341    /// Insert `text` immediately after the located node.
342    pub fn insert_after(&mut self, locator: &str, text: &str) -> Result<(), Error> {
343        self.apply(locator, text, |ed, loc, loc_len, txt, txt_len| unsafe {
344            ffi::twig_editor_insert_after(ed, loc, loc_len, txt, txt_len)
345        })
346    }
347
348    /// Insert `text` as the `index`-th child of the located container (an index
349    /// at or past the child count appends).
350    pub fn insert_child(&mut self, locator: &str, index: usize, text: &str) -> Result<(), Error> {
351        let status = unsafe {
352            ffi::twig_editor_insert_child(
353                self.raw.as_ptr(),
354                locator.as_ptr(),
355                locator.len(),
356                index,
357                text.as_ptr(),
358                text.len(),
359            )
360        };
361        Error::from_status(status)
362    }
363
364    /// Delete the located node (removes exactly its span; no whitespace
365    /// cleanup).
366    pub fn delete(&mut self, locator: &str) -> Result<(), Error> {
367        let status = unsafe {
368            ffi::twig_editor_delete(self.raw.as_ptr(), locator.as_ptr(), locator.len())
369        };
370        Error::from_status(status)
371    }
372
373    /// Delete the located node, tidying surrounding blank lines for a
374    /// whole-line (block) node; an inline node degrades to the exact delete.
375    pub fn delete_smart(&mut self, locator: &str) -> Result<(), Error> {
376        let status = unsafe {
377            ffi::twig_editor_delete_smart(self.raw.as_ptr(), locator.as_ptr(), locator.len())
378        };
379        Error::from_status(status)
380    }
381
382    /// Unwrap the located node: replace it with its interior (drop the wrapper,
383    /// keep the children) — e.g. peel a `:::vis{...}` container. A node with no
384    /// interior (a leaf, or an empty container) is removed.
385    pub fn unwrap_node(&mut self, locator: &str) -> Result<(), Error> {
386        let status = unsafe {
387            ffi::twig_editor_unwrap(self.raw.as_ptr(), locator.as_ptr(), locator.len())
388        };
389        Error::from_status(status)
390    }
391
392    /// Prune the document in place: remove every node matching the `drop`
393    /// selector except those also matching `keep` (`None` spares nothing),
394    /// then — if `unwrap_kept` — unwrap the survivors. Read the result with
395    /// [`Editor::source`].
396    pub fn filter(&mut self, drop: &str, keep: Option<&str>, unwrap_kept: bool) -> Result<(), Error> {
397        let (keep_ptr, keep_len) = match keep {
398            Some(k) => (k.as_ptr(), k.len()),
399            None => (std::ptr::null(), 0),
400        };
401        let status = unsafe {
402            ffi::twig_editor_filter(
403                self.raw.as_ptr(),
404                drop.as_ptr(),
405                drop.len(),
406                keep_ptr,
407                keep_len,
408                unwrap_kept as i32,
409            )
410        };
411        Error::from_status(status)
412    }
413
414    /// The editor's current (edited) source bytes.
415    pub fn source(&mut self) -> Result<Vec<u8>, Error> {
416        let raw = self.raw.as_ptr();
417        collect_bytes(|ptr, len| unsafe { ffi::twig_editor_source(raw, ptr, len) })
418    }
419
420    /// The editor's current source bytes as a UTF-8 string.
421    pub fn source_str(&mut self) -> Result<String, Error> {
422        String::from_utf8(self.source()?).map_err(|_| Error::Internal)
423    }
424
425    /// Encode the editor's current tree as pretty-printed JSON — the live
426    /// counterpart of [`Document::ast_json`], for inspecting between edits.
427    pub fn ast_json(&mut self) -> Result<Vec<u8>, Error> {
428        let raw = self.raw.as_ptr();
429        collect_bytes(|ptr, len| unsafe { ffi::twig_editor_ast_json(raw, ptr, len) })
430    }
431
432    /// Resolve a selector against the editor's current tree — the live
433    /// counterpart of [`Document::query`].
434    pub fn query(&mut self, selector: &str) -> Result<Vec<QueryMatch>, Error> {
435        let raw = self.raw.as_ptr();
436        collect_matches(|ptr, len| unsafe {
437            ffi::twig_editor_query(raw, selector.as_ptr(), selector.len(), ptr, len)
438        })
439    }
440
441    // ── offset-addressed editing & read-back ────────────────────────────────
442
443    /// Splice `[start, end)` of the current source with `text`, reparse, and
444    /// return the [`Change`] the edit produced — the offset-addressed primitive
445    /// a caret editor is built on: a keystroke is `edit_range(c, c, "x")`,
446    /// backspace `edit_range(c - 1, c, "")`, a selection replace
447    /// `edit_range(a, b, s)`. `start <= end <= ` source length, else
448    /// [`Error::InvalidArgument`]. A reparse-breaking edit is rolled back and
449    /// returns [`Error::EditConflict`], leaving the document untouched.
450    pub fn edit_range(&mut self, start: usize, end: usize, text: &str) -> Result<Change, Error> {
451        let mut change = ffi::TwigChange {
452            old_span: ffi::TwigSpan { start: 0, end: 0 },
453            new_span: ffi::TwigSpan { start: 0, end: 0 },
454        };
455        let status = unsafe {
456            ffi::twig_editor_edit_range(
457                self.raw.as_ptr(),
458                start,
459                end,
460                text.as_ptr(),
461                text.len(),
462                &mut change,
463            )
464        };
465        Error::from_status(status)?;
466        Ok(Change::from_ffi(change))
467    }
468
469    /// The byte effect of the last successful edit — including the locator ops
470    /// ([`Editor::replace`], [`Editor::delete_smart`], …), so any edit can
471    /// re-anchor a caret without re-diffing. `None` before the first successful
472    /// edit. (A multi-splice op such as [`Editor::filter`] reports only its
473    /// final splice.)
474    pub fn last_change(&mut self) -> Option<Change> {
475        let mut change = ffi::TwigChange {
476            old_span: ffi::TwigSpan { start: 0, end: 0 },
477            new_span: ffi::TwigSpan { start: 0, end: 0 },
478        };
479        let status = unsafe { ffi::twig_editor_last_change(self.raw.as_ptr(), &mut change) };
480        match status.0 {
481            ffi::TwigStatus::OK => Some(Change::from_ffi(change)),
482            _ => None,
483        }
484    }
485
486    /// Undo the last edit step, restoring the previous source and reparsing.
487    /// Returns the [`Change`] the undo produced (current → restored) so a caret
488    /// can re-anchor, or `None` when there's nothing to undo. History accrues
489    /// across every successful edit that funnels through the splice primitive.
490    pub fn undo(&mut self) -> Result<Option<Change>, Error> {
491        let mut change = ffi::TwigChange {
492            old_span: ffi::TwigSpan { start: 0, end: 0 },
493            new_span: ffi::TwigSpan { start: 0, end: 0 },
494        };
495        let status = unsafe { ffi::twig_editor_undo(self.raw.as_ptr(), &mut change) };
496        if status.0 == ffi::TwigStatus::NOT_FOUND {
497            return Ok(None);
498        }
499        Error::from_status(status)?;
500        Ok(Some(Change::from_ffi(change)))
501    }
502
503    /// Redo the most recently undone edit step; the inverse of [`Editor::undo`].
504    /// Returns `None` when the redo stack is empty (nothing undone, or a fresh
505    /// edit has invalidated it).
506    pub fn redo(&mut self) -> Result<Option<Change>, Error> {
507        let mut change = ffi::TwigChange {
508            old_span: ffi::TwigSpan { start: 0, end: 0 },
509            new_span: ffi::TwigSpan { start: 0, end: 0 },
510        };
511        let status = unsafe { ffi::twig_editor_redo(self.raw.as_ptr(), &mut change) };
512        if status.0 == ffi::TwigStatus::NOT_FOUND {
513            return Ok(None);
514        }
515        Error::from_status(status)?;
516        Ok(Some(Change::from_ffi(change)))
517    }
518
519    /// Fold the most recent edit into the undo step before it, so a caret editor
520    /// can coalesce a run of keystrokes into a single undo. Call right after an
521    /// `edit_range` that continues a run (same kind, no intervening caret move);
522    /// a no-op unless there are at least two steps to merge.
523    pub fn coalesce_last_undo(&mut self) -> Result<(), Error> {
524        let status = unsafe { ffi::twig_editor_coalesce_last(self.raw.as_ptr()) };
525        Error::from_status(status)
526    }
527
528    /// Snapshot the current tree as a flat [`FlatNode`] array (the JSON-free
529    /// read path for a renderer), indexed so `nodes[i].id == NodeId(i)`. Walk it
530    /// via the `parent`/`first_child`/`next_sibling` links; the root is the node
531    /// whose `parent` is `None`.
532    pub fn nodes(&mut self) -> Result<Vec<FlatNode>, Error> {
533        let mut ptr: *const ffi::TwigFlatNode = std::ptr::null();
534        let mut len = 0usize;
535        let status = unsafe { ffi::twig_editor_nodes(self.raw.as_ptr(), &mut ptr, &mut len) };
536        Error::from_status(status)?;
537        if len == 0 {
538            return Ok(Vec::new());
539        }
540        if ptr.is_null() {
541            return Err(Error::Internal);
542        }
543        let raw = unsafe { std::slice::from_raw_parts(ptr, len) };
544        raw.iter().map(flat_node_from_ffi).collect()
545    }
546
547    /// The deepest node whose span contains byte `offset` (with `offset` equal
548    /// to the source length treated as inside the root) — mouse hit-testing and
549    /// cursor context. `Ok(None)` if no node covers the offset;
550    /// [`Error::InvalidArgument`] if `offset` exceeds the source length.
551    pub fn node_at(&mut self, offset: usize) -> Result<Option<QueryMatch>, Error> {
552        let mut m = ffi::TwigQueryMatch {
553            node_id: 0,
554            span: ffi::TwigSpan { start: 0, end: 0 },
555            content_span: ffi::TwigSpan { start: 0, end: 0 },
556            has_content_span: 0,
557            kind: std::ptr::null(),
558        };
559        let status = unsafe { ffi::twig_editor_node_at(self.raw.as_ptr(), offset, &mut m) };
560        match status.0 {
561            ffi::TwigStatus::OK => Ok(Some(query_match_from_ffi(&m)?)),
562            ffi::TwigStatus::NOT_FOUND => Ok(None),
563            _ => Err(Error::from_status(status).unwrap_err()),
564        }
565    }
566
567    /// The chain of nodes containing byte `offset`, root-first down to the
568    /// deepest (the node [`Editor::node_at`] returns) — the ancestor path for a
569    /// breadcrumb or context-scoped edit. Empty if no node covers the offset.
570    pub fn ancestors_at(&mut self, offset: usize) -> Result<Vec<QueryMatch>, Error> {
571        let mut ptr: *const ffi::TwigQueryMatch = std::ptr::null();
572        let mut len = 0usize;
573        let status =
574            unsafe { ffi::twig_editor_nodes_at(self.raw.as_ptr(), offset, &mut ptr, &mut len) };
575        match status.0 {
576            ffi::TwigStatus::OK => {}
577            ffi::TwigStatus::NOT_FOUND => return Ok(Vec::new()),
578            _ => return Err(Error::from_status(status).unwrap_err()),
579        }
580        if len == 0 || ptr.is_null() {
581            return Ok(Vec::new());
582        }
583        let raw = unsafe { std::slice::from_raw_parts(ptr, len) };
584        raw.iter().map(query_match_from_ffi).collect()
585    }
586
587    // ── range-oriented rich-text ops (the toolbar) ──────────────────────────
588
589    /// Wrap `[start, end)` with `kind`'s delimiters — the unconditional half of
590    /// the inline toolbar (always adds a mark; `*word*` → `**word**` stacks).
591    /// [`Error::UnsupportedFormat`] if the document's format can't spell `kind`
592    /// (e.g. a Markdown [`InlineKind::Mark`]); [`Error::InvalidArgument`] for a
593    /// bad range; [`Error::EditConflict`] if the result doesn't reparse.
594    pub fn wrap_range(&mut self, start: usize, end: usize, kind: InlineKind) -> Result<Change, Error> {
595        self.change_op(|ed, out| unsafe {
596            ffi::twig_editor_wrap_range(ed, start, end, kind.to_c(), out)
597        })
598    }
599
600    /// Toggle `kind` over `[start, end)`: remove the mark if the range already
601    /// *is* a node of `kind` (its whole span or its rendered interior), else
602    /// wrap it — a rich editor's Cmd-B. Same error rules as
603    /// [`Editor::wrap_range`].
604    pub fn toggle_inline(&mut self, start: usize, end: usize, kind: InlineKind) -> Result<Change, Error> {
605        self.change_op(|ed, out| unsafe {
606            ffi::twig_editor_toggle_inline(ed, start, end, kind.to_c(), out)
607        })
608    }
609
610    /// Convert the innermost heading/paragraph covering byte `offset` to `kind`,
611    /// rewriting its leading marker while keeping its inline content (the
612    /// toolbar's H1…H6 / Body switch). Djot and Markdown only, else
613    /// [`Error::UnsupportedFormat`]; [`Error::NotFound`] if no heading/paragraph
614    /// covers `offset`; [`Error::InvalidArgument`] for a heading level outside
615    /// 1–6.
616    pub fn set_block(&mut self, offset: usize, kind: BlockKind) -> Result<Change, Error> {
617        let (block_kind, level) = kind.to_c();
618        self.change_op(|ed, out| unsafe {
619            ffi::twig_editor_set_block(ed, offset, block_kind, level, out)
620        })
621    }
622
623    /// Shared plumbing for the change-returning ops: run `op` (which fills a
624    /// `TwigChange` out-param) and wrap the result.
625    fn change_op(
626        &mut self,
627        op: impl FnOnce(*mut ffi::TwigEditor, *mut ffi::TwigChange) -> ffi::TwigStatus,
628    ) -> Result<Change, Error> {
629        let mut change = ffi::TwigChange {
630            old_span: ffi::TwigSpan { start: 0, end: 0 },
631            new_span: ffi::TwigSpan { start: 0, end: 0 },
632        };
633        let status = op(self.raw.as_ptr(), &mut change);
634        Error::from_status(status)?;
635        Ok(Change::from_ffi(change))
636    }
637
638    /// Shared plumbing for the `(locator, text)` edit ops.
639    fn apply(
640        &mut self,
641        locator: &str,
642        text: &str,
643        op: impl FnOnce(*mut ffi::TwigEditor, *const u8, usize, *const u8, usize) -> ffi::TwigStatus,
644    ) -> Result<(), Error> {
645        let status = op(
646            self.raw.as_ptr(),
647            locator.as_ptr(),
648            locator.len(),
649            text.as_ptr(),
650            text.len(),
651        );
652        Error::from_status(status)
653    }
654}
655
656impl Drop for Editor {
657    fn drop(&mut self) {
658        unsafe { ffi::twig_editor_destroy(self.raw.as_ptr()) }
659    }
660}
661
662/// Run `call` (which writes a borrowed `(ptr, len)` byte buffer) and copy the
663/// result into an owned `Vec` — the buffer is only valid until the next
664/// same-accessor call on the handle, so we copy before returning. Shared by
665/// [`Document`] and [`Editor`].
666fn collect_bytes(
667    call: impl FnOnce(*mut *const u8, *mut usize) -> ffi::TwigStatus,
668) -> Result<Vec<u8>, Error> {
669    let mut ptr = std::ptr::null();
670    let mut len = 0usize;
671    let status = call(&mut ptr, &mut len);
672    Error::from_status(status)?;
673    if len == 0 {
674        return Ok(Vec::new());
675    }
676    if ptr.is_null() {
677        return Err(Error::Internal);
678    }
679    let bytes = unsafe { std::slice::from_raw_parts(ptr, len) };
680    Ok(bytes.to_vec())
681}
682
683/// Run `call` (which writes a borrowed `(ptr, len)` match array) and copy each
684/// match into an owned [`QueryMatch`]. Shared by [`Document`] and [`Editor`].
685fn collect_matches(
686    call: impl FnOnce(*mut *const ffi::TwigQueryMatch, *mut usize) -> ffi::TwigStatus,
687) -> Result<Vec<QueryMatch>, Error> {
688    let mut ptr = std::ptr::null();
689    let mut len = 0usize;
690    let status = call(&mut ptr, &mut len);
691    Error::from_status(status)?;
692    if len == 0 {
693        return Ok(Vec::new());
694    }
695    if ptr.is_null() {
696        return Err(Error::Internal);
697    }
698    let matches = unsafe { std::slice::from_raw_parts(ptr, len) };
699    matches.iter().map(query_match_from_ffi).collect()
700}
701
702/// Copy a borrowed C ABI [`ffi::TwigQueryMatch`] into an owned [`QueryMatch`].
703/// Shared by `collect_matches`, [`Editor::node_at`], and [`Editor::ancestors_at`].
704fn query_match_from_ffi(m: &ffi::TwigQueryMatch) -> Result<QueryMatch, Error> {
705    Ok(QueryMatch {
706        node_id: m.node_id,
707        span: m.span.start..m.span.end,
708        content_span: if m.has_content_span != 0 {
709            Some(m.content_span.start..m.content_span.end)
710        } else {
711            None
712        },
713        kind: borrowed_cstr(m.kind)?,
714    })
715}
716
717/// Copy a borrowed C ABI [`ffi::TwigFlatNode`] into an owned [`FlatNode`].
718fn flat_node_from_ffi(n: &ffi::TwigFlatNode) -> Result<FlatNode, Error> {
719    let node_id = |v: u32| if v == ffi::TWIG_NO_NODE { None } else { Some(NodeId(v)) };
720    Ok(FlatNode {
721        id: NodeId(n.id),
722        parent: node_id(n.parent),
723        first_child: node_id(n.first_child),
724        next_sibling: node_id(n.next_sibling),
725        span: n.span.start..n.span.end,
726        content_span: if n.has_content_span != 0 {
727            Some(n.content_span.start..n.content_span.end)
728        } else {
729            None
730        },
731        level: if n.level != 0 { Some(n.level) } else { None },
732        kind: borrowed_cstr(n.kind)?,
733        text: borrowed_bytes(n.text_ptr, n.text_len),
734        destination: borrowed_bytes(n.destination_ptr, n.destination_len),
735    })
736}
737
738/// Copy a NUL-terminated, library-owned C string into an owned `String`.
739fn borrowed_cstr(ptr: *const c_char) -> Result<String, Error> {
740    if ptr.is_null() {
741        return Err(Error::Internal);
742    }
743    Ok(unsafe { std::ffi::CStr::from_ptr(ptr) }
744        .to_str()
745        .map_err(|_| Error::Internal)?
746        .to_owned())
747}
748
749/// Copy a borrowed `(ptr, len)` payload slice into an owned `String`, or `None`
750/// for a NULL pointer (the kind carries no such payload). The bytes are a slice
751/// of a UTF-8 document, so a lossy decode never actually substitutes.
752fn borrowed_bytes(ptr: *const u8, len: usize) -> Option<String> {
753    if ptr.is_null() {
754        return None;
755    }
756    let bytes = unsafe { std::slice::from_raw_parts(ptr, len) };
757    Some(String::from_utf8_lossy(bytes).into_owned())
758}
759
760/// The id of a node added to a [`Builder`], returned by every `add*` method and
761/// used to wire up the tree via [`Builder::set_children`] and to root a
762/// render/serialize/query.
763#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
764pub struct NodeId(pub u32);
765
766/// The void-payload node kinds, addable via [`Builder::add`]. Kinds with a
767/// payload have their own dedicated `add_*` method instead.
768#[derive(Clone, Copy, Debug, Eq, PartialEq)]
769pub enum VoidKind {
770    Doc,
771    Para,
772    ThematicBreak,
773    Section,
774    Div,
775    BlockQuote,
776    DefinitionList,
777    Table,
778    ListItem,
779    DefinitionListItem,
780    Term,
781    Definition,
782    Caption,
783    SoftBreak,
784    HardBreak,
785    NonBreakingSpace,
786    Emph,
787    Strong,
788    Span,
789    Mark,
790    Superscript,
791    Subscript,
792    Insert,
793    Delete,
794    DoubleQuoted,
795    SingleQuoted,
796}
797
798impl VoidKind {
799    fn to_c(self) -> c_int {
800        // Discriminants match `TwigNodeKind` in the C ABI.
801        match self {
802            VoidKind::Doc => 0,
803            VoidKind::Para => 1,
804            VoidKind::ThematicBreak => 3,
805            VoidKind::Section => 4,
806            VoidKind::Div => 5,
807            VoidKind::BlockQuote => 9,
808            VoidKind::DefinitionList => 13,
809            VoidKind::Table => 14,
810            VoidKind::ListItem => 15,
811            VoidKind::DefinitionListItem => 17,
812            VoidKind::Term => 18,
813            VoidKind::Definition => 19,
814            VoidKind::Caption => 22,
815            VoidKind::SoftBreak => 26,
816            VoidKind::HardBreak => 27,
817            VoidKind::NonBreakingSpace => 28,
818            VoidKind::Emph => 38,
819            VoidKind::Strong => 39,
820            VoidKind::Span => 42,
821            VoidKind::Mark => 43,
822            VoidKind::Superscript => 44,
823            VoidKind::Subscript => 45,
824            VoidKind::Insert => 46,
825            VoidKind::Delete => 47,
826            VoidKind::DoubleQuoted => 48,
827            VoidKind::SingleQuoted => 49,
828        }
829    }
830}
831
832/// The single-string-payload node kinds, addable via [`Builder::add_text`].
833#[derive(Clone, Copy, Debug, Eq, PartialEq)]
834pub enum TextKind {
835    Str,
836    Symb,
837    Verbatim,
838    InlineMath,
839    DisplayMath,
840    Url,
841    Email,
842    FootnoteReference,
843    Comment,
844    Doctype,
845    Cdata,
846}
847
848impl TextKind {
849    fn to_c(self) -> c_int {
850        match self {
851            TextKind::Str => 25,
852            TextKind::Symb => 29,
853            TextKind::Verbatim => 30,
854            TextKind::InlineMath => 32,
855            TextKind::DisplayMath => 33,
856            TextKind::Url => 34,
857            TextKind::Email => 35,
858            TextKind::FootnoteReference => 36,
859            TextKind::Comment => 52,
860            TextKind::Doctype => 53,
861            TextKind::Cdata => 55,
862        }
863    }
864}
865
866/// Bullet marker style for [`Builder::add_bullet_list`].
867#[derive(Clone, Copy, Debug, Eq, PartialEq)]
868pub enum BulletStyle {
869    Dash,
870    Plus,
871    Star,
872}
873
874impl BulletStyle {
875    fn to_c(self) -> c_int {
876        match self {
877            BulletStyle::Dash => 0,
878            BulletStyle::Plus => 1,
879            BulletStyle::Star => 2,
880        }
881    }
882}
883
884/// Numbering scheme for [`Builder::add_ordered_list`].
885#[derive(Clone, Copy, Debug, Eq, PartialEq)]
886pub enum OrderedNumbering {
887    Decimal,
888    LowerAlpha,
889    UpperAlpha,
890    LowerRoman,
891    UpperRoman,
892}
893
894impl OrderedNumbering {
895    fn to_c(self) -> c_int {
896        match self {
897            OrderedNumbering::Decimal => 0,
898            OrderedNumbering::LowerAlpha => 1,
899            OrderedNumbering::UpperAlpha => 2,
900            OrderedNumbering::LowerRoman => 3,
901            OrderedNumbering::UpperRoman => 4,
902        }
903    }
904}
905
906/// Delimiter around an ordered-list number (`1.`, `1)`, `(1)`).
907#[derive(Clone, Copy, Debug, Eq, PartialEq)]
908pub enum OrderedDelim {
909    Period,
910    ParenAfter,
911    ParenBoth,
912}
913
914impl OrderedDelim {
915    fn to_c(self) -> c_int {
916        match self {
917            OrderedDelim::Period => 0,
918            OrderedDelim::ParenAfter => 1,
919            OrderedDelim::ParenBoth => 2,
920        }
921    }
922}
923
924/// Table-cell alignment for [`Builder::add_cell`].
925#[derive(Clone, Copy, Debug, Eq, PartialEq)]
926pub enum Alignment {
927    Default,
928    Left,
929    Right,
930    Center,
931}
932
933impl Alignment {
934    fn to_c(self) -> c_int {
935        match self {
936            Alignment::Default => 0,
937            Alignment::Left => 1,
938            Alignment::Right => 2,
939            Alignment::Center => 3,
940        }
941    }
942}
943
944/// The smart-punctuation kind for [`Builder::add_smart_punctuation`].
945#[derive(Clone, Copy, Debug, Eq, PartialEq)]
946pub enum SmartPunctuation {
947    LeftSingleQuote,
948    RightSingleQuote,
949    LeftDoubleQuote,
950    RightDoubleQuote,
951    Ellipses,
952    EmDash,
953    EnDash,
954}
955
956impl SmartPunctuation {
957    fn to_c(self) -> c_int {
958        match self {
959            SmartPunctuation::LeftSingleQuote => 0,
960            SmartPunctuation::RightSingleQuote => 1,
961            SmartPunctuation::LeftDoubleQuote => 2,
962            SmartPunctuation::RightDoubleQuote => 3,
963            SmartPunctuation::Ellipses => 4,
964            SmartPunctuation::EmDash => 5,
965            SmartPunctuation::EnDash => 6,
966        }
967    }
968}
969
970/// The surface form of a generic directive for [`Builder::add_directive`].
971#[derive(Clone, Copy, Debug, Eq, PartialEq)]
972pub enum DirectiveForm {
973    Text,
974    Leaf,
975    Container,
976}
977
978impl DirectiveForm {
979    fn to_c(self) -> c_int {
980        match self {
981            DirectiveForm::Text => 0,
982            DirectiveForm::Leaf => 1,
983            DirectiveForm::Container => 2,
984        }
985    }
986}
987
988/// Decompose an optional string into `(ptr, len, has)` for the C ABI's
989/// `(ptr, len, has_*)` optional-string triples. The pointer borrows `s` and is
990/// only used within the same call.
991fn opt_str(s: Option<&str>) -> (*const u8, usize, c_int) {
992    match s {
993        Some(x) => (x.as_ptr(), x.len(), 1),
994        None => (std::ptr::null(), 0, 0),
995    }
996}
997
998/// Programmatic construction of a document — the write-path mirror of
999/// [`Document::parse`]. Build the tree bottom-up (add children, then the
1000/// container, wiring them with [`Builder::set_children`]); every `add*` method
1001/// returns the new node's [`NodeId`]. Then render, serialize, query, or dump the
1002/// subtree rooted at any id, on demand, without consuming the builder. All input
1003/// strings are copied, so caller buffers need not outlive a call.
1004#[derive(Debug)]
1005pub struct Builder {
1006    raw: NonNull<ffi::TwigBuilder>,
1007}
1008
1009impl Builder {
1010    /// Create an empty builder.
1011    pub fn new() -> Result<Self, Error> {
1012        let mut raw = std::ptr::null_mut();
1013        let status = unsafe { ffi::twig_builder_create(&mut raw) };
1014        Error::from_status(status)?;
1015        let raw = NonNull::new(raw).ok_or(Error::Internal)?;
1016        Ok(Self { raw })
1017    }
1018
1019    /// Add a void-payload node (attach children later with
1020    /// [`Builder::set_children`]).
1021    pub fn add(&mut self, kind: VoidKind) -> Result<NodeId, Error> {
1022        self.emit(|b, out| unsafe { ffi::twig_builder_add(b, kind.to_c(), out) })
1023    }
1024
1025    /// Add a single-string-payload node (a `str`, code span, url, comment, …).
1026    pub fn add_text(&mut self, kind: TextKind, text: &str) -> Result<NodeId, Error> {
1027        self.emit(|b, out| unsafe { ffi::twig_builder_add_text(b, kind.to_c(), text.as_ptr(), text.len(), out) })
1028    }
1029
1030    /// Add a heading of the given level (attach its inline children afterward).
1031    pub fn add_heading(&mut self, level: u32) -> Result<NodeId, Error> {
1032        self.emit(|b, out| unsafe { ffi::twig_builder_add_heading(b, level, out) })
1033    }
1034
1035    /// Add a code block, with an optional info-string language.
1036    pub fn add_code_block(&mut self, lang: Option<&str>, text: &str) -> Result<NodeId, Error> {
1037        let (lp, ll, has) = opt_str(lang);
1038        self.emit(|b, out| unsafe { ffi::twig_builder_add_code_block(b, lp, ll, has, text.as_ptr(), text.len(), out) })
1039    }
1040
1041    /// Add a raw block targeting `format` (e.g. `"html"`).
1042    pub fn add_raw_block(&mut self, format: &str, text: &str) -> Result<NodeId, Error> {
1043        self.emit(|b, out| unsafe {
1044            ffi::twig_builder_add_raw_block(b, format.as_ptr(), format.len(), text.as_ptr(), text.len(), out)
1045        })
1046    }
1047
1048    /// Add a document-metadata block written in config language `lang`.
1049    pub fn add_metadata(&mut self, lang: &str, text: &str) -> Result<NodeId, Error> {
1050        self.emit(|b, out| unsafe {
1051            ffi::twig_builder_add_metadata(b, lang.as_ptr(), lang.len(), text.as_ptr(), text.len(), out)
1052        })
1053    }
1054
1055    /// Add a raw inline targeting `format`.
1056    pub fn add_raw_inline(&mut self, format: &str, text: &str) -> Result<NodeId, Error> {
1057        self.emit(|b, out| unsafe {
1058            ffi::twig_builder_add_raw_inline(b, format.as_ptr(), format.len(), text.as_ptr(), text.len(), out)
1059        })
1060    }
1061
1062    /// Add a smart-punctuation node standing for `text` (its source spelling).
1063    pub fn add_smart_punctuation(&mut self, kind: SmartPunctuation, text: &str) -> Result<NodeId, Error> {
1064        self.emit(|b, out| unsafe {
1065            ffi::twig_builder_add_smart_punctuation(b, kind.to_c(), text.as_ptr(), text.len(), out)
1066        })
1067    }
1068
1069    /// Add a link with an optional destination and/or reference label (attach
1070    /// the link text as children).
1071    pub fn add_link(&mut self, destination: Option<&str>, reference: Option<&str>) -> Result<NodeId, Error> {
1072        let (dp, dl, hd) = opt_str(destination);
1073        let (rp, rl, hr) = opt_str(reference);
1074        self.emit(|b, out| unsafe { ffi::twig_builder_add_link(b, dp, dl, hd, rp, rl, hr, out) })
1075    }
1076
1077    /// Add an image — like [`Builder::add_link`], but children are the alt text.
1078    pub fn add_image(&mut self, destination: Option<&str>, reference: Option<&str>) -> Result<NodeId, Error> {
1079        let (dp, dl, hd) = opt_str(destination);
1080        let (rp, rl, hr) = opt_str(reference);
1081        self.emit(|b, out| unsafe { ffi::twig_builder_add_image(b, dp, dl, hd, rp, rl, hr, out) })
1082    }
1083
1084    /// Add a generic directive of the given form and name.
1085    pub fn add_directive(&mut self, form: DirectiveForm, name: &str) -> Result<NodeId, Error> {
1086        self.emit(|b, out| unsafe { ffi::twig_builder_add_directive(b, form.to_c(), name.as_ptr(), name.len(), out) })
1087    }
1088
1089    /// Add a generic named element (the escape hatch for HTML/XML tags).
1090    pub fn add_element(&mut self, name: &str) -> Result<NodeId, Error> {
1091        self.emit(|b, out| unsafe { ffi::twig_builder_add_element(b, name.as_ptr(), name.len(), out) })
1092    }
1093
1094    /// Add an XML processing instruction (`<?target data?>`).
1095    pub fn add_processing_instruction(&mut self, target: &str, data: &str) -> Result<NodeId, Error> {
1096        self.emit(|b, out| unsafe {
1097            ffi::twig_builder_add_processing_instruction(b, target.as_ptr(), target.len(), data.as_ptr(), data.len(), out)
1098        })
1099    }
1100
1101    /// Add a footnote definition with the given label.
1102    pub fn add_footnote(&mut self, label: &str) -> Result<NodeId, Error> {
1103        self.emit(|b, out| unsafe { ffi::twig_builder_add_footnote(b, label.as_ptr(), label.len(), out) })
1104    }
1105
1106    /// Add a link/image reference definition (`label` → `destination`).
1107    pub fn add_reference(&mut self, label: &str, destination: &str) -> Result<NodeId, Error> {
1108        self.emit(|b, out| unsafe {
1109            ffi::twig_builder_add_reference(b, label.as_ptr(), label.len(), destination.as_ptr(), destination.len(), out)
1110        })
1111    }
1112
1113    /// Add a bullet list.
1114    pub fn add_bullet_list(&mut self, style: BulletStyle, tight: bool) -> Result<NodeId, Error> {
1115        self.emit(|b, out| unsafe { ffi::twig_builder_add_bullet_list(b, style.to_c(), tight as c_int, out) })
1116    }
1117
1118    /// Add an ordered list, with an optional explicit start number.
1119    pub fn add_ordered_list(
1120        &mut self,
1121        numbering: OrderedNumbering,
1122        delim: OrderedDelim,
1123        tight: bool,
1124        start: Option<u32>,
1125    ) -> Result<NodeId, Error> {
1126        let (start_val, has_start) = match start {
1127            Some(s) => (s, 1),
1128            None => (0, 0),
1129        };
1130        self.emit(|b, out| unsafe {
1131            ffi::twig_builder_add_ordered_list(b, numbering.to_c(), delim.to_c(), tight as c_int, start_val, has_start, out)
1132        })
1133    }
1134
1135    /// Add a task list.
1136    pub fn add_task_list(&mut self, tight: bool) -> Result<NodeId, Error> {
1137        self.emit(|b, out| unsafe { ffi::twig_builder_add_task_list(b, tight as c_int, out) })
1138    }
1139
1140    /// Add a task-list item with the given checkbox state.
1141    pub fn add_task_list_item(&mut self, checked: bool) -> Result<NodeId, Error> {
1142        self.emit(|b, out| unsafe { ffi::twig_builder_add_task_list_item(b, checked as c_int, out) })
1143    }
1144
1145    /// Add a table row (`head` marks a header row).
1146    pub fn add_row(&mut self, head: bool) -> Result<NodeId, Error> {
1147        self.emit(|b, out| unsafe { ffi::twig_builder_add_row(b, head as c_int, out) })
1148    }
1149
1150    /// Add a table cell (`head` marks a header cell).
1151    pub fn add_cell(&mut self, head: bool, alignment: Alignment) -> Result<NodeId, Error> {
1152        self.emit(|b, out| unsafe { ffi::twig_builder_add_cell(b, head as c_int, alignment.to_c(), out) })
1153    }
1154
1155    /// Set `parent`'s children to `children` (in order), replacing any it had.
1156    /// Each child id should appear in exactly one `set_children` call.
1157    pub fn set_children(&mut self, parent: NodeId, children: &[NodeId]) -> Result<(), Error> {
1158        let ids: Vec<u32> = children.iter().map(|n| n.0).collect();
1159        let status = unsafe { ffi::twig_builder_set_children(self.raw.as_ptr(), parent.0, ids.as_ptr(), ids.len()) };
1160        Error::from_status(status)
1161    }
1162
1163    /// Attach `{...}` attributes to `id` (`(key, Some(value))`, or
1164    /// `(key, None)` for a bare attribute), replacing any it had. An empty slice
1165    /// clears them.
1166    pub fn set_attrs(&mut self, id: NodeId, attrs: &[(&str, Option<&str>)]) -> Result<(), Error> {
1167        let kvs: Vec<ffi::TwigKeyVal> = attrs
1168            .iter()
1169            .map(|(k, v)| ffi::TwigKeyVal {
1170                key: k.as_ptr(),
1171                key_len: k.len(),
1172                value: v.map_or(std::ptr::null(), |s| s.as_ptr()),
1173                value_len: v.map_or(0, |s| s.len()),
1174            })
1175            .collect();
1176        let status = unsafe { ffi::twig_builder_set_attrs(self.raw.as_ptr(), id.0, kvs.as_ptr(), kvs.len()) };
1177        Error::from_status(status)
1178    }
1179
1180    /// Render the subtree rooted at `root` to HTML (generic whole-vocabulary
1181    /// printer — a built tree has no djot/Markdown side tables).
1182    pub fn render_html(&mut self, root: NodeId) -> Result<Vec<u8>, Error> {
1183        let raw = self.raw.as_ptr();
1184        collect_bytes(|ptr, len| unsafe { ffi::twig_builder_render_html(raw, root.0, ptr, len) })
1185    }
1186
1187    /// Serialize the subtree rooted at `root` to `format`'s source syntax.
1188    /// Returns [`Error::UnsupportedFormat`] when the target can't represent the
1189    /// built tree (e.g. semantic kinds into XML).
1190    pub fn serialize(&mut self, root: NodeId, format: Format) -> Result<Vec<u8>, Error> {
1191        let raw = self.raw.as_ptr();
1192        let ffi_format: ffi::TwigFormat = format.into();
1193        collect_bytes(|ptr, len| unsafe { ffi::twig_builder_serialize(raw, root.0, ffi_format as i32, ptr, len) })
1194    }
1195
1196    /// Encode the subtree rooted at `root` as pretty-printed JSON.
1197    pub fn ast_json(&mut self, root: NodeId) -> Result<Vec<u8>, Error> {
1198        let raw = self.raw.as_ptr();
1199        collect_bytes(|ptr, len| unsafe { ffi::twig_builder_ast_json(raw, root.0, ptr, len) })
1200    }
1201
1202    /// Resolve a selector against the subtree rooted at `root` (same grammar as
1203    /// [`Document::query`]).
1204    pub fn query(&mut self, root: NodeId, selector: &str) -> Result<Vec<QueryMatch>, Error> {
1205        let raw = self.raw.as_ptr();
1206        collect_matches(|ptr, len| unsafe {
1207            ffi::twig_builder_query(raw, root.0, selector.as_ptr(), selector.len(), ptr, len)
1208        })
1209    }
1210
1211    /// Shared plumbing for the `add*` constructors: run `call` (which writes the
1212    /// new node's id) and wrap the result.
1213    fn emit(
1214        &mut self,
1215        call: impl FnOnce(*mut ffi::TwigBuilder, *mut u32) -> ffi::TwigStatus,
1216    ) -> Result<NodeId, Error> {
1217        let mut id: u32 = 0;
1218        let status = call(self.raw.as_ptr(), &mut id);
1219        Error::from_status(status)?;
1220        Ok(NodeId(id))
1221    }
1222}
1223
1224impl Drop for Builder {
1225    fn drop(&mut self) {
1226        unsafe { ffi::twig_builder_destroy(self.raw.as_ptr()) }
1227    }
1228}
1229
1230#[cfg(test)]
1231mod tests {
1232    use super::*;
1233
1234    #[test]
1235    fn abi_version_matches() {
1236        // The linked library must speak the exact ABI layout this crate's
1237        // `#[repr(C)]` mirrors assume. If this fails, the Zig `TWIG_ABI_VERSION`
1238        // was bumped without updating `ffi::TWIG_ABI_VERSION` (and the mirrors).
1239        assert_eq!(abi_version(), ffi::TWIG_ABI_VERSION);
1240    }
1241
1242    #[test]
1243    fn parses_and_renders_markdown_html() {
1244        let mut doc = Document::parse_str("# hi\n", Format::Markdown).expect("parse markdown");
1245        let html = doc.render_html().expect("render html");
1246        assert_eq!(String::from_utf8_lossy(&html), "<h1>hi</h1>\n");
1247    }
1248
1249    #[test]
1250    fn parses_html_input() {
1251        let mut doc = Document::parse_str("<p>hi</p>", Format::Html).expect("parse html");
1252        let html = doc.render_html().expect("render html");
1253        assert!(String::from_utf8_lossy(&html).contains("hi"));
1254    }
1255
1256    #[test]
1257    fn serialize_round_trips_and_cross_converts() {
1258        let mut doc = Document::parse_str("# hi\n", Format::Markdown).expect("parse markdown");
1259
1260        let canonical = doc.serialize(Format::Markdown).expect("serialize markdown");
1261        assert!(String::from_utf8_lossy(&canonical).contains("# hi"));
1262
1263        // Cross-format Markdown -> XML has no serializer.
1264        assert_eq!(doc.serialize(Format::Xml), Err(Error::UnsupportedFormat));
1265    }
1266
1267    #[test]
1268    fn serialize_markdown_to_djot() {
1269        let mut doc =
1270            Document::parse_str("This is *markdown*.\n", Format::Markdown).expect("parse markdown");
1271        let djot = doc.serialize(Format::Djot).expect("serialize djot");
1272        assert!(String::from_utf8_lossy(&djot).contains("_markdown_"));
1273    }
1274
1275    #[test]
1276    fn ast_json_dumps_the_tree() {
1277        let mut doc = Document::parse_str("hello\n", Format::Djot).expect("parse djot");
1278        let json = doc.ast_json().expect("ast json");
1279        assert!(String::from_utf8_lossy(&json).contains("\"kind\": \"doc\""));
1280    }
1281
1282    #[test]
1283    fn query_finds_nodes_by_selector() {
1284        let source = "# One\n\n## Two\n";
1285        let mut doc = Document::parse_str(source, Format::Markdown).expect("parse markdown");
1286        let matches = doc.query("heading").expect("query");
1287
1288        assert_eq!(matches.len(), 2);
1289        for m in &matches {
1290            assert_eq!(m.kind, "heading");
1291            assert!(m.span.start < m.span.end);
1292        }
1293    }
1294
1295    #[test]
1296    fn query_recovers_code_spans() {
1297        let source = "prose `code` more prose\n";
1298        let mut doc = Document::parse_str(source, Format::Markdown).expect("parse markdown");
1299        let matches = doc.query("verbatim").expect("query");
1300
1301        assert_eq!(matches.len(), 1);
1302        assert_eq!(&source[matches[0].span.clone()], "`code`");
1303    }
1304
1305    #[test]
1306    fn query_rejects_a_malformed_selector() {
1307        let mut doc = Document::parse_str("hi\n", Format::Markdown).expect("parse markdown");
1308        assert_eq!(doc.query("list >"), Err(Error::InvalidArgument));
1309    }
1310
1311    #[test]
1312    fn editor_edits_by_index_path() {
1313        let mut ed = Editor::new_str("<a><b>hi</b></a>", Format::Xml).expect("editor");
1314        ed.replace_content("0.0", "bye").expect("replace_content");
1315        assert_eq!(ed.source_str().expect("source"), "<a><b>bye</b></a>");
1316    }
1317
1318    #[test]
1319    fn editor_insert_child_and_delete() {
1320        let mut ed = Editor::new_str("<r><a/><c/></r>", Format::Xml).expect("editor");
1321        ed.insert_child("0", 1, "<b/>").expect("insert_child");
1322        assert_eq!(ed.source_str().expect("source"), "<r><a/><b/><c/></r>");
1323        ed.delete("0.1").expect("delete");
1324        assert_eq!(ed.source_str().expect("source"), "<r><a/><c/></r>");
1325    }
1326
1327    #[test]
1328    fn editor_edits_by_selector() {
1329        let mut ed = Editor::new_str("# One\n\n## Two\n", Format::Markdown).expect("editor");
1330        ed.replace("heading(\"Two\")", "## Renamed").expect("replace");
1331        assert_eq!(ed.source_str().expect("source"), "# One\n\n## Renamed\n");
1332    }
1333
1334    #[test]
1335    fn editor_locator_errors_are_distinct() {
1336        let mut ed = Editor::new_str("<r><a/><a/></r>", Format::Xml).expect("editor");
1337        assert_eq!(ed.replace("0.9", "x"), Err(Error::NotFound));
1338        assert_eq!(ed.replace("element", "x"), Err(Error::Ambiguous));
1339        assert_eq!(ed.replace("element(", "x"), Err(Error::InvalidArgument));
1340        // Untouched by the failed edits.
1341        assert_eq!(ed.source_str().expect("source"), "<r><a/><a/></r>");
1342    }
1343
1344    #[test]
1345    fn editor_reparse_break_rolls_back() {
1346        let mut ed = Editor::new_str("<a>ok</a>", Format::Xml).expect("editor");
1347        assert_eq!(ed.replace_content("0", "<b>"), Err(Error::EditConflict));
1348        assert_eq!(ed.source_str().expect("source"), "<a>ok</a>");
1349    }
1350
1351    #[test]
1352    fn editor_leaf_content_is_not_editable() {
1353        let mut ed = Editor::new_str("<a>hi</a>", Format::Xml).expect("editor");
1354        assert_eq!(ed.replace_content("0.0", "x"), Err(Error::NotEditable));
1355    }
1356
1357    #[test]
1358    fn editor_query_reflects_current_tree() {
1359        let mut ed = Editor::new_str("<r><a/></r>", Format::Xml).expect("editor");
1360        ed.insert_child("0", 1, "<b/>").expect("insert_child");
1361        // Root <r> plus <a/> and <b/>.
1362        assert_eq!(ed.query("element").expect("query").len(), 3);
1363        let json = ed.ast_json().expect("ast_json");
1364        assert!(String::from_utf8_lossy(&json).contains("\"kind\": \"doc\""));
1365    }
1366
1367    // ── offset-addressed editing & read-back (P0–P3) ────────────────────────
1368
1369    #[test]
1370    fn editor_edit_range_types_backspaces_and_reports_change() {
1371        let mut ed = Editor::new_str("ab\n", Format::Markdown).expect("editor");
1372
1373        // Type "X" at offset 1 (a zero-width splice = an insertion).
1374        let c = ed.edit_range(1, 1, "X").expect("edit_range insert");
1375        assert_eq!(ed.source_str().unwrap(), "aXb\n");
1376        assert_eq!(c.old, 1..1);
1377        assert_eq!(c.new, 1..2);
1378        assert_eq!(c.delta(), 1);
1379
1380        // Backspace it (delete the "X").
1381        let c2 = ed.edit_range(1, 2, "").expect("edit_range delete");
1382        assert_eq!(ed.source_str().unwrap(), "ab\n");
1383        assert_eq!(c2.old, 1..2);
1384        assert_eq!(c2.new, 1..1);
1385        assert_eq!(c2.delta(), -1);
1386    }
1387
1388    #[test]
1389    fn editor_edit_range_rejects_bad_ranges() {
1390        let mut ed = Editor::new_str("hi\n", Format::Markdown).expect("editor");
1391        assert_eq!(ed.edit_range(0, 99, "x"), Err(Error::InvalidArgument)); // end past len
1392        assert_eq!(ed.edit_range(2, 1, "x"), Err(Error::InvalidArgument)); // start > end
1393        assert_eq!(ed.source_str().unwrap(), "hi\n"); // untouched
1394    }
1395
1396    #[test]
1397    fn editor_last_change_reports_locator_ops_too() {
1398        let mut ed = Editor::new_str("# One\n\n## Two\n", Format::Markdown).expect("editor");
1399        assert_eq!(ed.last_change(), None); // nothing edited yet
1400
1401        ed.replace("heading(\"Two\")", "## Renamed").expect("replace");
1402        assert_eq!(ed.source_str().unwrap(), "# One\n\n## Renamed\n");
1403        let c = ed.last_change().expect("a change was recorded");
1404        // "## Two" occupied [7,13); "## Renamed" (10 bytes) now occupies [7,17).
1405        assert_eq!(c.old, 7..13);
1406        assert_eq!(c.new, 7..17);
1407    }
1408
1409    #[test]
1410    fn editor_nodes_is_a_walkable_flat_tree() {
1411        let mut ed = Editor::new_str("# Hi\n\ntext\n", Format::Markdown).expect("editor");
1412        let nodes = ed.nodes().expect("nodes");
1413        assert!(!nodes.is_empty());
1414
1415        // Dense, index-aligned ids.
1416        for (i, n) in nodes.iter().enumerate() {
1417            assert_eq!(n.id, NodeId(i as u32));
1418        }
1419        // Exactly one root (no parent), and it's the doc.
1420        let roots: Vec<_> = nodes.iter().filter(|n| n.parent.is_none()).collect();
1421        assert_eq!(roots.len(), 1);
1422        assert_eq!(roots[0].kind, "doc");
1423
1424        // The heading carries its level; the "Hi" text is reachable as a payload.
1425        let heading = nodes.iter().find(|n| n.kind == "heading").expect("a heading");
1426        assert_eq!(heading.level, Some(1));
1427        assert!(nodes.iter().any(|n| n.text.as_deref() == Some("Hi")));
1428
1429        // Every non-root node's parent links back to a node that lists it as a
1430        // child (via first_child/next_sibling).
1431        for n in nodes.iter().filter(|n| n.parent.is_some()) {
1432            let p = &nodes[n.parent.unwrap().0 as usize];
1433            let mut kid = p.first_child;
1434            let mut seen = false;
1435            while let Some(NodeId(k)) = kid {
1436                if k == n.id.0 {
1437                    seen = true;
1438                    break;
1439                }
1440                kid = nodes[k as usize].next_sibling;
1441            }
1442            assert!(seen, "node {:?} not found among its parent's children", n.id);
1443        }
1444    }
1445
1446    #[test]
1447    fn editor_node_at_and_ancestors_hit_test_offsets() {
1448        let mut ed = Editor::new_str("# Hi\n\ntext\n", Format::Markdown).expect("editor");
1449
1450        // Offset 2 is the "H" of the heading "# Hi" [0,4).
1451        let m = ed.node_at(2).expect("node_at").expect("a node covers offset 2");
1452        assert!(m.span.contains(&2));
1453
1454        // The ancestor chain is root-first and ends at the deepest (== node_at).
1455        let chain = ed.ancestors_at(2).expect("ancestors_at");
1456        assert!(!chain.is_empty());
1457        assert_eq!(chain[0].kind, "doc");
1458        assert_eq!(chain.last().unwrap().node_id, m.node_id);
1459
1460        // An out-of-range offset is an error; a gap covers nothing deeper than doc.
1461        assert_eq!(ed.node_at(999), Err(Error::InvalidArgument));
1462    }
1463
1464    // ── range-oriented rich-text ops (P5) ───────────────────────────────────
1465
1466    #[test]
1467    fn editor_wrap_and_toggle_inline_round_trip() {
1468        let mut ed = Editor::new_str("a word b\n", Format::Markdown).expect("editor");
1469
1470        // Bold "word" [2,6); the Change reports the new "**word**" region.
1471        let c = ed.wrap_range(2, 6, InlineKind::Strong).expect("wrap");
1472        assert_eq!(ed.source_str().unwrap(), "a **word** b\n");
1473        assert_eq!(&ed.source_str().unwrap()[c.new.clone()], "**word**");
1474
1475        // Toggle it off by selecting the strong node's interior [4,8).
1476        ed.toggle_inline(4, 8, InlineKind::Strong).expect("toggle off");
1477        assert_eq!(ed.source_str().unwrap(), "a word b\n");
1478
1479        // Toggle emphasis on when the range isn't already marked.
1480        ed.toggle_inline(2, 6, InlineKind::Emph).expect("toggle on");
1481        assert_eq!(ed.source_str().unwrap(), "a *word* b\n");
1482    }
1483
1484    #[test]
1485    fn editor_inline_kind_support_is_format_specific() {
1486        // Markdown has no highlight/mark spelling.
1487        let mut md = Editor::new_str("a word b\n", Format::Markdown).expect("editor");
1488        assert_eq!(md.wrap_range(2, 6, InlineKind::Mark), Err(Error::UnsupportedFormat));
1489
1490        // Djot spells it {=…=}.
1491        let mut dj = Editor::new_str("a word b\n", Format::Djot).expect("editor");
1492        dj.wrap_range(2, 6, InlineKind::Mark).expect("djot mark");
1493        assert_eq!(dj.source_str().unwrap(), "a {=word=} b\n");
1494    }
1495
1496    #[test]
1497    fn editor_toggle_strips_verbatim_without_content_span() {
1498        let mut ed = Editor::new_str("a `code` b\n", Format::Markdown).expect("editor");
1499        // The verbatim node [2,8) has no content_span; toggle peels the backticks.
1500        ed.toggle_inline(2, 8, InlineKind::Verbatim).expect("toggle code off");
1501        assert_eq!(ed.source_str().unwrap(), "a code b\n");
1502    }
1503
1504    #[test]
1505    fn editor_set_block_switches_para_and_heading_levels() {
1506        let mut ed = Editor::new_str("Title\n\nbody text\n", Format::Markdown).expect("editor");
1507
1508        // Paragraph -> H2 (offset 0 is inside "Title").
1509        ed.set_block(0, BlockKind::Heading(2)).expect("to h2");
1510        assert_eq!(ed.source_str().unwrap(), "## Title\n\nbody text\n");
1511
1512        // H2 -> H1 (offset now inside "## Title").
1513        ed.set_block(3, BlockKind::Heading(1)).expect("to h1");
1514        assert_eq!(ed.source_str().unwrap(), "# Title\n\nbody text\n");
1515
1516        // Heading -> paragraph, dropping the marker.
1517        ed.set_block(2, BlockKind::Paragraph).expect("to para");
1518        assert_eq!(ed.source_str().unwrap(), "Title\n\nbody text\n");
1519    }
1520
1521    #[test]
1522    fn editor_set_block_rejects_bad_level_and_format() {
1523        let mut md = Editor::new_str("hi\n", Format::Markdown).expect("editor");
1524        assert_eq!(md.set_block(0, BlockKind::Heading(9)), Err(Error::InvalidArgument));
1525
1526        let mut xml = Editor::new_str("<a>hi</a>", Format::Xml).expect("editor");
1527        assert_eq!(xml.set_block(1, BlockKind::Heading(1)), Err(Error::UnsupportedFormat));
1528    }
1529
1530    #[test]
1531    fn editor_undo_redo_round_trip() {
1532        let mut ed = Editor::new_str("hello\n", Format::Markdown).expect("editor");
1533        ed.edit_range(5, 5, "!").expect("edit");
1534        assert_eq!(ed.source_str().unwrap(), "hello!\n");
1535
1536        let change = ed.undo().expect("undo ok").expect("something to undo");
1537        assert_eq!(ed.source_str().unwrap(), "hello\n");
1538        assert_eq!(change.new.end, 5);
1539        assert!(ed.undo().expect("undo ok").is_none(), "history exhausted");
1540
1541        ed.redo().expect("redo ok").expect("something to redo");
1542        assert_eq!(ed.source_str().unwrap(), "hello!\n");
1543    }
1544
1545    #[test]
1546    fn editor_coalesce_folds_a_run() {
1547        let mut ed = Editor::new_str("\n", Format::Markdown).expect("editor");
1548        ed.edit_range(0, 0, "a").expect("edit");
1549        ed.edit_range(1, 1, "b").expect("edit");
1550        ed.coalesce_last_undo().expect("coalesce");
1551        assert_eq!(ed.source_str().unwrap(), "ab\n");
1552        // One undo removes the whole coalesced run.
1553        ed.undo().expect("undo ok").expect("something to undo");
1554        assert_eq!(ed.source_str().unwrap(), "\n");
1555        assert!(ed.undo().expect("undo ok").is_none());
1556    }
1557
1558    #[test]
1559    fn editor_set_block_converts_setext_heading() {
1560        // A setext heading rebuilt from its content_span collapses the underline.
1561        let mut ed = Editor::new_str("Title\n=====\n\nbody\n", Format::Markdown).expect("editor");
1562        ed.set_block(0, BlockKind::Heading(1)).expect("setext to atx");
1563        assert_eq!(ed.source_str().unwrap(), "# Title\n\nbody\n");
1564    }
1565
1566    #[test]
1567    fn editor_unwrap_and_smart_delete() {
1568        let mut ed = Editor::new_str("<r><box><b/><c/></box></r>", Format::Xml).expect("editor");
1569        ed.unwrap_node("0.0").expect("unwrap"); // <box>
1570        assert_eq!(ed.source_str().expect("source"), "<r><b/><c/></r>");
1571
1572        let mut md = Editor::new_str("A\n\nB\n\nC\n", Format::Markdown).expect("editor");
1573        md.delete_smart("1").expect("delete_smart"); // the "B" paragraph
1574        assert_eq!(md.source_str().expect("source"), "A\n\nC\n");
1575    }
1576
1577    #[test]
1578    fn editor_directives_require_the_extension_flag() {
1579        let src = ":::vis{.public}\nhi\n:::\n";
1580        // Without the flag, the colon-fence lines are plain paragraph text —
1581        // no directive node.
1582        let mut plain = Editor::new_str(src, Format::Markdown).expect("editor");
1583        assert_eq!(plain.query("directive").expect("query").len(), 0);
1584        // With it enabled, the container directive is recognized.
1585        let mut ext = Editor::new_ext(
1586            src.as_bytes(),
1587            Format::Markdown,
1588            MarkdownExtensions { directives: true, ..Default::default() },
1589        )
1590        .expect("editor");
1591        assert_eq!(ext.query("directive").expect("query").len(), 1);
1592    }
1593
1594    #[test]
1595    fn editor_filter_public_audience_view() {
1596        let src = "# Archive\n\n:::vis{.public}\nPublic.\n:::\n\n:::vis{.family}\nPrivate.\n:::\n";
1597        let mut ed = Editor::new_ext(
1598            src.as_bytes(),
1599            Format::Markdown,
1600            MarkdownExtensions { directives: true, ..Default::default() },
1601        )
1602        .expect("editor");
1603        // Drop every vis block except the public one, then unwrap it.
1604        ed.filter(
1605            "directive[name=vis]",
1606            Some("directive[class~=public]"),
1607            true,
1608        )
1609        .expect("filter");
1610        assert_eq!(ed.source_str().expect("source"), "# Archive\n\nPublic.\n");
1611    }
1612
1613    #[test]
1614    fn editor_filter_rejects_a_malformed_selector() {
1615        let mut ed = Editor::new_str("hi\n", Format::Markdown).expect("editor");
1616        assert_eq!(ed.filter("list >", None, false), Err(Error::InvalidArgument));
1617    }
1618
1619    #[test]
1620    fn builder_builds_and_renders_a_document() {
1621        let mut b = Builder::new().expect("builder");
1622
1623        // # Title\n\nhello *world*
1624        let title = b.add_text(TextKind::Str, "Title").unwrap();
1625        let heading = b.add_heading(1).unwrap();
1626        b.set_children(heading, &[title]).unwrap();
1627
1628        let hello = b.add_text(TextKind::Str, "hello ").unwrap();
1629        let world = b.add_text(TextKind::Str, "world").unwrap();
1630        let emph = b.add(VoidKind::Emph).unwrap();
1631        b.set_children(emph, &[world]).unwrap();
1632        let para = b.add(VoidKind::Para).unwrap();
1633        b.set_children(para, &[hello, emph]).unwrap();
1634
1635        let doc = b.add(VoidKind::Doc).unwrap();
1636        b.set_children(doc, &[heading, para]).unwrap();
1637
1638        let html = String::from_utf8(b.render_html(doc).unwrap()).unwrap();
1639        assert!(html.contains("<h1>Title</h1>"), "{html}");
1640        assert!(html.contains("<em>world</em>"), "{html}");
1641
1642        let md = String::from_utf8(b.serialize(doc, Format::Markdown).unwrap()).unwrap();
1643        assert!(md.contains("# Title"), "{md}");
1644        assert!(md.contains("*world*"), "{md}");
1645
1646        let matches = b.query(doc, "heading").unwrap();
1647        assert_eq!(matches.len(), 1);
1648        assert_eq!(matches[0].kind, "heading");
1649
1650        let json = String::from_utf8(b.ast_json(doc).unwrap()).unwrap();
1651        assert!(json.contains("\"kind\": \"doc\""), "{json}");
1652    }
1653
1654    #[test]
1655    fn builder_element_with_attributes() {
1656        let mut b = Builder::new().expect("builder");
1657        let inner = b.add_text(TextKind::Str, "hi").unwrap();
1658        let el = b.add_element("section").unwrap();
1659        b.set_children(el, &[inner]).unwrap();
1660        b.set_attrs(el, &[("class", Some("note")), ("hidden", None)]).unwrap();
1661
1662        let html = String::from_utf8(b.render_html(el).unwrap()).unwrap();
1663        assert!(html.contains("<section"), "{html}");
1664        assert!(html.contains("class=\"note\""), "{html}");
1665        assert!(html.contains("hidden"), "{html}");
1666    }
1667
1668    #[test]
1669    fn builder_lists_round_trip_to_markdown() {
1670        let mut b = Builder::new().expect("builder");
1671
1672        // An ordered list: 1. one / 2. two
1673        let one_txt = b.add_text(TextKind::Str, "one").unwrap();
1674        let one_para = b.add(VoidKind::Para).unwrap();
1675        b.set_children(one_para, &[one_txt]).unwrap();
1676        let one = b.add(VoidKind::ListItem).unwrap();
1677        b.set_children(one, &[one_para]).unwrap();
1678
1679        let two_txt = b.add_text(TextKind::Str, "two").unwrap();
1680        let two_para = b.add(VoidKind::Para).unwrap();
1681        b.set_children(two_para, &[two_txt]).unwrap();
1682        let two = b.add(VoidKind::ListItem).unwrap();
1683        b.set_children(two, &[two_para]).unwrap();
1684
1685        let list = b
1686            .add_ordered_list(OrderedNumbering::Decimal, OrderedDelim::Period, true, Some(1))
1687            .unwrap();
1688        b.set_children(list, &[one, two]).unwrap();
1689        let doc = b.add(VoidKind::Doc).unwrap();
1690        b.set_children(doc, &[list]).unwrap();
1691
1692        let md = String::from_utf8(b.serialize(doc, Format::Markdown).unwrap()).unwrap();
1693        assert!(md.contains("1. one"), "{md}");
1694        assert!(md.contains("2. two"), "{md}");
1695    }
1696
1697    #[test]
1698    fn builder_rejects_invalid_kind_and_id() {
1699        let b = Builder::new().expect("builder");
1700        // `heading` (code 2) carries a payload, so the void-kind `add` rejects it
1701        // — the safe `VoidKind` enum has no such variant, so we go through the raw
1702        // ABI to prove the guard.
1703        let mut id = 0u32;
1704        let status = unsafe { ffi::twig_builder_add(b.raw.as_ptr(), 2, &mut id) };
1705        assert_eq!(Error::from_status(status), Err(Error::InvalidArgument));
1706
1707        // A root id past the end can't be rendered.
1708        let mut ptr = std::ptr::null();
1709        let mut len = 0usize;
1710        let status = unsafe { ffi::twig_builder_render_html(b.raw.as_ptr(), 4242, &mut ptr, &mut len) };
1711        assert_eq!(Error::from_status(status), Err(Error::InvalidArgument));
1712    }
1713}