Skip to main content

twig/
lib.rs

1mod error;
2
3// The raw FFI layer moved to the `twig-sys` crate. Alias it as `ffi` so every
4// `ffi::…` / `crate::ffi::…` reference in this crate keeps resolving unchanged,
5// and so `twig-sys`'s build script (via its `links = "twig"`) links `libtwig.a`
6// into this crate.
7pub(crate) use twig_sys as ffi;
8
9use std::ops::Range;
10use std::os::raw::{c_char, c_int};
11use std::ptr::NonNull;
12
13pub use error::Error;
14pub use ffi::TwigSpan as Span;
15
16#[derive(Clone, Copy, Debug, Eq, PartialEq)]
17pub enum Format {
18    Djot,
19    Markdown,
20    Xml,
21    Html,
22}
23
24impl From<Format> for ffi::TwigFormat {
25    fn from(value: Format) -> Self {
26        match value {
27            Format::Djot => ffi::TwigFormat::Djot,
28            Format::Markdown => ffi::TwigFormat::Markdown,
29            Format::Xml => ffi::TwigFormat::Xml,
30            Format::Html => ffi::TwigFormat::Html,
31        }
32    }
33}
34
35/// One node returned by [`Document::query`]: its AST id, byte spans, and kind.
36#[derive(Clone, Debug, Eq, PartialEq)]
37pub struct QueryMatch {
38    /// The node's id in the shared AST.
39    pub node_id: u32,
40    /// The node's whole byte range in the source.
41    pub span: Range<usize>,
42    /// The node's interior byte range (between its delimiters), or `None` for
43    /// a leaf / a container with no known interior.
44    pub content_span: Option<Range<usize>>,
45    /// The node-kind name (e.g. `"heading"`, `"code_block"`).
46    pub kind: String,
47}
48
49/// The byte-level effect of an [`Editor`] edit: `old` is the range of the
50/// pre-edit source that was replaced, `new` the range the replacement now
51/// occupies in the post-edit source (they share a start). An insertion has an
52/// empty `old`; a deletion an empty `new`. Everything a caret/selection needs
53/// to re-anchor across an edit without re-diffing: shift any offset `>= old.end`
54/// by `new.len() - old.len()`.
55#[derive(Clone, Debug, Eq, PartialEq)]
56pub struct Change {
57    pub old: Range<usize>,
58    pub new: Range<usize>,
59}
60
61impl Change {
62    /// The net change in source length (`new.len() - old.len()`).
63    pub fn delta(&self) -> isize {
64        self.new.len() as isize - self.old.len() as isize
65    }
66
67    fn from_ffi(c: ffi::TwigChange) -> Self {
68        Change {
69            old: c.old_span.start..c.old_span.end,
70            new: c.new_span.start..c.new_span.end,
71        }
72    }
73}
74
75/// One node of an [`Editor::nodes`] snapshot — the flat AST arena as owned Rust
76/// data (the JSON-free read path). `id` indexes the snapshot; `parent`,
77/// `first_child`, and `next_sibling` link the tree (`None` where absent).
78/// `text` is the node's primary payload (a `str`'s bytes, a `code_block`'s
79/// body, …) and `destination` a link/image target, each `None` when the kind
80/// carries no such payload.
81/// `#[non_exhaustive]`: a snapshot node is something twig *hands you*, never
82/// something you build, so it gains a field whenever a node kind's payload is
83/// surfaced (as `head`/`alignment` were for tables). Sealing construction here
84/// keeps every future addition a minor release instead of a major one.
85#[derive(Clone, Debug, Eq, PartialEq)]
86#[non_exhaustive]
87pub struct FlatNode {
88    pub id: NodeId,
89    pub parent: Option<NodeId>,
90    pub first_child: Option<NodeId>,
91    pub next_sibling: Option<NodeId>,
92    pub span: Range<usize>,
93    pub content_span: Option<Range<usize>>,
94    /// A heading's level; `None` for every other kind.
95    pub level: Option<u32>,
96    pub kind: String,
97    pub text: Option<String>,
98    pub destination: Option<String>,
99    /// Whether a `row`/`cell` belongs to the table head; `None` for every other
100    /// kind.
101    pub head: Option<bool>,
102    /// A `cell`'s column alignment; `None` for every other kind. The delimiter
103    /// row (`|:--|--:|`) that spells the alignment out is consumed by the parser
104    /// and has no node of its own, so this is the only way to recover it.
105    /// [`Alignment::Default`] is a real, unspecified alignment (a bare `---`) —
106    /// distinct from the `None` a non-cell node reports.
107    pub alignment: Option<Alignment>,
108    /// A generic `element`'s tag name (`"picture"`, `"source"`, …); `None` for
109    /// every semantic kind (whose identity is `kind` alone). With this an
110    /// `html_elements` parse's `<picture>`/`<source>` are distinguishable — both
111    /// report `kind == "element"`.
112    pub name: Option<String>,
113    /// The node's `{...}` / HTML attributes as `(key, value)` pairs in source
114    /// order (empty when it has none). A bare attribute (HTML `disabled`, or a
115    /// `<source media=…>` used as a flag) has a `None` value.
116    pub attrs: Vec<(String, Option<String>)>,
117}
118
119/// An inline mark for [`Editor::wrap_range`] / [`Editor::toggle_inline`] — a
120/// rich editor's Bold / Italic / Code / … buttons. Markdown spells only
121/// [`InlineKind::Strong`], [`InlineKind::Emph`], and [`InlineKind::Verbatim`];
122/// Djot spells all of them. An unsupported kind yields [`Error::UnsupportedFormat`].
123#[derive(Clone, Copy, Debug, Eq, PartialEq)]
124pub enum InlineKind {
125    Strong,
126    Emph,
127    Verbatim,
128    Mark,
129    Superscript,
130    Subscript,
131    Insert,
132    Delete,
133}
134
135impl InlineKind {
136    fn to_c(self) -> c_int {
137        match self {
138            InlineKind::Strong => 0,
139            InlineKind::Emph => 1,
140            InlineKind::Verbatim => 2,
141            InlineKind::Mark => 3,
142            InlineKind::Superscript => 4,
143            InlineKind::Subscript => 5,
144            InlineKind::Insert => 6,
145            InlineKind::Delete => 7,
146        }
147    }
148}
149
150/// A block target for [`Editor::set_block`] — the toolbar's H1…H6 / Body switch.
151#[derive(Clone, Copy, Debug, Eq, PartialEq)]
152pub enum BlockKind {
153    Paragraph,
154    /// A heading of the given level (1–6; out of range is [`Error::InvalidArgument`]).
155    Heading(u32),
156}
157
158impl BlockKind {
159    /// `(block_kind_code, level)` for the C ABI.
160    fn to_c(self) -> (c_int, u32) {
161        match self {
162            BlockKind::Paragraph => (0, 0),
163            BlockKind::Heading(level) => (1, level),
164        }
165    }
166}
167
168/// A block container for [`Editor::toggle_block_container`] — the toolbar's
169/// Quote / Bulleted list / Numbered list buttons. Where a [`BlockKind`] rewrites
170/// one block's leading marker, a container prefixes every line of a range and
171/// nests. Djot and Markdown spell all three; other formats yield
172/// [`Error::UnsupportedFormat`].
173#[derive(Clone, Copy, Debug, Eq, PartialEq)]
174pub enum BlockContainerKind {
175    BlockQuote,
176    BulletList,
177    OrderedList,
178}
179
180impl BlockContainerKind {
181    fn to_c(self) -> c_int {
182        match self {
183            BlockContainerKind::BlockQuote => 0,
184            BlockContainerKind::BulletList => 1,
185            BlockContainerKind::OrderedList => 2,
186        }
187    }
188}
189
190#[derive(Clone, Copy, Debug, Eq, PartialEq)]
191pub struct Version {
192    pub major: u8,
193    pub minor: u8,
194    pub patch: u8,
195}
196
197pub fn version() -> Version {
198    let packed = unsafe { ffi::twig_version() };
199    Version {
200        major: (packed >> 16) as u8,
201        minor: (packed >> 8) as u8,
202        patch: packed as u8,
203    }
204}
205
206/// The C ABI contract version this crate was **compiled** against — the
207/// compile-time counterpart to [`abi_version`] (which reports the **linked
208/// library's**). This crate builds and links its own vendored copy of the Zig
209/// source, so the two always agree; the pair is exposed so a consumer embedding
210/// a separately-built library can verify layout compatibility at load time.
211pub const ABI_VERSION: u32 = ffi::TWIG_ABI_VERSION;
212
213/// The C ABI contract version of the linked library. This crate is written
214/// against [`ABI_VERSION`]; the two agreeing is what makes the `#[repr(C)]`
215/// mirrors in `ffi` sound. It is bumped only on a breaking ABI change (a struct
216/// layout change or a renumbered enum value), never on an additive one (a new
217/// format code or a new function).
218pub fn abi_version() -> u32 {
219    unsafe { ffi::twig_abi_version() }
220}
221
222pub fn version_string() -> &'static str {
223    let ptr = unsafe { ffi::twig_version_string() };
224    unsafe { std::ffi::CStr::from_ptr(ptr) }
225        .to_str()
226        .unwrap_or("")
227}
228
229#[derive(Debug)]
230pub struct Document {
231    raw: NonNull<ffi::TwigDocument>,
232}
233
234impl Document {
235    pub fn parse(input: &[u8], format: Format) -> Result<Self, Error> {
236        Self::parse_with(input, format, MarkdownExtensions::default())
237    }
238
239    pub fn parse_str(input: &str, format: Format) -> Result<Self, Error> {
240        Self::parse(input.as_bytes(), format)
241    }
242
243    /// Like [`Document::parse`], plus Markdown `extensions` to enable (ignored
244    /// for other formats) — the read-path counterpart of [`Editor::new_ext`].
245    /// Enable [`MarkdownExtensions::html_elements`] here to make embedded HTML
246    /// (`<img>`, `<picture>`, …) queryable via [`Document::query`] instead of
247    /// arriving as opaque raw HTML.
248    pub fn parse_with(
249        input: &[u8],
250        format: Format,
251        extensions: MarkdownExtensions,
252    ) -> Result<Self, Error> {
253        let mut raw = std::ptr::null_mut();
254        let ffi_format: ffi::TwigFormat = format.into();
255        let status = unsafe {
256            ffi::twig_parse_ext(
257                input.as_ptr(),
258                input.len(),
259                ffi_format as i32,
260                extensions.to_flags(),
261                &mut raw,
262            )
263        };
264        Error::from_status(status)?;
265        let raw = NonNull::new(raw).ok_or(Error::Internal)?;
266        Ok(Self { raw })
267    }
268
269    /// [`Document::parse_with`] for a `&str`.
270    pub fn parse_str_with(
271        input: &str,
272        format: Format,
273        extensions: MarkdownExtensions,
274    ) -> Result<Self, Error> {
275        Self::parse_with(input.as_bytes(), format, extensions)
276    }
277
278    /// Render the document to HTML. For Djot/Markdown this is the rich
279    /// rendering path that resolves reference/footnote side tables.
280    pub fn render_html(&mut self) -> Result<Vec<u8>, Error> {
281        let raw = self.raw.as_ptr();
282        collect_bytes(|ptr, len| unsafe { ffi::twig_document_render_html(raw, ptr, len) })
283    }
284
285    /// Serialize the document to `format`'s own source syntax: a round-trip
286    /// when `format` matches the document's own format, cross-format
287    /// conversion otherwise (e.g. parse Markdown, serialize as Djot). Returns
288    /// [`Error::UnsupportedFormat`] when the requested direction has no
289    /// serializer (today: converting into XML from another format).
290    pub fn serialize(&mut self, format: Format) -> Result<Vec<u8>, Error> {
291        let raw = self.raw.as_ptr();
292        let ffi_format: ffi::TwigFormat = format.into();
293        collect_bytes(|ptr, len| unsafe {
294            ffi::twig_document_serialize(raw, ffi_format as i32, ptr, len)
295        })
296    }
297
298    /// Encode the document's AST as pretty-printed JSON (the same encoding as
299    /// `twig convert -o ast`).
300    pub fn ast_json(&mut self) -> Result<Vec<u8>, Error> {
301        let raw = self.raw.as_ptr();
302        collect_bytes(|ptr, len| unsafe { ffi::twig_document_ast_json(raw, ptr, len) })
303    }
304
305    /// Resolve a CSS-lite selector (e.g. `heading[level=2]`,
306    /// `link[dest^="http"]`, `code`, `list > item`) against the document,
307    /// returning one [`QueryMatch`] per matching node in document order. A
308    /// malformed selector yields [`Error::InvalidArgument`].
309    ///
310    /// This is the general replacement for scanning code spans by hand: a
311    /// `verbatim` / `code_block` / `raw_inline` / `raw_block` selector recovers
312    /// those, and every other node kind is reachable too.
313    pub fn query(&mut self, selector: &str) -> Result<Vec<QueryMatch>, Error> {
314        let raw = self.raw.as_ptr();
315        collect_matches(|ptr, len| unsafe {
316            ffi::twig_document_query(raw, selector.as_ptr(), selector.len(), ptr, len)
317        })
318    }
319}
320
321impl Drop for Document {
322    fn drop(&mut self) {
323        unsafe { ffi::twig_document_destroy(self.raw.as_ptr()) }
324    }
325}
326
327/// Opt-in Markdown extensions to enable for a parse — for either the read path
328/// ([`Document::parse_with`]) or the edit path ([`Editor::new_ext`]). Ignored
329/// for non-Markdown formats. Every field defaults off, matching the library; the
330/// default-on extensions (tables, strikethrough, task lists, …) are always on
331/// and need no flag here.
332#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
333pub struct MarkdownExtensions {
334    /// Generic directives: `:name`, `::name`, `:::name`.
335    pub directives: bool,
336    /// `$...$` / `$$...$$` math.
337    pub math: bool,
338    /// Parse recognized raw HTML into semantic AST nodes — an `<img>` becomes an
339    /// [`image` node](FlatNode) instead of an opaque `raw_block`/`raw_inline`, so
340    /// it is addressable by [`Document::query`] and the tree read paths. Only
341    /// tags that map verbatim onto the source are promoted; the rest stay raw.
342    pub html_elements: bool,
343}
344
345impl MarkdownExtensions {
346    fn to_flags(self) -> u32 {
347        let mut flags = 0;
348        if self.directives {
349            flags |= ffi::TWIG_MD_DIRECTIVES;
350        }
351        if self.math {
352            flags |= ffi::TWIG_MD_MATH;
353        }
354        if self.html_elements {
355            flags |= ffi::TWIG_MD_HTML_ELEMENTS;
356        }
357        flags
358    }
359}
360
361/// A span-splice editor over a document: applies lossless, in-place edits and
362/// reparses after each one, so node addressing stays valid as the document
363/// evolves. Every op is addressed by a `locator` — a dot-separated index path
364/// (`"0.3.1"`) or a selector that must match exactly one node
365/// (`heading("Status")`). A failed edit leaves the document unchanged.
366#[derive(Debug)]
367pub struct Editor {
368    raw: NonNull<ffi::TwigEditor>,
369}
370
371impl Editor {
372    /// Create an editor over a private copy of `input`, parsed as `format` with
373    /// default options.
374    pub fn new(input: &[u8], format: Format) -> Result<Self, Error> {
375        let mut raw = std::ptr::null_mut();
376        let ffi_format: ffi::TwigFormat = format.into();
377        let status =
378            unsafe { ffi::twig_editor_create(input.as_ptr(), input.len(), ffi_format as i32, &mut raw) };
379        Error::from_status(status)?;
380        let raw = NonNull::new(raw).ok_or(Error::Internal)?;
381        Ok(Self { raw })
382    }
383
384    pub fn new_str(input: &str, format: Format) -> Result<Self, Error> {
385        Self::new(input.as_bytes(), format)
386    }
387
388    /// Like [`Editor::new`], plus Markdown `extensions` to enable (ignored for
389    /// other formats). The editor reparses with these after every edit, so a
390    /// directive-bearing document stays parseable — needed before
391    /// [`Editor::filter`] can match `directive[...]` selectors.
392    pub fn new_ext(input: &[u8], format: Format, extensions: MarkdownExtensions) -> Result<Self, Error> {
393        let mut raw = std::ptr::null_mut();
394        let ffi_format: ffi::TwigFormat = format.into();
395        let status = unsafe {
396            ffi::twig_editor_create_ext(
397                input.as_ptr(),
398                input.len(),
399                ffi_format as i32,
400                extensions.to_flags(),
401                &mut raw,
402            )
403        };
404        Error::from_status(status)?;
405        let raw = NonNull::new(raw).ok_or(Error::Internal)?;
406        Ok(Self { raw })
407    }
408
409    /// Replace the whole source of the located node with `text`.
410    pub fn replace(&mut self, locator: &str, text: &str) -> Result<(), Error> {
411        self.apply(locator, text, |ed, loc, loc_len, txt, txt_len| unsafe {
412            ffi::twig_editor_replace(ed, loc, loc_len, txt, txt_len)
413        })
414    }
415
416    /// Replace the interior (between-delimiters content) of the located
417    /// container.
418    pub fn replace_content(&mut self, locator: &str, text: &str) -> Result<(), Error> {
419        self.apply(locator, text, |ed, loc, loc_len, txt, txt_len| unsafe {
420            ffi::twig_editor_replace_content(ed, loc, loc_len, txt, txt_len)
421        })
422    }
423
424    /// Insert `text` immediately before the located node.
425    pub fn insert_before(&mut self, locator: &str, text: &str) -> Result<(), Error> {
426        self.apply(locator, text, |ed, loc, loc_len, txt, txt_len| unsafe {
427            ffi::twig_editor_insert_before(ed, loc, loc_len, txt, txt_len)
428        })
429    }
430
431    /// Insert `text` immediately after the located node.
432    pub fn insert_after(&mut self, locator: &str, text: &str) -> Result<(), Error> {
433        self.apply(locator, text, |ed, loc, loc_len, txt, txt_len| unsafe {
434            ffi::twig_editor_insert_after(ed, loc, loc_len, txt, txt_len)
435        })
436    }
437
438    /// Insert `text` as the `index`-th child of the located container (an index
439    /// at or past the child count appends).
440    pub fn insert_child(&mut self, locator: &str, index: usize, text: &str) -> Result<(), Error> {
441        let status = unsafe {
442            ffi::twig_editor_insert_child(
443                self.raw.as_ptr(),
444                locator.as_ptr(),
445                locator.len(),
446                index,
447                text.as_ptr(),
448                text.len(),
449            )
450        };
451        Error::from_status(status)
452    }
453
454    /// Delete the located node (removes exactly its span; no whitespace
455    /// cleanup).
456    pub fn delete(&mut self, locator: &str) -> Result<(), Error> {
457        let status = unsafe {
458            ffi::twig_editor_delete(self.raw.as_ptr(), locator.as_ptr(), locator.len())
459        };
460        Error::from_status(status)
461    }
462
463    /// Delete the located node, tidying surrounding blank lines for a
464    /// whole-line (block) node; an inline node degrades to the exact delete.
465    pub fn delete_smart(&mut self, locator: &str) -> Result<(), Error> {
466        let status = unsafe {
467            ffi::twig_editor_delete_smart(self.raw.as_ptr(), locator.as_ptr(), locator.len())
468        };
469        Error::from_status(status)
470    }
471
472    /// Unwrap the located node: replace it with its interior (drop the wrapper,
473    /// keep the children) — e.g. peel a `:::vis{...}` container. A node with no
474    /// interior (a leaf, or an empty container) is removed.
475    pub fn unwrap_node(&mut self, locator: &str) -> Result<(), Error> {
476        let status = unsafe {
477            ffi::twig_editor_unwrap(self.raw.as_ptr(), locator.as_ptr(), locator.len())
478        };
479        Error::from_status(status)
480    }
481
482    /// Prune the document in place: remove every node matching the `drop`
483    /// selector except those also matching `keep` (`None` spares nothing),
484    /// then — if `unwrap_kept` — unwrap the survivors. Read the result with
485    /// [`Editor::source`].
486    pub fn filter(&mut self, drop: &str, keep: Option<&str>, unwrap_kept: bool) -> Result<(), Error> {
487        let (keep_ptr, keep_len) = match keep {
488            Some(k) => (k.as_ptr(), k.len()),
489            None => (std::ptr::null(), 0),
490        };
491        let status = unsafe {
492            ffi::twig_editor_filter(
493                self.raw.as_ptr(),
494                drop.as_ptr(),
495                drop.len(),
496                keep_ptr,
497                keep_len,
498                unwrap_kept as i32,
499            )
500        };
501        Error::from_status(status)
502    }
503
504    /// The editor's current (edited) source bytes.
505    pub fn source(&mut self) -> Result<Vec<u8>, Error> {
506        let raw = self.raw.as_ptr();
507        collect_bytes(|ptr, len| unsafe { ffi::twig_editor_source(raw, ptr, len) })
508    }
509
510    /// The editor's current source bytes as a UTF-8 string.
511    pub fn source_str(&mut self) -> Result<String, Error> {
512        String::from_utf8(self.source()?).map_err(|_| Error::Internal)
513    }
514
515    /// Encode the editor's current tree as pretty-printed JSON — the live
516    /// counterpart of [`Document::ast_json`], for inspecting between edits.
517    pub fn ast_json(&mut self) -> Result<Vec<u8>, Error> {
518        let raw = self.raw.as_ptr();
519        collect_bytes(|ptr, len| unsafe { ffi::twig_editor_ast_json(raw, ptr, len) })
520    }
521
522    /// Resolve a selector against the editor's current tree — the live
523    /// counterpart of [`Document::query`].
524    pub fn query(&mut self, selector: &str) -> Result<Vec<QueryMatch>, Error> {
525        let raw = self.raw.as_ptr();
526        collect_matches(|ptr, len| unsafe {
527            ffi::twig_editor_query(raw, selector.as_ptr(), selector.len(), ptr, len)
528        })
529    }
530
531    // ── offset-addressed editing & read-back ────────────────────────────────
532
533    /// Splice `[start, end)` of the current source with `text`, reparse, and
534    /// return the [`Change`] the edit produced — the offset-addressed primitive
535    /// a caret editor is built on: a keystroke is `edit_range(c, c, "x")`,
536    /// backspace `edit_range(c - 1, c, "")`, a selection replace
537    /// `edit_range(a, b, s)`. `start <= end <= ` source length, else
538    /// [`Error::InvalidArgument`]. A reparse-breaking edit is rolled back and
539    /// returns [`Error::EditConflict`], leaving the document untouched.
540    pub fn edit_range(&mut self, start: usize, end: usize, text: &str) -> Result<Change, Error> {
541        let mut change = ffi::TwigChange {
542            old_span: ffi::TwigSpan { start: 0, end: 0 },
543            new_span: ffi::TwigSpan { start: 0, end: 0 },
544        };
545        let status = unsafe {
546            ffi::twig_editor_edit_range(
547                self.raw.as_ptr(),
548                start,
549                end,
550                text.as_ptr(),
551                text.len(),
552                &mut change,
553            )
554        };
555        Error::from_status(status)?;
556        Ok(Change::from_ffi(change))
557    }
558
559    /// The byte effect of the last successful edit — including the locator ops
560    /// ([`Editor::replace`], [`Editor::delete_smart`], …), so any edit can
561    /// re-anchor a caret without re-diffing. `None` before the first successful
562    /// edit. (A multi-splice op such as [`Editor::filter`] reports only its
563    /// final splice.)
564    pub fn last_change(&mut self) -> Option<Change> {
565        let mut change = ffi::TwigChange {
566            old_span: ffi::TwigSpan { start: 0, end: 0 },
567            new_span: ffi::TwigSpan { start: 0, end: 0 },
568        };
569        let status = unsafe { ffi::twig_editor_last_change(self.raw.as_ptr(), &mut change) };
570        match status.0 {
571            ffi::TwigStatus::OK => Some(Change::from_ffi(change)),
572            _ => None,
573        }
574    }
575
576    /// Undo the last edit step, restoring the previous source and reparsing.
577    /// Returns the [`Change`] the undo produced (current → restored) so a caret
578    /// can re-anchor, or `None` when there's nothing to undo. History accrues
579    /// across every successful edit that funnels through the splice primitive.
580    pub fn undo(&mut self) -> Result<Option<Change>, Error> {
581        let mut change = ffi::TwigChange {
582            old_span: ffi::TwigSpan { start: 0, end: 0 },
583            new_span: ffi::TwigSpan { start: 0, end: 0 },
584        };
585        let status = unsafe { ffi::twig_editor_undo(self.raw.as_ptr(), &mut change) };
586        if status.0 == ffi::TwigStatus::NOT_FOUND {
587            return Ok(None);
588        }
589        Error::from_status(status)?;
590        Ok(Some(Change::from_ffi(change)))
591    }
592
593    /// Redo the most recently undone edit step; the inverse of [`Editor::undo`].
594    /// Returns `None` when the redo stack is empty (nothing undone, or a fresh
595    /// edit has invalidated it).
596    pub fn redo(&mut self) -> Result<Option<Change>, Error> {
597        let mut change = ffi::TwigChange {
598            old_span: ffi::TwigSpan { start: 0, end: 0 },
599            new_span: ffi::TwigSpan { start: 0, end: 0 },
600        };
601        let status = unsafe { ffi::twig_editor_redo(self.raw.as_ptr(), &mut change) };
602        if status.0 == ffi::TwigStatus::NOT_FOUND {
603            return Ok(None);
604        }
605        Error::from_status(status)?;
606        Ok(Some(Change::from_ffi(change)))
607    }
608
609    /// Fold the most recent edit into the undo step before it, so a caret editor
610    /// can coalesce a run of keystrokes into a single undo. Call right after an
611    /// `edit_range` that continues a run (same kind, no intervening caret move);
612    /// a no-op unless there are at least two steps to merge.
613    pub fn coalesce_last_undo(&mut self) -> Result<(), Error> {
614        let status = unsafe { ffi::twig_editor_coalesce_last(self.raw.as_ptr()) };
615        Error::from_status(status)
616    }
617
618    /// A monotonic change token, bumped once per successful mutation of the
619    /// document (every edit and every undo/redo). Never decreases and never
620    /// repeats for the life of the editor; the initial parse is revision 0.
621    /// Equal revision means a byte-identical document, so it can key a cache
622    /// instead of hand-tracking "did anything change?".
623    pub fn revision(&mut self) -> u64 {
624        unsafe { ffi::twig_editor_revision(self.raw.as_ptr()) }
625    }
626
627    /// The cumulative dirty byte range since the last [`Editor::clear_dirty`]
628    /// (or since the editor was created) — the union of every mutation's byte
629    /// effect over that window, in current source coordinates — or `None` when
630    /// the document is clean relative to the last clear.
631    ///
632    /// The incremental-rebuild companion to [`Editor::revision`]: `revision`
633    /// says *whether* a cached view (glyph rows, syntax spans) needs rebuilding,
634    /// this says *which bytes* changed, so a consumer rebuilds only the affected
635    /// part instead of the whole document. A single conservative interval: it
636    /// always covers every changed byte and may over-cover the gap between edits
637    /// to disjoint regions, but never under-covers.
638    ///
639    /// It reports where *bytes* differ — exact, because twig splices losslessly
640    /// and never reflows untouched bytes — not where the *parse* differs. An
641    /// edit can reinterpret bytes outside the range (opening a code fence, a `#`
642    /// promoting a paragraph to a heading), so a consumer rebuilding *structure*
643    /// from it should widen the range to the enclosing block(s) itself (e.g. via
644    /// [`Editor::node_at`] on each end). Typical loop: on a repaint, if
645    /// [`Editor::revision`] moved, read this range, rebuild the rows it (widened)
646    /// covers, then call [`Editor::clear_dirty`].
647    pub fn dirty_range(&mut self) -> Option<Range<usize>> {
648        let mut span = ffi::TwigSpan { start: 0, end: 0 };
649        let status = unsafe { ffi::twig_editor_dirty_range(self.raw.as_ptr(), &mut span) };
650        match status.0 {
651            ffi::TwigStatus::OK => Some(span.start..span.end),
652            _ => None,
653        }
654    }
655
656    /// Acknowledge the current dirty range: mark the document clean so a later
657    /// [`Editor::dirty_range`] reports only mutations made after this call. Call
658    /// it once you've consumed the range (rebuilt the affected view). Leaves the
659    /// document, [`Editor::revision`], and [`Editor::last_change`] untouched.
660    pub fn clear_dirty(&mut self) {
661        unsafe { ffi::twig_editor_clear_dirty(self.raw.as_ptr()) };
662    }
663
664    /// Attach an opaque, caller-owned blob (e.g. a serialized caret/selection)
665    /// to the editor's current document state. Twig copies the bytes and never
666    /// interprets them; it only carries them through the undo history so
667    /// [`Editor::undo`]/[`Editor::redo`] hand back the caret matching the
668    /// restored source (via [`Editor::caret_blob`]). Set it with the pre-edit
669    /// caret *before* an edit so the retired undo step captures it. An empty
670    /// blob clears the current caret.
671    pub fn set_caret_blob(&mut self, blob: &[u8]) -> Result<(), Error> {
672        let status =
673            unsafe { ffi::twig_editor_set_caret_blob(self.raw.as_ptr(), blob.as_ptr(), blob.len()) };
674        Error::from_status(status)
675    }
676
677    /// The opaque caret blob for the editor's current document state (see
678    /// [`Editor::set_caret_blob`]). After [`Editor::undo`]/[`Editor::redo`] this
679    /// is the restored state's caret; after an edit it is empty until set again.
680    /// Returns an owned copy, so it outlives the next edit.
681    pub fn caret_blob(&mut self) -> Result<Vec<u8>, Error> {
682        let raw = self.raw.as_ptr();
683        collect_bytes(|ptr, len| unsafe { ffi::twig_editor_caret_blob(raw, ptr, len) })
684    }
685
686    /// Snapshot the current tree as a flat [`FlatNode`] array (the JSON-free
687    /// read path for a renderer), indexed so `nodes[i].id == NodeId(i)`. Walk it
688    /// via the `parent`/`first_child`/`next_sibling` links; the root is the node
689    /// whose `parent` is `None`.
690    pub fn nodes(&mut self) -> Result<Vec<FlatNode>, Error> {
691        let mut ptr: *const ffi::TwigFlatNode = std::ptr::null();
692        let mut len = 0usize;
693        let status = unsafe { ffi::twig_editor_nodes(self.raw.as_ptr(), &mut ptr, &mut len) };
694        Error::from_status(status)?;
695        if len == 0 {
696            return Ok(Vec::new());
697        }
698        if ptr.is_null() {
699            return Err(Error::Internal);
700        }
701        let raw = unsafe { std::slice::from_raw_parts(ptr, len) };
702        raw.iter().map(flat_node_from_ffi).collect()
703    }
704
705    /// The direct children of `node` as [`QueryMatch`]es (id, span, kind) —
706    /// `None` enumerates the document root's children (the top-level blocks). The
707    /// cheap top-level enumeration an incremental renderer walks to decide which
708    /// blocks changed, without marshalling the whole arena; pair it with
709    /// [`Editor::subtree`] to then re-marshal only those that did. A childless
710    /// node yields an empty vec.
711    pub fn child_spans(&mut self, node: Option<NodeId>) -> Result<Vec<QueryMatch>, Error> {
712        let id = node.map_or(ffi::TWIG_NO_NODE, |n| n.0);
713        let mut ptr: *const ffi::TwigQueryMatch = std::ptr::null();
714        let mut len = 0usize;
715        let status =
716            unsafe { ffi::twig_editor_child_spans(self.raw.as_ptr(), id, &mut ptr, &mut len) };
717        Error::from_status(status)?;
718        if len == 0 || ptr.is_null() {
719            return Ok(Vec::new());
720        }
721        let raw = unsafe { std::slice::from_raw_parts(ptr, len) };
722        raw.iter().map(query_match_from_ffi).collect()
723    }
724
725    /// Snapshot the subtree rooted at `node` as a self-contained [`FlatNode`]
726    /// array with *local* ids: `array[0]` is the root, every link is an index
727    /// into the returned vec (or `None`), and spans stay absolute. The
728    /// incremental-render companion to [`Editor::nodes`] — re-marshal one edited
729    /// block's subtree instead of the whole document. The root's `parent` and
730    /// `next_sibling` are `None`, so a walk from index 0 stays inside the
731    /// subtree. [`Error::InvalidArgument`] if `node` is out of range.
732    pub fn subtree(&mut self, node: NodeId) -> Result<Vec<FlatNode>, Error> {
733        let mut ptr: *const ffi::TwigFlatNode = std::ptr::null();
734        let mut len = 0usize;
735        let status =
736            unsafe { ffi::twig_editor_subtree(self.raw.as_ptr(), node.0, &mut ptr, &mut len) };
737        Error::from_status(status)?;
738        if len == 0 || ptr.is_null() {
739            return Ok(Vec::new());
740        }
741        let raw = unsafe { std::slice::from_raw_parts(ptr, len) };
742        raw.iter().map(flat_node_from_ffi).collect()
743    }
744
745    /// The deepest node whose span contains byte `offset` (with `offset` equal
746    /// to the source length treated as inside the root) — mouse hit-testing and
747    /// cursor context. `Ok(None)` if no node covers the offset;
748    /// [`Error::InvalidArgument`] if `offset` exceeds the source length.
749    pub fn node_at(&mut self, offset: usize) -> Result<Option<QueryMatch>, Error> {
750        let mut m = ffi::TwigQueryMatch {
751            node_id: 0,
752            span: ffi::TwigSpan { start: 0, end: 0 },
753            content_span: ffi::TwigSpan { start: 0, end: 0 },
754            has_content_span: 0,
755            kind: std::ptr::null(),
756        };
757        let status = unsafe { ffi::twig_editor_node_at(self.raw.as_ptr(), offset, &mut m) };
758        match status.0 {
759            ffi::TwigStatus::OK => Ok(Some(query_match_from_ffi(&m)?)),
760            ffi::TwigStatus::NOT_FOUND => Ok(None),
761            _ => Err(Error::from_status(status).unwrap_err()),
762        }
763    }
764
765    /// The chain of nodes containing byte `offset`, root-first down to the
766    /// deepest (the node [`Editor::node_at`] returns) — the ancestor path for a
767    /// breadcrumb or context-scoped edit. Empty if no node covers the offset.
768    pub fn ancestors_at(&mut self, offset: usize) -> Result<Vec<QueryMatch>, Error> {
769        let mut ptr: *const ffi::TwigQueryMatch = std::ptr::null();
770        let mut len = 0usize;
771        let status =
772            unsafe { ffi::twig_editor_nodes_at(self.raw.as_ptr(), offset, &mut ptr, &mut len) };
773        match status.0 {
774            ffi::TwigStatus::OK => {}
775            ffi::TwigStatus::NOT_FOUND => return Ok(Vec::new()),
776            _ => return Err(Error::from_status(status).unwrap_err()),
777        }
778        if len == 0 || ptr.is_null() {
779            return Ok(Vec::new());
780        }
781        let raw = unsafe { std::slice::from_raw_parts(ptr, len) };
782        raw.iter().map(query_match_from_ffi).collect()
783    }
784
785    // ── range-oriented rich-text ops (the toolbar) ──────────────────────────
786
787    /// Wrap `[start, end)` with `kind`'s delimiters — the unconditional half of
788    /// the inline toolbar (always adds a mark; `*word*` → `**word**` stacks).
789    /// [`Error::UnsupportedFormat`] if the document's format can't spell `kind`
790    /// (e.g. a Markdown [`InlineKind::Mark`]); [`Error::InvalidArgument`] for a
791    /// bad range; [`Error::EditConflict`] if the result doesn't reparse.
792    pub fn wrap_range(&mut self, start: usize, end: usize, kind: InlineKind) -> Result<Change, Error> {
793        self.change_op(|ed, out| unsafe {
794            ffi::twig_editor_wrap_range(ed, start, end, kind.to_c(), out)
795        })
796    }
797
798    /// Toggle `kind` over `[start, end)`: remove the mark if the range already
799    /// *is* a node of `kind` (its whole span or its rendered interior), else
800    /// wrap it — a rich editor's Cmd-B. Same error rules as
801    /// [`Editor::wrap_range`].
802    pub fn toggle_inline(&mut self, start: usize, end: usize, kind: InlineKind) -> Result<Change, Error> {
803        self.change_op(|ed, out| unsafe {
804            ffi::twig_editor_toggle_inline(ed, start, end, kind.to_c(), out)
805        })
806    }
807
808    /// Convert the innermost heading/paragraph covering byte `offset` to `kind`,
809    /// rewriting its leading marker while keeping its inline content (the
810    /// toolbar's H1…H6 / Body switch). Djot and Markdown only, else
811    /// [`Error::UnsupportedFormat`]; [`Error::NotFound`] if no heading/paragraph
812    /// covers `offset`; [`Error::InvalidArgument`] for a heading level outside
813    /// 1–6.
814    pub fn set_block(&mut self, offset: usize, kind: BlockKind) -> Result<Change, Error> {
815        let (block_kind, level) = kind.to_c();
816        self.change_op(|ed, out| unsafe {
817            ffi::twig_editor_set_block(ed, offset, block_kind, level, out)
818        })
819    }
820
821    /// Toggle a block container over the blocks `[start, end)` covers — the
822    /// toolbar's Quote / Bulleted list / Numbered list buttons. Djot and Markdown
823    /// only, else [`Error::UnsupportedFormat`]; [`Error::NotFound`] if the range
824    /// covers no block; [`Error::InvalidArgument`] for a bad range.
825    ///
826    /// The range widens to whole lines of the blocks it touches (you cannot quote
827    /// half a paragraph), and the prefix lands at column 0, so a container wraps
828    /// the outermost structure on those lines.
829    ///
830    /// Whether this adds or removes is decided from the **AST** — the ancestors
831    /// of `start` — not by looking for a `>` in the source. It removes the
832    /// container only when the range covers every block that container holds, and
833    /// then only one level (`> > a` → `> a`). A partly covered container **nests**
834    /// instead, since removing it would drag its uncovered siblings out with it:
835    /// selecting the first paragraph of `> a\n>\n> b\n` gives `> > a\n>\n> b\n`.
836    /// Toggling one list kind while inside the other **converts** in place
837    /// (`- a` → `1. a`) rather than nesting.
838    ///
839    /// Each covered block becomes one item, so an ordered list numbers a
840    /// multi-block range `1.`, `2.`, `3.`… Removing a list inserts a blank line
841    /// between items that lacked one, keeping them separate blocks (a tight
842    /// `- a\n- b\n` stripped bare would be a single two-line paragraph).
843    pub fn toggle_block_container(
844        &mut self,
845        start: usize,
846        end: usize,
847        kind: BlockContainerKind,
848    ) -> Result<Change, Error> {
849        self.change_op(|ed, out| unsafe {
850            ffi::twig_editor_toggle_block_container(ed, start, end, kind.to_c(), out)
851        })
852    }
853
854    /// Renumber the ordered list at byte `offset` so its markers run `1, 2, 3, …`,
855    /// each nesting level restarting at 1 — the numbering a caret editor keeps as
856    /// items are inserted, deleted, and nested, where a raw splice leaves the
857    /// source numbers stale (`1. 2. 2. 3.`). Djot and Markdown; the display of an
858    /// ordered list is renumbered by any CommonMark renderer regardless, so this
859    /// is source hygiene, not a render fix.
860    ///
861    /// [`Error::NotFound`] when `offset` is not inside an ordered list. When the
862    /// numbering is already sequential this is a no-op that still returns `Ok` —
863    /// the source is left byte-for-byte unchanged. The `Change` is not returned
864    /// because a no-op has none; re-read [`Editor::source_str`] for the result.
865    pub fn renumber_ordered_lists(&mut self, offset: usize) -> Result<(), Error> {
866        self.change_op(|ed, out| unsafe {
867            ffi::twig_editor_renumber_ordered_lists(ed, offset, out)
868        })?;
869        Ok(())
870    }
871
872    // ── Tables ───────────────────────────────────────────────────────────────
873    // Structural editing of the pipe table at a byte `offset`: the caret's cell
874    // is the anchor. The whole table is re-spelled and spliced in one edit, so a
875    // caller re-reads [`Editor::source_str`] and re-places its caret rather than
876    // leaning on the returned span. [`Error::NotFound`] when `offset` is not in a
877    // table; [`Error::NotEditable`] for a refused (degenerate) edit.
878
879    /// Insert an empty row below (`below`) or above the caret's row.
880    pub fn table_insert_row(&mut self, offset: usize, below: bool) -> Result<(), Error> {
881        self.table_edit(offset, ffi::TWIG_TABLE_INSERT_ROW, below as c_int)
882    }
883
884    /// Delete the caret's row. [`Error::NotEditable`] for the header row or the
885    /// last remaining body row.
886    pub fn table_delete_row(&mut self, offset: usize) -> Result<(), Error> {
887        self.table_edit(offset, ffi::TWIG_TABLE_DELETE_ROW, 0)
888    }
889
890    /// Insert an empty column right (`right`) or left of the caret's column.
891    pub fn table_insert_column(&mut self, offset: usize, right: bool) -> Result<(), Error> {
892        self.table_edit(offset, ffi::TWIG_TABLE_INSERT_COLUMN, right as c_int)
893    }
894
895    /// Delete the caret's column. [`Error::NotEditable`] when it is the only one.
896    pub fn table_delete_column(&mut self, offset: usize) -> Result<(), Error> {
897        self.table_edit(offset, ffi::TWIG_TABLE_DELETE_COLUMN, 0)
898    }
899
900    /// Set the caret's column to `alignment`.
901    pub fn table_set_alignment(&mut self, offset: usize, alignment: Alignment) -> Result<(), Error> {
902        self.table_edit(offset, ffi::TWIG_TABLE_SET_ALIGNMENT, alignment.to_c())
903    }
904
905    /// Move the caret's row one place down (`down`) or up, within the body rows.
906    pub fn table_move_row(&mut self, offset: usize, down: bool) -> Result<(), Error> {
907        self.table_edit(offset, ffi::TWIG_TABLE_MOVE_ROW, down as c_int)
908    }
909
910    /// Move the caret's column one place right (`right`) or left.
911    pub fn table_move_column(&mut self, offset: usize, right: bool) -> Result<(), Error> {
912        self.table_edit(offset, ffi::TWIG_TABLE_MOVE_COLUMN, right as c_int)
913    }
914
915    fn table_edit(&mut self, offset: usize, op: c_int, arg: c_int) -> Result<(), Error> {
916        self.change_op(|ed, out| unsafe {
917            ffi::twig_editor_table_edit(ed, offset, op, arg, out)
918        })?;
919        Ok(())
920    }
921
922    /// Link `[start, end)` to `destination` — `[text](destination)`. Djot and
923    /// Markdown only, else [`Error::UnsupportedFormat`];
924    /// [`Error::InvalidArgument`] for a bad range or a destination containing a
925    /// newline (neither format can carry one, and quietly rewriting the URL would
926    /// be worse than refusing).
927    ///
928    /// An existing link covering the range has its destination **replaced** and
929    /// its text kept, so re-linking fixes a URL instead of nesting
930    /// `[[t](a)](b)`; to unlink, use [`Editor::unwrap_node`].
931    ///
932    /// A **range inside an existing autolink** (`<https://x.dev>`) re-points it
933    /// the same way, but there is no text to keep — an autolink's text *is* its
934    /// destination — so the node is replaced whole, respelled canonically for the
935    /// new destination. This covers a caret and any selection the autolink
936    /// contains, including one covering it exactly: an autolink's URL is not
937    /// editable text, so no part of it can host a `[`, and "link half this URL"
938    /// has no spelling. A caret inside both an autolink and a link
939    /// (`[<https://x.dev>](d)`) re-points the link, whose text is separable from
940    /// its destination and so survives.
941    ///
942    /// A selection starting or ending strictly **inside** an autolink without
943    /// being contained by it — running from ordinary text into the middle of a
944    /// URL — is refused with [`Status::NotEditable`]: half of it is real text,
945    /// so there is nothing to re-point, and any splice would rewrite the URL.
946    /// A selection that *contains* an autolink whole is unaffected — it splices
947    /// at the edges and wraps as usual.
948    ///
949    /// A link with **no text** — an empty range, or re-pointing an existing
950    /// `[](old)` — is spelled canonically for the destination given, never as
951    /// `[](destination)`: a childless link has nothing to render, so consumers
952    /// fall back to showing the destination and a caret has nowhere to sit. A
953    /// destination the format can autolink (an absolute URL or an email, by that
954    /// format's own rules) yields `<destination>`; anything else yields
955    /// `[destination](destination)`, the destination doubling as the text so it
956    /// stays visible and editable. Which destinations autolink is not the
957    /// caller's to guess — `<foo>` is raw HTML in Markdown, a relative path goes
958    /// literal in both, and the formats disagree (`<mailto:a@b.dev>` is a url in
959    /// Markdown, an email in Djot), so each is asked its own parser.
960    ///
961    /// The destination is escaped for the format, so a `)` or a space in it
962    /// cannot break the markup — and the two formats genuinely differ: Markdown
963    /// ends a destination at the first space (`[t](a b)` is not a link at all) so
964    /// whitespace moves it into the `<…>` form, while Djot takes spaces literally
965    /// and would read `<a b>` as the URL itself.
966    pub fn insert_link(
967        &mut self,
968        start: usize,
969        end: usize,
970        destination: &str,
971    ) -> Result<Change, Error> {
972        self.change_op(|ed, out| unsafe {
973            ffi::twig_editor_insert_link(
974                ed,
975                start,
976                end,
977                destination.as_ptr(),
978                destination.len(),
979                out,
980            )
981        })
982    }
983
984    /// Insert `text` at `offset` as a literal run: every byte the format reads as
985    /// markup is backslash-escaped so the run reparses as exactly `text` — a typed
986    /// `*`, `#` or `` ` `` stays that character rather than opening emphasis, a
987    /// heading or a code span. This is the inverse of serialization (which writes
988    /// an already-parsed run verbatim): it is what a WYSIWYG surface calls so that
989    /// keyboard input can never mint markup, leaving formatting to explicit
990    /// commands.
991    ///
992    /// The escaping is positional and per-format, and neither is the caller's to
993    /// reproduce: inline specials (`*`, `` ` ``, `[`, `<`…) are escaped anywhere
994    /// on the line, while block markers (`#`, `>`, `-`…) are escaped only where
995    /// `offset` sits in its line's leading whitespace — so an inserted "5 - 3"
996    /// keeps its `-` but "- item" at column zero does not become a bullet. An
997    /// embedded newline in `text` re-enters that line-start zone.
998    ///
999    /// Two constructs a byte-alphabet cannot reach are left as typed: a GFM
1000    /// bare-URL autolink (`https://x.com`, with no delimiter to escape) and an
1001    /// ordered-list marker (`1.`, special only after a digit run). Returns
1002    /// [`Error::UnsupportedFormat`] for a parse-only format (XML, HTML) and
1003    /// [`Error::InvalidArgument`] when `offset` is past the source.
1004    pub fn insert_literal(&mut self, offset: usize, text: &str) -> Result<Change, Error> {
1005        self.change_op(|ed, out| unsafe {
1006            ffi::twig_editor_insert_literal(ed, offset, text.as_ptr(), text.len(), out)
1007        })
1008    }
1009
1010    /// Insert a hard line break *inside a table cell* at `offset`, spelled the
1011    /// format's way (`<br>` for Markdown). A table row is one source line, so the
1012    /// ordinary newline-based hard break can't appear there; the spliced `<br>`
1013    /// reparses as a semantic `hard_break` node — not opaque raw HTML — so the
1014    /// break reads back as structure. Like the other gestures it leans on the
1015    /// splice+reparse+rollback backstop: a break that would no longer parse as the
1016    /// same table yields [`Error::EditConflict`] and changes nothing.
1017    ///
1018    /// Returns [`Error::UnsupportedFormat`] for a format with no in-cell break
1019    /// spelling — djot (no idiomatic in-cell break), HTML and XML (parse-only);
1020    /// [`Error::NotFound`] when `offset` is not inside a table cell (only the
1021    /// in-cell gesture is spelled today); and [`Error::InvalidArgument`] when
1022    /// `offset` is past the source.
1023    pub fn insert_line_break(&mut self, offset: usize) -> Result<Change, Error> {
1024        self.change_op(|ed, out| unsafe { ffi::twig_editor_insert_line_break(ed, offset, out) })
1025    }
1026
1027    /// Shared plumbing for the change-returning ops: run `op` (which fills a
1028    /// `TwigChange` out-param) and wrap the result.
1029    fn change_op(
1030        &mut self,
1031        op: impl FnOnce(*mut ffi::TwigEditor, *mut ffi::TwigChange) -> ffi::TwigStatus,
1032    ) -> Result<Change, Error> {
1033        let mut change = ffi::TwigChange {
1034            old_span: ffi::TwigSpan { start: 0, end: 0 },
1035            new_span: ffi::TwigSpan { start: 0, end: 0 },
1036        };
1037        let status = op(self.raw.as_ptr(), &mut change);
1038        Error::from_status(status)?;
1039        Ok(Change::from_ffi(change))
1040    }
1041
1042    /// Shared plumbing for the `(locator, text)` edit ops.
1043    fn apply(
1044        &mut self,
1045        locator: &str,
1046        text: &str,
1047        op: impl FnOnce(*mut ffi::TwigEditor, *const u8, usize, *const u8, usize) -> ffi::TwigStatus,
1048    ) -> Result<(), Error> {
1049        let status = op(
1050            self.raw.as_ptr(),
1051            locator.as_ptr(),
1052            locator.len(),
1053            text.as_ptr(),
1054            text.len(),
1055        );
1056        Error::from_status(status)
1057    }
1058}
1059
1060impl Drop for Editor {
1061    fn drop(&mut self) {
1062        unsafe { ffi::twig_editor_destroy(self.raw.as_ptr()) }
1063    }
1064}
1065
1066/// Run `call` (which writes a borrowed `(ptr, len)` byte buffer) and copy the
1067/// result into an owned `Vec` — the buffer is only valid until the next
1068/// same-accessor call on the handle, so we copy before returning. Shared by
1069/// [`Document`] and [`Editor`].
1070fn collect_bytes(
1071    call: impl FnOnce(*mut *const u8, *mut usize) -> ffi::TwigStatus,
1072) -> Result<Vec<u8>, Error> {
1073    let mut ptr = std::ptr::null();
1074    let mut len = 0usize;
1075    let status = call(&mut ptr, &mut len);
1076    Error::from_status(status)?;
1077    if len == 0 {
1078        return Ok(Vec::new());
1079    }
1080    if ptr.is_null() {
1081        return Err(Error::Internal);
1082    }
1083    let bytes = unsafe { std::slice::from_raw_parts(ptr, len) };
1084    Ok(bytes.to_vec())
1085}
1086
1087/// Run `call` (which writes a borrowed `(ptr, len)` match array) and copy each
1088/// match into an owned [`QueryMatch`]. Shared by [`Document`] and [`Editor`].
1089fn collect_matches(
1090    call: impl FnOnce(*mut *const ffi::TwigQueryMatch, *mut usize) -> ffi::TwigStatus,
1091) -> Result<Vec<QueryMatch>, Error> {
1092    let mut ptr = std::ptr::null();
1093    let mut len = 0usize;
1094    let status = call(&mut ptr, &mut len);
1095    Error::from_status(status)?;
1096    if len == 0 {
1097        return Ok(Vec::new());
1098    }
1099    if ptr.is_null() {
1100        return Err(Error::Internal);
1101    }
1102    let matches = unsafe { std::slice::from_raw_parts(ptr, len) };
1103    matches.iter().map(query_match_from_ffi).collect()
1104}
1105
1106/// Copy a borrowed C ABI [`ffi::TwigQueryMatch`] into an owned [`QueryMatch`].
1107/// Shared by `collect_matches`, [`Editor::node_at`], and [`Editor::ancestors_at`].
1108fn query_match_from_ffi(m: &ffi::TwigQueryMatch) -> Result<QueryMatch, Error> {
1109    Ok(QueryMatch {
1110        node_id: m.node_id,
1111        span: m.span.start..m.span.end,
1112        content_span: if m.has_content_span != 0 {
1113            Some(m.content_span.start..m.content_span.end)
1114        } else {
1115            None
1116        },
1117        kind: borrowed_cstr(m.kind)?,
1118    })
1119}
1120
1121/// Copy a borrowed C ABI [`ffi::TwigFlatNode`] into an owned [`FlatNode`].
1122fn flat_node_from_ffi(n: &ffi::TwigFlatNode) -> Result<FlatNode, Error> {
1123    let node_id = |v: u32| if v == ffi::TWIG_NO_NODE { None } else { Some(NodeId(v)) };
1124    Ok(FlatNode {
1125        id: NodeId(n.id),
1126        parent: node_id(n.parent),
1127        first_child: node_id(n.first_child),
1128        next_sibling: node_id(n.next_sibling),
1129        span: n.span.start..n.span.end,
1130        content_span: if n.has_content_span != 0 {
1131            Some(n.content_span.start..n.content_span.end)
1132        } else {
1133            None
1134        },
1135        level: if n.level != 0 { Some(n.level) } else { None },
1136        kind: borrowed_cstr(n.kind)?,
1137        text: borrowed_bytes(n.text_ptr, n.text_len),
1138        destination: borrowed_bytes(n.destination_ptr, n.destination_len),
1139        head: match n.head {
1140            ffi::TWIG_HEAD_NONE => None,
1141            v => Some(v != 0),
1142        },
1143        alignment: Alignment::from_c(n.alignment),
1144        name: borrowed_bytes(n.name_ptr, n.name_len),
1145        attrs: borrowed_attrs(n.attrs_ptr, n.attrs_len),
1146    })
1147}
1148
1149/// Copy a borrowed `TwigKeyVal` array into owned `(key, value)` pairs, or an
1150/// empty vec for a NULL pointer (the node has no attributes). A bare attribute
1151/// (NULL `value`) maps to a `None` value, distinct from a present-but-empty one.
1152fn borrowed_attrs(ptr: *const ffi::TwigKeyVal, len: usize) -> Vec<(String, Option<String>)> {
1153    if ptr.is_null() || len == 0 {
1154        return Vec::new();
1155    }
1156    let kvs = unsafe { std::slice::from_raw_parts(ptr, len) };
1157    kvs.iter()
1158        .map(|kv| {
1159            let key = borrowed_bytes(kv.key, kv.key_len).unwrap_or_default();
1160            (key, borrowed_bytes(kv.value, kv.value_len))
1161        })
1162        .collect()
1163}
1164
1165/// Copy a NUL-terminated, library-owned C string into an owned `String`.
1166fn borrowed_cstr(ptr: *const c_char) -> Result<String, Error> {
1167    if ptr.is_null() {
1168        return Err(Error::Internal);
1169    }
1170    Ok(unsafe { std::ffi::CStr::from_ptr(ptr) }
1171        .to_str()
1172        .map_err(|_| Error::Internal)?
1173        .to_owned())
1174}
1175
1176/// Copy a borrowed `(ptr, len)` payload slice into an owned `String`, or `None`
1177/// for a NULL pointer (the kind carries no such payload). The bytes are a slice
1178/// of a UTF-8 document, so a lossy decode never actually substitutes.
1179fn borrowed_bytes(ptr: *const u8, len: usize) -> Option<String> {
1180    if ptr.is_null() {
1181        return None;
1182    }
1183    let bytes = unsafe { std::slice::from_raw_parts(ptr, len) };
1184    Some(String::from_utf8_lossy(bytes).into_owned())
1185}
1186
1187/// The id of a node added to a [`Builder`], returned by every `add*` method and
1188/// used to wire up the tree via [`Builder::set_children`] and to root a
1189/// render/serialize/query.
1190#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
1191pub struct NodeId(pub u32);
1192
1193/// The void-payload node kinds, addable via [`Builder::add`]. Kinds with a
1194/// payload have their own dedicated `add_*` method instead.
1195#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1196pub enum VoidKind {
1197    Doc,
1198    Para,
1199    ThematicBreak,
1200    Section,
1201    Div,
1202    BlockQuote,
1203    DefinitionList,
1204    Table,
1205    ListItem,
1206    DefinitionListItem,
1207    Term,
1208    Definition,
1209    Caption,
1210    SoftBreak,
1211    HardBreak,
1212    NonBreakingSpace,
1213    Emph,
1214    Strong,
1215    Span,
1216    Mark,
1217    Superscript,
1218    Subscript,
1219    Insert,
1220    Delete,
1221    DoubleQuoted,
1222    SingleQuoted,
1223}
1224
1225impl VoidKind {
1226    fn to_c(self) -> c_int {
1227        // Discriminants match `TwigNodeKind` in the C ABI.
1228        match self {
1229            VoidKind::Doc => 0,
1230            VoidKind::Para => 1,
1231            VoidKind::ThematicBreak => 3,
1232            VoidKind::Section => 4,
1233            VoidKind::Div => 5,
1234            VoidKind::BlockQuote => 9,
1235            VoidKind::DefinitionList => 13,
1236            VoidKind::Table => 14,
1237            VoidKind::ListItem => 15,
1238            VoidKind::DefinitionListItem => 17,
1239            VoidKind::Term => 18,
1240            VoidKind::Definition => 19,
1241            VoidKind::Caption => 22,
1242            VoidKind::SoftBreak => 26,
1243            VoidKind::HardBreak => 27,
1244            VoidKind::NonBreakingSpace => 28,
1245            VoidKind::Emph => 38,
1246            VoidKind::Strong => 39,
1247            VoidKind::Span => 42,
1248            VoidKind::Mark => 43,
1249            VoidKind::Superscript => 44,
1250            VoidKind::Subscript => 45,
1251            VoidKind::Insert => 46,
1252            VoidKind::Delete => 47,
1253            VoidKind::DoubleQuoted => 48,
1254            VoidKind::SingleQuoted => 49,
1255        }
1256    }
1257}
1258
1259/// The single-string-payload node kinds, addable via [`Builder::add_text`].
1260#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1261pub enum TextKind {
1262    Str,
1263    Symb,
1264    Verbatim,
1265    InlineMath,
1266    DisplayMath,
1267    Url,
1268    Email,
1269    FootnoteReference,
1270    Comment,
1271    Doctype,
1272    Cdata,
1273}
1274
1275impl TextKind {
1276    fn to_c(self) -> c_int {
1277        match self {
1278            TextKind::Str => 25,
1279            TextKind::Symb => 29,
1280            TextKind::Verbatim => 30,
1281            TextKind::InlineMath => 32,
1282            TextKind::DisplayMath => 33,
1283            TextKind::Url => 34,
1284            TextKind::Email => 35,
1285            TextKind::FootnoteReference => 36,
1286            TextKind::Comment => 52,
1287            TextKind::Doctype => 53,
1288            TextKind::Cdata => 55,
1289        }
1290    }
1291}
1292
1293/// Bullet marker style for [`Builder::add_bullet_list`].
1294#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1295pub enum BulletStyle {
1296    Dash,
1297    Plus,
1298    Star,
1299}
1300
1301impl BulletStyle {
1302    fn to_c(self) -> c_int {
1303        match self {
1304            BulletStyle::Dash => 0,
1305            BulletStyle::Plus => 1,
1306            BulletStyle::Star => 2,
1307        }
1308    }
1309}
1310
1311/// Numbering scheme for [`Builder::add_ordered_list`].
1312#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1313pub enum OrderedNumbering {
1314    Decimal,
1315    LowerAlpha,
1316    UpperAlpha,
1317    LowerRoman,
1318    UpperRoman,
1319}
1320
1321impl OrderedNumbering {
1322    fn to_c(self) -> c_int {
1323        match self {
1324            OrderedNumbering::Decimal => 0,
1325            OrderedNumbering::LowerAlpha => 1,
1326            OrderedNumbering::UpperAlpha => 2,
1327            OrderedNumbering::LowerRoman => 3,
1328            OrderedNumbering::UpperRoman => 4,
1329        }
1330    }
1331}
1332
1333/// Delimiter around an ordered-list number (`1.`, `1)`, `(1)`).
1334#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1335pub enum OrderedDelim {
1336    Period,
1337    ParenAfter,
1338    ParenBoth,
1339}
1340
1341impl OrderedDelim {
1342    fn to_c(self) -> c_int {
1343        match self {
1344            OrderedDelim::Period => 0,
1345            OrderedDelim::ParenAfter => 1,
1346            OrderedDelim::ParenBoth => 2,
1347        }
1348    }
1349}
1350
1351/// Table-cell alignment: written via [`Builder::add_cell`], read back on
1352/// [`FlatNode::alignment`].
1353#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1354pub enum Alignment {
1355    Default,
1356    Left,
1357    Right,
1358    Center,
1359}
1360
1361impl Alignment {
1362    fn to_c(self) -> c_int {
1363        match self {
1364            Alignment::Default => ffi::TWIG_ALIGN_DEFAULT,
1365            Alignment::Left => ffi::TWIG_ALIGN_LEFT,
1366            Alignment::Right => ffi::TWIG_ALIGN_RIGHT,
1367            Alignment::Center => ffi::TWIG_ALIGN_CENTER,
1368        }
1369    }
1370
1371    /// The inverse of [`Alignment::to_c`]; `None` for [`ffi::TWIG_ALIGN_NONE`]
1372    /// (the node isn't a cell) or any code this binding doesn't know.
1373    fn from_c(v: c_int) -> Option<Self> {
1374        match v {
1375            ffi::TWIG_ALIGN_DEFAULT => Some(Alignment::Default),
1376            ffi::TWIG_ALIGN_LEFT => Some(Alignment::Left),
1377            ffi::TWIG_ALIGN_RIGHT => Some(Alignment::Right),
1378            ffi::TWIG_ALIGN_CENTER => Some(Alignment::Center),
1379            _ => None,
1380        }
1381    }
1382}
1383
1384/// The smart-punctuation kind for [`Builder::add_smart_punctuation`].
1385#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1386pub enum SmartPunctuation {
1387    LeftSingleQuote,
1388    RightSingleQuote,
1389    LeftDoubleQuote,
1390    RightDoubleQuote,
1391    Ellipses,
1392    EmDash,
1393    EnDash,
1394}
1395
1396impl SmartPunctuation {
1397    fn to_c(self) -> c_int {
1398        match self {
1399            SmartPunctuation::LeftSingleQuote => 0,
1400            SmartPunctuation::RightSingleQuote => 1,
1401            SmartPunctuation::LeftDoubleQuote => 2,
1402            SmartPunctuation::RightDoubleQuote => 3,
1403            SmartPunctuation::Ellipses => 4,
1404            SmartPunctuation::EmDash => 5,
1405            SmartPunctuation::EnDash => 6,
1406        }
1407    }
1408}
1409
1410/// The surface form of a generic directive for [`Builder::add_directive`].
1411#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1412pub enum DirectiveForm {
1413    Text,
1414    Leaf,
1415    Container,
1416}
1417
1418impl DirectiveForm {
1419    fn to_c(self) -> c_int {
1420        match self {
1421            DirectiveForm::Text => 0,
1422            DirectiveForm::Leaf => 1,
1423            DirectiveForm::Container => 2,
1424        }
1425    }
1426}
1427
1428/// Decompose an optional string into `(ptr, len, has)` for the C ABI's
1429/// `(ptr, len, has_*)` optional-string triples. The pointer borrows `s` and is
1430/// only used within the same call.
1431fn opt_str(s: Option<&str>) -> (*const u8, usize, c_int) {
1432    match s {
1433        Some(x) => (x.as_ptr(), x.len(), 1),
1434        None => (std::ptr::null(), 0, 0),
1435    }
1436}
1437
1438/// Programmatic construction of a document — the write-path mirror of
1439/// [`Document::parse`]. Build the tree bottom-up (add children, then the
1440/// container, wiring them with [`Builder::set_children`]); every `add*` method
1441/// returns the new node's [`NodeId`]. Then render, serialize, query, or dump the
1442/// subtree rooted at any id, on demand, without consuming the builder. All input
1443/// strings are copied, so caller buffers need not outlive a call.
1444#[derive(Debug)]
1445pub struct Builder {
1446    raw: NonNull<ffi::TwigBuilder>,
1447}
1448
1449impl Builder {
1450    /// Create an empty builder.
1451    pub fn new() -> Result<Self, Error> {
1452        let mut raw = std::ptr::null_mut();
1453        let status = unsafe { ffi::twig_builder_create(&mut raw) };
1454        Error::from_status(status)?;
1455        let raw = NonNull::new(raw).ok_or(Error::Internal)?;
1456        Ok(Self { raw })
1457    }
1458
1459    /// Add a void-payload node (attach children later with
1460    /// [`Builder::set_children`]).
1461    pub fn add(&mut self, kind: VoidKind) -> Result<NodeId, Error> {
1462        self.emit(|b, out| unsafe { ffi::twig_builder_add(b, kind.to_c(), out) })
1463    }
1464
1465    /// Add a single-string-payload node (a `str`, code span, url, comment, …).
1466    pub fn add_text(&mut self, kind: TextKind, text: &str) -> Result<NodeId, Error> {
1467        self.emit(|b, out| unsafe { ffi::twig_builder_add_text(b, kind.to_c(), text.as_ptr(), text.len(), out) })
1468    }
1469
1470    /// Add a heading of the given level (attach its inline children afterward).
1471    pub fn add_heading(&mut self, level: u32) -> Result<NodeId, Error> {
1472        self.emit(|b, out| unsafe { ffi::twig_builder_add_heading(b, level, out) })
1473    }
1474
1475    /// Add a code block, with an optional info-string language.
1476    pub fn add_code_block(&mut self, lang: Option<&str>, text: &str) -> Result<NodeId, Error> {
1477        let (lp, ll, has) = opt_str(lang);
1478        self.emit(|b, out| unsafe { ffi::twig_builder_add_code_block(b, lp, ll, has, text.as_ptr(), text.len(), out) })
1479    }
1480
1481    /// Add a raw block targeting `format` (e.g. `"html"`).
1482    pub fn add_raw_block(&mut self, format: &str, text: &str) -> Result<NodeId, Error> {
1483        self.emit(|b, out| unsafe {
1484            ffi::twig_builder_add_raw_block(b, format.as_ptr(), format.len(), text.as_ptr(), text.len(), out)
1485        })
1486    }
1487
1488    /// Add a document-metadata block written in config language `lang`.
1489    pub fn add_metadata(&mut self, lang: &str, text: &str) -> Result<NodeId, Error> {
1490        self.emit(|b, out| unsafe {
1491            ffi::twig_builder_add_metadata(b, lang.as_ptr(), lang.len(), text.as_ptr(), text.len(), out)
1492        })
1493    }
1494
1495    /// Add a raw inline targeting `format`.
1496    pub fn add_raw_inline(&mut self, format: &str, text: &str) -> Result<NodeId, Error> {
1497        self.emit(|b, out| unsafe {
1498            ffi::twig_builder_add_raw_inline(b, format.as_ptr(), format.len(), text.as_ptr(), text.len(), out)
1499        })
1500    }
1501
1502    /// Add a smart-punctuation node standing for `text` (its source spelling).
1503    pub fn add_smart_punctuation(&mut self, kind: SmartPunctuation, text: &str) -> Result<NodeId, Error> {
1504        self.emit(|b, out| unsafe {
1505            ffi::twig_builder_add_smart_punctuation(b, kind.to_c(), text.as_ptr(), text.len(), out)
1506        })
1507    }
1508
1509    /// Add a link with an optional destination and/or reference label (attach
1510    /// the link text as children).
1511    pub fn add_link(&mut self, destination: Option<&str>, reference: Option<&str>) -> Result<NodeId, Error> {
1512        let (dp, dl, hd) = opt_str(destination);
1513        let (rp, rl, hr) = opt_str(reference);
1514        self.emit(|b, out| unsafe { ffi::twig_builder_add_link(b, dp, dl, hd, rp, rl, hr, out) })
1515    }
1516
1517    /// Add an image — like [`Builder::add_link`], but children are the alt text.
1518    pub fn add_image(&mut self, destination: Option<&str>, reference: Option<&str>) -> Result<NodeId, Error> {
1519        let (dp, dl, hd) = opt_str(destination);
1520        let (rp, rl, hr) = opt_str(reference);
1521        self.emit(|b, out| unsafe { ffi::twig_builder_add_image(b, dp, dl, hd, rp, rl, hr, out) })
1522    }
1523
1524    /// Add a generic directive of the given form and name.
1525    pub fn add_directive(&mut self, form: DirectiveForm, name: &str) -> Result<NodeId, Error> {
1526        self.emit(|b, out| unsafe { ffi::twig_builder_add_directive(b, form.to_c(), name.as_ptr(), name.len(), out) })
1527    }
1528
1529    /// Add a generic named element (the escape hatch for HTML/XML tags).
1530    pub fn add_element(&mut self, name: &str) -> Result<NodeId, Error> {
1531        self.emit(|b, out| unsafe { ffi::twig_builder_add_element(b, name.as_ptr(), name.len(), out) })
1532    }
1533
1534    /// Add an XML processing instruction (`<?target data?>`).
1535    pub fn add_processing_instruction(&mut self, target: &str, data: &str) -> Result<NodeId, Error> {
1536        self.emit(|b, out| unsafe {
1537            ffi::twig_builder_add_processing_instruction(b, target.as_ptr(), target.len(), data.as_ptr(), data.len(), out)
1538        })
1539    }
1540
1541    /// Add a footnote definition with the given label.
1542    pub fn add_footnote(&mut self, label: &str) -> Result<NodeId, Error> {
1543        self.emit(|b, out| unsafe { ffi::twig_builder_add_footnote(b, label.as_ptr(), label.len(), out) })
1544    }
1545
1546    /// Add a link/image reference definition (`label` → `destination`).
1547    pub fn add_reference(&mut self, label: &str, destination: &str) -> Result<NodeId, Error> {
1548        self.emit(|b, out| unsafe {
1549            ffi::twig_builder_add_reference(b, label.as_ptr(), label.len(), destination.as_ptr(), destination.len(), out)
1550        })
1551    }
1552
1553    /// Add a bullet list.
1554    pub fn add_bullet_list(&mut self, style: BulletStyle, tight: bool) -> Result<NodeId, Error> {
1555        self.emit(|b, out| unsafe { ffi::twig_builder_add_bullet_list(b, style.to_c(), tight as c_int, out) })
1556    }
1557
1558    /// Add an ordered list, with an optional explicit start number.
1559    pub fn add_ordered_list(
1560        &mut self,
1561        numbering: OrderedNumbering,
1562        delim: OrderedDelim,
1563        tight: bool,
1564        start: Option<u32>,
1565    ) -> Result<NodeId, Error> {
1566        let (start_val, has_start) = match start {
1567            Some(s) => (s, 1),
1568            None => (0, 0),
1569        };
1570        self.emit(|b, out| unsafe {
1571            ffi::twig_builder_add_ordered_list(b, numbering.to_c(), delim.to_c(), tight as c_int, start_val, has_start, out)
1572        })
1573    }
1574
1575    /// Add a task list.
1576    pub fn add_task_list(&mut self, tight: bool) -> Result<NodeId, Error> {
1577        self.emit(|b, out| unsafe { ffi::twig_builder_add_task_list(b, tight as c_int, out) })
1578    }
1579
1580    /// Add a task-list item with the given checkbox state.
1581    pub fn add_task_list_item(&mut self, checked: bool) -> Result<NodeId, Error> {
1582        self.emit(|b, out| unsafe { ffi::twig_builder_add_task_list_item(b, checked as c_int, out) })
1583    }
1584
1585    /// Add a table row (`head` marks a header row).
1586    pub fn add_row(&mut self, head: bool) -> Result<NodeId, Error> {
1587        self.emit(|b, out| unsafe { ffi::twig_builder_add_row(b, head as c_int, out) })
1588    }
1589
1590    /// Add a table cell (`head` marks a header cell).
1591    pub fn add_cell(&mut self, head: bool, alignment: Alignment) -> Result<NodeId, Error> {
1592        self.emit(|b, out| unsafe { ffi::twig_builder_add_cell(b, head as c_int, alignment.to_c(), out) })
1593    }
1594
1595    /// Set `parent`'s children to `children` (in order), replacing any it had.
1596    /// Each child id should appear in exactly one `set_children` call.
1597    pub fn set_children(&mut self, parent: NodeId, children: &[NodeId]) -> Result<(), Error> {
1598        let ids: Vec<u32> = children.iter().map(|n| n.0).collect();
1599        let status = unsafe { ffi::twig_builder_set_children(self.raw.as_ptr(), parent.0, ids.as_ptr(), ids.len()) };
1600        Error::from_status(status)
1601    }
1602
1603    /// Attach `{...}` attributes to `id` (`(key, Some(value))`, or
1604    /// `(key, None)` for a bare attribute), replacing any it had. An empty slice
1605    /// clears them.
1606    pub fn set_attrs(&mut self, id: NodeId, attrs: &[(&str, Option<&str>)]) -> Result<(), Error> {
1607        let kvs: Vec<ffi::TwigKeyVal> = attrs
1608            .iter()
1609            .map(|(k, v)| ffi::TwigKeyVal {
1610                key: k.as_ptr(),
1611                key_len: k.len(),
1612                value: v.map_or(std::ptr::null(), |s| s.as_ptr()),
1613                value_len: v.map_or(0, |s| s.len()),
1614            })
1615            .collect();
1616        let status = unsafe { ffi::twig_builder_set_attrs(self.raw.as_ptr(), id.0, kvs.as_ptr(), kvs.len()) };
1617        Error::from_status(status)
1618    }
1619
1620    /// Render the subtree rooted at `root` to HTML (generic whole-vocabulary
1621    /// printer — a built tree has no djot/Markdown side tables).
1622    pub fn render_html(&mut self, root: NodeId) -> Result<Vec<u8>, Error> {
1623        let raw = self.raw.as_ptr();
1624        collect_bytes(|ptr, len| unsafe { ffi::twig_builder_render_html(raw, root.0, ptr, len) })
1625    }
1626
1627    /// Serialize the subtree rooted at `root` to `format`'s source syntax.
1628    /// Returns [`Error::UnsupportedFormat`] when the target can't represent the
1629    /// built tree (e.g. semantic kinds into XML).
1630    pub fn serialize(&mut self, root: NodeId, format: Format) -> Result<Vec<u8>, Error> {
1631        let raw = self.raw.as_ptr();
1632        let ffi_format: ffi::TwigFormat = format.into();
1633        collect_bytes(|ptr, len| unsafe { ffi::twig_builder_serialize(raw, root.0, ffi_format as i32, ptr, len) })
1634    }
1635
1636    /// Encode the subtree rooted at `root` as pretty-printed JSON.
1637    pub fn ast_json(&mut self, root: NodeId) -> Result<Vec<u8>, Error> {
1638        let raw = self.raw.as_ptr();
1639        collect_bytes(|ptr, len| unsafe { ffi::twig_builder_ast_json(raw, root.0, ptr, len) })
1640    }
1641
1642    /// Resolve a selector against the subtree rooted at `root` (same grammar as
1643    /// [`Document::query`]).
1644    pub fn query(&mut self, root: NodeId, selector: &str) -> Result<Vec<QueryMatch>, Error> {
1645        let raw = self.raw.as_ptr();
1646        collect_matches(|ptr, len| unsafe {
1647            ffi::twig_builder_query(raw, root.0, selector.as_ptr(), selector.len(), ptr, len)
1648        })
1649    }
1650
1651    /// Shared plumbing for the `add*` constructors: run `call` (which writes the
1652    /// new node's id) and wrap the result.
1653    fn emit(
1654        &mut self,
1655        call: impl FnOnce(*mut ffi::TwigBuilder, *mut u32) -> ffi::TwigStatus,
1656    ) -> Result<NodeId, Error> {
1657        let mut id: u32 = 0;
1658        let status = call(self.raw.as_ptr(), &mut id);
1659        Error::from_status(status)?;
1660        Ok(NodeId(id))
1661    }
1662}
1663
1664impl Drop for Builder {
1665    fn drop(&mut self) {
1666        unsafe { ffi::twig_builder_destroy(self.raw.as_ptr()) }
1667    }
1668}
1669
1670#[cfg(test)]
1671mod tests {
1672    use super::*;
1673
1674    #[test]
1675    fn abi_version_matches() {
1676        // The linked library must speak the exact ABI layout this crate's
1677        // `#[repr(C)]` mirrors assume. If this fails, the Zig `TWIG_ABI_VERSION`
1678        // was bumped without updating `ffi::TWIG_ABI_VERSION` (and the mirrors).
1679        assert_eq!(abi_version(), ffi::TWIG_ABI_VERSION);
1680    }
1681
1682    #[test]
1683    fn parses_and_renders_markdown_html() {
1684        let mut doc = Document::parse_str("# hi\n", Format::Markdown).expect("parse markdown");
1685        let html = doc.render_html().expect("render html");
1686        assert_eq!(String::from_utf8_lossy(&html), "<h1>hi</h1>\n");
1687    }
1688
1689    #[test]
1690    fn parses_html_input() {
1691        let mut doc = Document::parse_str("<p>hi</p>", Format::Html).expect("parse html");
1692        let html = doc.render_html().expect("render html");
1693        assert!(String::from_utf8_lossy(&html).contains("hi"));
1694    }
1695
1696    #[test]
1697    fn serialize_round_trips_and_cross_converts() {
1698        let mut doc = Document::parse_str("# hi\n", Format::Markdown).expect("parse markdown");
1699
1700        let canonical = doc.serialize(Format::Markdown).expect("serialize markdown");
1701        assert!(String::from_utf8_lossy(&canonical).contains("# hi"));
1702
1703        // Cross-format Markdown -> XML has no serializer.
1704        assert_eq!(doc.serialize(Format::Xml), Err(Error::UnsupportedFormat));
1705    }
1706
1707    #[test]
1708    fn serialize_markdown_to_djot() {
1709        let mut doc =
1710            Document::parse_str("This is *markdown*.\n", Format::Markdown).expect("parse markdown");
1711        let djot = doc.serialize(Format::Djot).expect("serialize djot");
1712        assert!(String::from_utf8_lossy(&djot).contains("_markdown_"));
1713    }
1714
1715    #[test]
1716    fn ast_json_dumps_the_tree() {
1717        let mut doc = Document::parse_str("hello\n", Format::Djot).expect("parse djot");
1718        let json = doc.ast_json().expect("ast json");
1719        assert!(String::from_utf8_lossy(&json).contains("\"kind\": \"doc\""));
1720    }
1721
1722    #[test]
1723    fn query_finds_nodes_by_selector() {
1724        let source = "# One\n\n## Two\n";
1725        let mut doc = Document::parse_str(source, Format::Markdown).expect("parse markdown");
1726        let matches = doc.query("heading").expect("query");
1727
1728        assert_eq!(matches.len(), 2);
1729        for m in &matches {
1730            assert_eq!(m.kind, "heading");
1731            assert!(m.span.start < m.span.end);
1732        }
1733    }
1734
1735    #[test]
1736    fn query_recovers_code_spans() {
1737        let source = "prose `code` more prose\n";
1738        let mut doc = Document::parse_str(source, Format::Markdown).expect("parse markdown");
1739        let matches = doc.query("verbatim").expect("query");
1740
1741        assert_eq!(matches.len(), 1);
1742        assert_eq!(&source[matches[0].span.clone()], "`code`");
1743    }
1744
1745    #[test]
1746    fn query_rejects_a_malformed_selector() {
1747        let mut doc = Document::parse_str("hi\n", Format::Markdown).expect("parse markdown");
1748        assert_eq!(doc.query("list >"), Err(Error::InvalidArgument));
1749    }
1750
1751    #[test]
1752    fn editor_edits_by_index_path() {
1753        let mut ed = Editor::new_str("<a><b>hi</b></a>", Format::Xml).expect("editor");
1754        ed.replace_content("0.0", "bye").expect("replace_content");
1755        assert_eq!(ed.source_str().expect("source"), "<a><b>bye</b></a>");
1756    }
1757
1758    #[test]
1759    fn flat_nodes_expose_element_name_and_attrs() {
1760        // A `<picture>` with a theme-switching `<source>`: the dark alternative
1761        // lives only in the `<source>`'s attributes, which the snapshot now
1762        // surfaces (both `<picture>` and `<source>` report `kind == "element"`).
1763        let src = "<picture><source media=\"(prefers-color-scheme: dark)\" srcset=\"d.svg\"><img src=\"l.svg\" alt=\"x\"></picture>\n";
1764        let mut ed = Editor::new_ext(
1765            src.as_bytes(),
1766            Format::Markdown,
1767            MarkdownExtensions { html_elements: true, ..Default::default() },
1768        )
1769        .expect("editor");
1770        let nodes = ed.nodes().expect("nodes");
1771
1772        let source = nodes
1773            .iter()
1774            .find(|n| n.name.as_deref() == Some("source"))
1775            .expect("a <source> element node");
1776        assert_eq!(
1777            source.attrs,
1778            vec![
1779                ("media".to_string(), Some("(prefers-color-scheme: dark)".to_string())),
1780                ("srcset".to_string(), Some("d.svg".to_string())),
1781            ]
1782        );
1783
1784        // The `<img>` fallback stays an `image` node (no element name), and its
1785        // `src` is the ordinary `destination`.
1786        let img = nodes.iter().find(|n| n.kind == "image").expect("an image node");
1787        assert!(img.name.is_none());
1788        assert_eq!(img.destination.as_deref(), Some("l.svg"));
1789
1790        // A semantic node carries neither an element name nor attributes.
1791        let picture_kids_str = nodes.iter().find(|n| n.kind == "str");
1792        if let Some(s) = picture_kids_str {
1793            assert!(s.name.is_none() && s.attrs.is_empty());
1794        }
1795    }
1796
1797    #[test]
1798    fn editor_insert_child_and_delete() {
1799        let mut ed = Editor::new_str("<r><a/><c/></r>", Format::Xml).expect("editor");
1800        ed.insert_child("0", 1, "<b/>").expect("insert_child");
1801        assert_eq!(ed.source_str().expect("source"), "<r><a/><b/><c/></r>");
1802        ed.delete("0.1").expect("delete");
1803        assert_eq!(ed.source_str().expect("source"), "<r><a/><c/></r>");
1804    }
1805
1806    #[test]
1807    fn editor_edits_by_selector() {
1808        let mut ed = Editor::new_str("# One\n\n## Two\n", Format::Markdown).expect("editor");
1809        ed.replace("heading(\"Two\")", "## Renamed").expect("replace");
1810        assert_eq!(ed.source_str().expect("source"), "# One\n\n## Renamed\n");
1811    }
1812
1813    #[test]
1814    fn editor_locator_errors_are_distinct() {
1815        let mut ed = Editor::new_str("<r><a/><a/></r>", Format::Xml).expect("editor");
1816        assert_eq!(ed.replace("0.9", "x"), Err(Error::NotFound));
1817        assert_eq!(ed.replace("element", "x"), Err(Error::Ambiguous));
1818        assert_eq!(ed.replace("element(", "x"), Err(Error::InvalidArgument));
1819        // Untouched by the failed edits.
1820        assert_eq!(ed.source_str().expect("source"), "<r><a/><a/></r>");
1821    }
1822
1823    #[test]
1824    fn editor_reparse_break_rolls_back() {
1825        let mut ed = Editor::new_str("<a>ok</a>", Format::Xml).expect("editor");
1826        assert_eq!(ed.replace_content("0", "<b>"), Err(Error::EditConflict));
1827        assert_eq!(ed.source_str().expect("source"), "<a>ok</a>");
1828    }
1829
1830    #[test]
1831    fn editor_leaf_content_is_not_editable() {
1832        let mut ed = Editor::new_str("<a>hi</a>", Format::Xml).expect("editor");
1833        assert_eq!(ed.replace_content("0.0", "x"), Err(Error::NotEditable));
1834    }
1835
1836    #[test]
1837    fn editor_query_reflects_current_tree() {
1838        let mut ed = Editor::new_str("<r><a/></r>", Format::Xml).expect("editor");
1839        ed.insert_child("0", 1, "<b/>").expect("insert_child");
1840        // Root <r> plus <a/> and <b/>.
1841        assert_eq!(ed.query("element").expect("query").len(), 3);
1842        let json = ed.ast_json().expect("ast_json");
1843        assert!(String::from_utf8_lossy(&json).contains("\"kind\": \"doc\""));
1844    }
1845
1846    // ── offset-addressed editing & read-back (P0–P3) ────────────────────────
1847
1848    #[test]
1849    fn editor_edit_range_types_backspaces_and_reports_change() {
1850        let mut ed = Editor::new_str("ab\n", Format::Markdown).expect("editor");
1851
1852        // Type "X" at offset 1 (a zero-width splice = an insertion).
1853        let c = ed.edit_range(1, 1, "X").expect("edit_range insert");
1854        assert_eq!(ed.source_str().unwrap(), "aXb\n");
1855        assert_eq!(c.old, 1..1);
1856        assert_eq!(c.new, 1..2);
1857        assert_eq!(c.delta(), 1);
1858
1859        // Backspace it (delete the "X").
1860        let c2 = ed.edit_range(1, 2, "").expect("edit_range delete");
1861        assert_eq!(ed.source_str().unwrap(), "ab\n");
1862        assert_eq!(c2.old, 1..2);
1863        assert_eq!(c2.new, 1..1);
1864        assert_eq!(c2.delta(), -1);
1865    }
1866
1867    #[test]
1868    fn editor_edit_range_rejects_bad_ranges() {
1869        let mut ed = Editor::new_str("hi\n", Format::Markdown).expect("editor");
1870        assert_eq!(ed.edit_range(0, 99, "x"), Err(Error::InvalidArgument)); // end past len
1871        assert_eq!(ed.edit_range(2, 1, "x"), Err(Error::InvalidArgument)); // start > end
1872        assert_eq!(ed.source_str().unwrap(), "hi\n"); // untouched
1873    }
1874
1875    #[test]
1876    fn editor_last_change_reports_locator_ops_too() {
1877        let mut ed = Editor::new_str("# One\n\n## Two\n", Format::Markdown).expect("editor");
1878        assert_eq!(ed.last_change(), None); // nothing edited yet
1879
1880        ed.replace("heading(\"Two\")", "## Renamed").expect("replace");
1881        assert_eq!(ed.source_str().unwrap(), "# One\n\n## Renamed\n");
1882        let c = ed.last_change().expect("a change was recorded");
1883        // "## Two" occupied [7,13); "## Renamed" (10 bytes) now occupies [7,17).
1884        assert_eq!(c.old, 7..13);
1885        assert_eq!(c.new, 7..17);
1886    }
1887
1888    #[test]
1889    fn editor_nodes_is_a_walkable_flat_tree() {
1890        let mut ed = Editor::new_str("# Hi\n\ntext\n", Format::Markdown).expect("editor");
1891        let nodes = ed.nodes().expect("nodes");
1892        assert!(!nodes.is_empty());
1893
1894        // Dense, index-aligned ids.
1895        for (i, n) in nodes.iter().enumerate() {
1896            assert_eq!(n.id, NodeId(i as u32));
1897        }
1898        // Exactly one root (no parent), and it's the doc.
1899        let roots: Vec<_> = nodes.iter().filter(|n| n.parent.is_none()).collect();
1900        assert_eq!(roots.len(), 1);
1901        assert_eq!(roots[0].kind, "doc");
1902
1903        // The heading carries its level; the "Hi" text is reachable as a payload.
1904        let heading = nodes.iter().find(|n| n.kind == "heading").expect("a heading");
1905        assert_eq!(heading.level, Some(1));
1906        assert!(nodes.iter().any(|n| n.text.as_deref() == Some("Hi")));
1907
1908        // A kind with no row/cell payload reports neither.
1909        assert_eq!(heading.head, None);
1910        assert_eq!(heading.alignment, None);
1911
1912        // Every non-root node's parent links back to a node that lists it as a
1913        // child (via first_child/next_sibling).
1914        for n in nodes.iter().filter(|n| n.parent.is_some()) {
1915            let p = &nodes[n.parent.unwrap().0 as usize];
1916            let mut kid = p.first_child;
1917            let mut seen = false;
1918            while let Some(NodeId(k)) = kid {
1919                if k == n.id.0 {
1920                    seen = true;
1921                    break;
1922                }
1923                kid = nodes[k as usize].next_sibling;
1924            }
1925            assert!(seen, "node {:?} not found among its parent's children", n.id);
1926        }
1927    }
1928
1929    #[test]
1930    fn editor_child_spans_and_subtree_agree_with_nodes() {
1931        let src = "# Title\n\nHello **world** and more.\n\n- one\n- two\n";
1932        let mut ed = Editor::new_str(src, Format::Markdown).expect("editor");
1933        let all = ed.nodes().expect("nodes");
1934        let doc = all.iter().find(|n| n.kind == "doc").expect("doc");
1935
1936        // child_spans(None) == the doc root's children, same ids/kinds/spans and
1937        // in the same order.
1938        let top = ed.child_spans(None).expect("child_spans");
1939        let mut want = Vec::new();
1940        let mut c = doc.first_child;
1941        while let Some(id) = c {
1942            want.push(id);
1943            c = all[id.0 as usize].next_sibling;
1944        }
1945        assert_eq!(top.len(), want.len(), "top-level count");
1946        for (m, id) in top.iter().zip(&want) {
1947            assert_eq!(m.node_id, id.0, "child id");
1948            assert_eq!(m.kind, all[id.0 as usize].kind, "child kind");
1949            assert_eq!(m.span, all[id.0 as usize].span, "child span");
1950        }
1951        // The span addresses the block as written (absolute offsets).
1952        assert!(src[top[0].span.clone()].starts_with('#'), "first block is the heading");
1953
1954        // child_spans works below the top level too.
1955        let list = top.iter().find(|m| m.kind.ends_with("list")).expect("a list");
1956        let items = ed.child_spans(Some(NodeId(list.node_id))).expect("items");
1957        assert_eq!(items.len(), 2);
1958        assert!(items.iter().all(|m| m.kind == "list_item"), "items: {items:?}");
1959
1960        // subtree(para) is self-contained, local-indexed, and spans stay absolute.
1961        let para = top.iter().find(|m| m.kind == "para").expect("a para").node_id;
1962        let sub = ed.subtree(NodeId(para)).expect("subtree");
1963        assert_eq!(sub[0].id, NodeId(0), "root is local id 0");
1964        assert_eq!(sub[0].parent, None, "root has no parent inside the subtree");
1965        assert_eq!(sub[0].next_sibling, None, "root's sibling is severed");
1966        assert_eq!(sub[0].kind, "para");
1967        for (i, n) in sub.iter().enumerate() {
1968            assert_eq!(n.id, NodeId(i as u32), "dense local ids");
1969            for link in [n.parent, n.first_child, n.next_sibling].into_iter().flatten() {
1970                assert!((link.0 as usize) < sub.len(), "link {link:?} escapes the subtree");
1971            }
1972        }
1973        assert!(
1974            src[sub[0].span.clone()].starts_with("Hello"),
1975            "absolute span: {:?}",
1976            &src[sub[0].span.clone()]
1977        );
1978
1979        // Same multiset of node kinds as the paragraph's arena subtree.
1980        fn arena_kinds(all: &[FlatNode], root: NodeId) -> Vec<String> {
1981            let mut out = Vec::new();
1982            let mut stack = vec![root];
1983            while let Some(id) = stack.pop() {
1984                let n = &all[id.0 as usize];
1985                out.push(n.kind.clone());
1986                let mut c = n.first_child;
1987                while let Some(cid) = c {
1988                    stack.push(cid);
1989                    c = all[cid.0 as usize].next_sibling;
1990                }
1991            }
1992            out
1993        }
1994        let mut want_kinds = arena_kinds(&all, NodeId(para));
1995        let mut got_kinds: Vec<String> = sub.iter().map(|n| n.kind.clone()).collect();
1996        want_kinds.sort();
1997        got_kinds.sort();
1998        assert_eq!(got_kinds, want_kinds, "subtree kinds match the arena");
1999
2000        // Out-of-range id is rejected.
2001        assert!(matches!(ed.subtree(NodeId(9999)), Err(Error::InvalidArgument)));
2002    }
2003
2004    #[test]
2005    fn flat_nodes_carry_table_head_and_alignment() {
2006        // The delimiter row (`|:-----|----:|`) is consumed by the parser and has
2007        // no node of its own, so `alignment` on the cells is the only way a
2008        // consumer can recover the column alignment from a snapshot.
2009        let src = "| Name | Qty |\n|:-----|----:|\n| Pear | 3 |\n";
2010        let mut ed = Editor::new_str(src, Format::Markdown).expect("editor");
2011        let nodes = ed.nodes().expect("nodes");
2012
2013        let rows: Vec<_> = nodes.iter().filter(|n| n.kind == "row").collect();
2014        assert_eq!(rows.len(), 2, "a header row and one body row");
2015        assert_eq!(rows[0].head, Some(true), "first row is the header");
2016        assert_eq!(rows[1].head, Some(false), "second row is a body row");
2017
2018        let cells: Vec<_> = nodes.iter().filter(|n| n.kind == "cell").collect();
2019        assert_eq!(cells.len(), 4);
2020        // Alignment comes from the delimiter row and applies down the column.
2021        assert_eq!(cells[0].alignment, Some(Alignment::Left));
2022        assert_eq!(cells[1].alignment, Some(Alignment::Right));
2023        assert_eq!(cells[2].alignment, Some(Alignment::Left));
2024        assert_eq!(cells[3].alignment, Some(Alignment::Right));
2025        // Header cells are flagged too, not just their row.
2026        assert_eq!(cells[0].head, Some(true));
2027        assert_eq!(cells[2].head, Some(false));
2028
2029        // A table with no alignment spelled out reports Default — a real value,
2030        // distinct from the None a non-cell reports.
2031        let mut plain = Editor::new_str("| A |\n| --- |\n| b |\n", Format::Markdown).expect("editor");
2032        let pnodes = plain.nodes().expect("nodes");
2033        let pcell = pnodes.iter().find(|n| n.kind == "cell").expect("a cell");
2034        assert_eq!(pcell.alignment, Some(Alignment::Default));
2035    }
2036
2037    #[test]
2038    fn editor_node_at_and_ancestors_hit_test_offsets() {
2039        let mut ed = Editor::new_str("# Hi\n\ntext\n", Format::Markdown).expect("editor");
2040
2041        // Offset 2 is the "H" of the heading "# Hi" [0,4).
2042        let m = ed.node_at(2).expect("node_at").expect("a node covers offset 2");
2043        assert!(m.span.contains(&2));
2044
2045        // The ancestor chain is root-first and ends at the deepest (== node_at).
2046        let chain = ed.ancestors_at(2).expect("ancestors_at");
2047        assert!(!chain.is_empty());
2048        assert_eq!(chain[0].kind, "doc");
2049        assert_eq!(chain.last().unwrap().node_id, m.node_id);
2050
2051        // An out-of-range offset is an error; a gap covers nothing deeper than doc.
2052        assert_eq!(ed.node_at(999), Err(Error::InvalidArgument));
2053    }
2054
2055    // ── range-oriented rich-text ops (P5) ───────────────────────────────────
2056
2057    #[test]
2058    fn editor_wrap_and_toggle_inline_round_trip() {
2059        let mut ed = Editor::new_str("a word b\n", Format::Markdown).expect("editor");
2060
2061        // Bold "word" [2,6); the Change reports the new "**word**" region.
2062        let c = ed.wrap_range(2, 6, InlineKind::Strong).expect("wrap");
2063        assert_eq!(ed.source_str().unwrap(), "a **word** b\n");
2064        assert_eq!(&ed.source_str().unwrap()[c.new.clone()], "**word**");
2065
2066        // Toggle it off by selecting the strong node's interior [4,8).
2067        ed.toggle_inline(4, 8, InlineKind::Strong).expect("toggle off");
2068        assert_eq!(ed.source_str().unwrap(), "a word b\n");
2069
2070        // Toggle emphasis on when the range isn't already marked.
2071        ed.toggle_inline(2, 6, InlineKind::Emph).expect("toggle on");
2072        assert_eq!(ed.source_str().unwrap(), "a *word* b\n");
2073    }
2074
2075    #[test]
2076    fn editor_inline_kind_support_is_format_specific() {
2077        // Markdown has no highlight/mark spelling.
2078        let mut md = Editor::new_str("a word b\n", Format::Markdown).expect("editor");
2079        assert_eq!(md.wrap_range(2, 6, InlineKind::Mark), Err(Error::UnsupportedFormat));
2080
2081        // Djot spells it {=…=}.
2082        let mut dj = Editor::new_str("a word b\n", Format::Djot).expect("editor");
2083        dj.wrap_range(2, 6, InlineKind::Mark).expect("djot mark");
2084        assert_eq!(dj.source_str().unwrap(), "a {=word=} b\n");
2085    }
2086
2087    #[test]
2088    fn editor_toggle_strips_verbatim_via_content_span() {
2089        let mut ed = Editor::new_str("a `code` b\n", Format::Markdown).expect("editor");
2090        // The verbatim node [2,8) reports content_span [3,7); toggle peels it.
2091        ed.toggle_inline(2, 8, InlineKind::Verbatim).expect("toggle code off");
2092        assert_eq!(ed.source_str().unwrap(), "a code b\n");
2093
2094        // A multi-backtick span peels BOTH runs via content_span, not by
2095        // stripping a single delimiter (which would corrupt it to "`x`").
2096        let mut ed2 = Editor::new_str("a ``x`` b\n", Format::Markdown).expect("editor");
2097        ed2.toggle_inline(2, 7, InlineKind::Verbatim).expect("toggle multi off");
2098        assert_eq!(ed2.source_str().unwrap(), "a x b\n");
2099    }
2100
2101    #[test]
2102    fn editor_set_block_switches_para_and_heading_levels() {
2103        let mut ed = Editor::new_str("Title\n\nbody text\n", Format::Markdown).expect("editor");
2104
2105        // Paragraph -> H2 (offset 0 is inside "Title").
2106        ed.set_block(0, BlockKind::Heading(2)).expect("to h2");
2107        assert_eq!(ed.source_str().unwrap(), "## Title\n\nbody text\n");
2108
2109        // H2 -> H1 (offset now inside "## Title").
2110        ed.set_block(3, BlockKind::Heading(1)).expect("to h1");
2111        assert_eq!(ed.source_str().unwrap(), "# Title\n\nbody text\n");
2112
2113        // Heading -> paragraph, dropping the marker.
2114        ed.set_block(2, BlockKind::Paragraph).expect("to para");
2115        assert_eq!(ed.source_str().unwrap(), "Title\n\nbody text\n");
2116    }
2117
2118    #[test]
2119    fn editor_set_block_rejects_bad_level_and_format() {
2120        let mut md = Editor::new_str("hi\n", Format::Markdown).expect("editor");
2121        assert_eq!(md.set_block(0, BlockKind::Heading(9)), Err(Error::InvalidArgument));
2122
2123        let mut xml = Editor::new_str("<a>hi</a>", Format::Xml).expect("editor");
2124        assert_eq!(xml.set_block(1, BlockKind::Heading(1)), Err(Error::UnsupportedFormat));
2125    }
2126
2127    #[test]
2128    fn editor_toggle_block_container_round_trips() {
2129        let mut ed = Editor::new_str("a\n", Format::Djot).expect("editor");
2130
2131        let c = ed
2132            .toggle_block_container(0, 1, BlockContainerKind::BlockQuote)
2133            .expect("quote on");
2134        assert_eq!(ed.source_str().unwrap(), "> a\n");
2135        assert_eq!(&ed.source_str().unwrap()[c.new.clone()], "> a\n");
2136
2137        ed.toggle_block_container(2, 3, BlockContainerKind::BlockQuote)
2138            .expect("quote off");
2139        assert_eq!(ed.source_str().unwrap(), "a\n");
2140    }
2141
2142    #[test]
2143    fn editor_toggle_block_container_nests_a_partial_selection() {
2144        let mut ed = Editor::new_str("> a\n>\n> b\n", Format::Djot).expect("editor");
2145
2146        // Only the first paragraph is covered, so the quote is not fully
2147        // selected: nest rather than drag `b` out of the quote too.
2148        ed.toggle_block_container(2, 3, BlockContainerKind::BlockQuote)
2149            .expect("nest");
2150        assert_eq!(ed.source_str().unwrap(), "> > a\n>\n> b\n");
2151
2152        // Peel the inner level back off, leaving the outer quote intact.
2153        ed.toggle_block_container(4, 5, BlockContainerKind::BlockQuote)
2154            .expect("peel");
2155        assert_eq!(ed.source_str().unwrap(), "> a\n>\n> b\n");
2156    }
2157
2158    #[test]
2159    fn editor_toggle_block_container_numbers_and_converts_lists() {
2160        let mut ed = Editor::new_str("a\n\nb\n", Format::Djot).expect("editor");
2161
2162        // Each covered block becomes its own numbered item.
2163        ed.toggle_block_container(0, 4, BlockContainerKind::OrderedList)
2164            .expect("ordered on");
2165        assert_eq!(ed.source_str().unwrap(), "1. a\n\n2. b\n");
2166
2167        // The other list kind converts in place instead of nesting.
2168        ed.toggle_block_container(3, 9, BlockContainerKind::BulletList)
2169            .expect("convert");
2170        assert_eq!(ed.source_str().unwrap(), "- a\n\n- b\n");
2171    }
2172
2173    #[test]
2174    fn editor_toggle_block_container_rejects_unspellable_format() {
2175        let mut xml = Editor::new_str("<a>hi</a>", Format::Xml).expect("editor");
2176        assert_eq!(
2177            xml.toggle_block_container(3, 5, BlockContainerKind::BlockQuote),
2178            Err(Error::UnsupportedFormat)
2179        );
2180    }
2181
2182    #[test]
2183    fn editor_insert_link_wraps_and_repoints() {
2184        let mut ed = Editor::new_str("a word b\n", Format::Djot).expect("editor");
2185
2186        ed.insert_link(2, 6, "http://x.dev").expect("link");
2187        assert_eq!(ed.source_str().unwrap(), "a [word](http://x.dev) b\n");
2188
2189        // A caret inside the existing link re-points it rather than nesting.
2190        ed.insert_link(3, 7, "http://y.dev").expect("re-point");
2191        assert_eq!(ed.source_str().unwrap(), "a [word](http://y.dev) b\n");
2192    }
2193
2194    #[test]
2195    fn editor_insert_link_repoints_an_autolink() {
2196        // The regression: an autolink is a `url`/`email` node whose text IS its
2197        // destination. Read as ordinary text, a caret inside it spliced a whole
2198        // new link into the middle of the old URL —
2199        // `see <https<https://y.dev>://x.dev> ok`.
2200        for format in [Format::Markdown, Format::Djot] {
2201            let mut ed = Editor::new_str("see <https://x.dev> ok\n", format).expect("editor");
2202            ed.insert_link(10, 10, "https://y.dev").expect("re-point");
2203            assert_eq!(ed.source_str().unwrap(), "see <https://y.dev> ok\n");
2204
2205            // Source that looks right can still parse wrong: assert the reparse.
2206            let nodes = ed.nodes().expect("nodes");
2207            let url = nodes
2208                .iter()
2209                .find(|n| n.kind == "url")
2210                .expect("still an autolink");
2211            assert_eq!(url.text.as_deref(), Some("https://y.dev"));
2212            assert!(!nodes.iter().any(|n| n.kind == "link"));
2213        }
2214    }
2215
2216    #[test]
2217    fn editor_insert_link_escapes_the_destination() {
2218        // Unescaped, the `)` would close the link early and spill `b` into the
2219        // paragraph as literal text.
2220        let mut dj = Editor::new_str("w\n", Format::Djot).expect("editor");
2221        dj.insert_link(0, 1, "a)b").expect("link");
2222        assert_eq!(dj.source_str().unwrap(), "[w](a\\)b)\n");
2223
2224        // Whitespace is where the formats part ways: Markdown needs the angle
2225        // form (a bare space ends the destination and kills the link outright),
2226        // Djot must NOT use it (it would link to the literal text `<a b>`).
2227        let mut md = Editor::new_str("w\n", Format::Markdown).expect("editor");
2228        md.insert_link(0, 1, "a b").expect("link");
2229        assert_eq!(md.source_str().unwrap(), "[w](<a b>)\n");
2230
2231        let mut dj2 = Editor::new_str("w\n", Format::Djot).expect("editor");
2232        dj2.insert_link(0, 1, "a b").expect("link");
2233        assert_eq!(dj2.source_str().unwrap(), "[w](a b)\n");
2234    }
2235
2236    #[test]
2237    fn editor_insert_link_rejects_a_newline_destination() {
2238        let mut ed = Editor::new_str("w\n", Format::Djot).expect("editor");
2239        assert_eq!(ed.insert_link(0, 1, "a\nb"), Err(Error::InvalidArgument));
2240
2241        let mut xml = Editor::new_str("<a>hi</a>", Format::Xml).expect("editor");
2242        assert_eq!(xml.insert_link(3, 5, "u"), Err(Error::UnsupportedFormat));
2243    }
2244
2245    #[test]
2246    fn editor_insert_literal_keeps_typed_specials_literal() {
2247        for format in [Format::Markdown, Format::Djot] {
2248            let mut ed = Editor::new_str("z\n", format).expect("editor");
2249            // A `*` at a line start would open emphasis unescaped.
2250            ed.insert_literal(0, "*hi*").expect("literal");
2251
2252            // Source that looks right can still parse wrong: assert the reparse.
2253            let nodes = ed.nodes().expect("nodes");
2254            assert!(!nodes.iter().any(|n| n.kind == "emph" || n.kind == "strong"));
2255            let text: String = nodes
2256                .iter()
2257                .filter(|n| n.kind == "str")
2258                .filter_map(|n| n.text.clone())
2259                .collect();
2260            assert_eq!(text, "*hi*z");
2261        }
2262    }
2263
2264    #[test]
2265    fn editor_insert_literal_escapes_block_markers_only_at_line_start() {
2266        // Mid-line, a `#` opens nothing and is left as typed.
2267        let mut ed = Editor::new_str("az\n", Format::Markdown).expect("editor");
2268        ed.insert_literal(1, "# ").expect("literal");
2269        assert_eq!(ed.source_str().unwrap(), "a# z\n");
2270
2271        // At a line start it would open a heading, so it is escaped.
2272        let mut ed2 = Editor::new_str("z\n", Format::Markdown).expect("editor");
2273        ed2.insert_literal(0, "# ").expect("literal");
2274        assert_eq!(ed2.source_str().unwrap(), "\\# z\n");
2275        assert!(!ed2.nodes().expect("nodes").iter().any(|n| n.kind == "heading"));
2276    }
2277
2278    #[test]
2279    fn editor_insert_literal_rejects_bad_offset_and_parse_only_format() {
2280        let mut ed = Editor::new_str("ab\n", Format::Markdown).expect("editor");
2281        assert_eq!(ed.insert_literal(99, "x"), Err(Error::InvalidArgument));
2282
2283        let mut xml = Editor::new_str("<a>hi</a>", Format::Xml).expect("editor");
2284        assert_eq!(xml.insert_literal(3, "x"), Err(Error::UnsupportedFormat));
2285    }
2286
2287    #[test]
2288    fn editor_insert_line_break_splices_in_cell_br() {
2289        let mut ed =
2290            Editor::new_str("| a | b |\n| --- | --- |\n", Format::Markdown).expect("editor");
2291        // Caret just after `a` in the header cell.
2292        ed.insert_line_break(3).expect("line break");
2293        assert_eq!(ed.source_str().unwrap(), "| a<br> | b |\n| --- | --- |\n");
2294        // The break reads back as a semantic node, not raw HTML.
2295        let nodes = ed.nodes().expect("nodes");
2296        assert!(nodes.iter().any(|n| n.kind == "hard_break"));
2297        assert!(!nodes.iter().any(|n| n.kind == "raw_inline"));
2298    }
2299
2300    #[test]
2301    fn editor_insert_line_break_rejects_off_cell_off_format_and_bad_offset() {
2302        // Not inside a cell → NotFound.
2303        let mut para = Editor::new_str("just text\n", Format::Markdown).expect("editor");
2304        assert_eq!(para.insert_line_break(3), Err(Error::NotFound));
2305
2306        // Djot has no in-cell break spelling → UnsupportedFormat.
2307        let mut dj =
2308            Editor::new_str("| a | b |\n| --- | --- |\n", Format::Djot).expect("editor");
2309        assert_eq!(dj.insert_line_break(3), Err(Error::UnsupportedFormat));
2310
2311        // Out-of-range offset → InvalidArgument.
2312        let mut ed =
2313            Editor::new_str("| a | b |\n| --- | --- |\n", Format::Markdown).expect("editor");
2314        assert_eq!(ed.insert_line_break(9999), Err(Error::InvalidArgument));
2315    }
2316
2317    #[test]
2318    fn editor_undo_redo_round_trip() {
2319        let mut ed = Editor::new_str("hello\n", Format::Markdown).expect("editor");
2320        ed.edit_range(5, 5, "!").expect("edit");
2321        assert_eq!(ed.source_str().unwrap(), "hello!\n");
2322
2323        let change = ed.undo().expect("undo ok").expect("something to undo");
2324        assert_eq!(ed.source_str().unwrap(), "hello\n");
2325        assert_eq!(change.new.end, 5);
2326        assert!(ed.undo().expect("undo ok").is_none(), "history exhausted");
2327
2328        ed.redo().expect("redo ok").expect("something to redo");
2329        assert_eq!(ed.source_str().unwrap(), "hello!\n");
2330    }
2331
2332    #[test]
2333    fn editor_coalesce_folds_a_run() {
2334        let mut ed = Editor::new_str("\n", Format::Markdown).expect("editor");
2335        ed.edit_range(0, 0, "a").expect("edit");
2336        ed.edit_range(1, 1, "b").expect("edit");
2337        ed.coalesce_last_undo().expect("coalesce");
2338        assert_eq!(ed.source_str().unwrap(), "ab\n");
2339        // One undo removes the whole coalesced run.
2340        ed.undo().expect("undo ok").expect("something to undo");
2341        assert_eq!(ed.source_str().unwrap(), "\n");
2342        assert!(ed.undo().expect("undo ok").is_none());
2343    }
2344
2345    #[test]
2346    fn editor_revision_bumps_per_successful_mutation() {
2347        let mut ed = Editor::new_str("x\n", Format::Markdown).expect("editor");
2348        assert_eq!(ed.revision(), 0);
2349        ed.edit_range(1, 1, "y").expect("edit");
2350        assert_eq!(ed.revision(), 1);
2351
2352        // A reparse-breaking edit is rolled back and must not bump the revision.
2353        let mut xml = Editor::new_str("<a>ok</a>", Format::Xml).expect("editor");
2354        assert_eq!(xml.revision(), 0);
2355        assert!(xml.replace_content("0", "<b>").is_err());
2356        assert_eq!(xml.revision(), 0);
2357
2358        // undo and redo are mutations too.
2359        ed.undo().expect("undo ok").expect("something to undo");
2360        assert_eq!(ed.revision(), 2);
2361        ed.redo().expect("redo ok").expect("something to redo");
2362        assert_eq!(ed.revision(), 3);
2363    }
2364
2365    #[test]
2366    fn editor_dirty_range_tracks_and_clears() {
2367        let mut ed = Editor::new_str("abcdefgh\n", Format::Markdown).expect("editor");
2368        // Clean to start.
2369        assert_eq!(ed.dirty_range(), None);
2370
2371        // One insertion of two bytes at offset 2 dirties exactly [2, 4).
2372        ed.edit_range(2, 2, "XY").expect("edit");
2373        assert_eq!(ed.dirty_range(), Some(2..4));
2374
2375        // A second, disjoint edit near the end accumulates conservatively: the
2376        // reported range is a superset covering both edits.
2377        ed.edit_range(9, 9, "Z").expect("edit"); // source is now "abXYcdefgZh\n"
2378        let d = ed.dirty_range().expect("dirty");
2379        assert!(d.start <= 2 && d.end >= 10, "range {d:?} must cover both edits");
2380
2381        // clear_dirty acknowledges without moving the revision.
2382        let rev = ed.revision();
2383        ed.clear_dirty();
2384        assert_eq!(ed.dirty_range(), None);
2385        assert_eq!(ed.revision(), rev);
2386
2387        // Post-clear, only new mutations show up — and undo counts as one.
2388        ed.undo().expect("undo ok").expect("something to undo");
2389        assert!(ed.dirty_range().is_some());
2390    }
2391
2392    #[test]
2393    fn editor_caret_blob_follows_undo_and_redo() {
2394        let mut ed = Editor::new_str("hello\n", Format::Markdown).expect("editor");
2395        assert!(ed.caret_blob().unwrap().is_empty());
2396
2397        // Set the pre-edit caret, then edit: the retired undo step captures it.
2398        ed.set_caret_blob(b"before").expect("set caret");
2399        ed.edit_range(5, 5, "!").expect("edit");
2400        // A fresh state starts caret-less until the host sets one.
2401        assert!(ed.caret_blob().unwrap().is_empty());
2402        ed.set_caret_blob(b"after").expect("set caret");
2403
2404        // Undo restores the pre-edit source AND the pre-edit caret.
2405        ed.undo().expect("undo ok").expect("something to undo");
2406        assert_eq!(ed.source_str().unwrap(), "hello\n");
2407        assert_eq!(ed.caret_blob().unwrap(), b"before");
2408
2409        // Redo restores the post-edit source AND the post-edit caret.
2410        ed.redo().expect("redo ok").expect("something to redo");
2411        assert_eq!(ed.source_str().unwrap(), "hello!\n");
2412        assert_eq!(ed.caret_blob().unwrap(), b"after");
2413    }
2414
2415    #[test]
2416    fn editor_coalesced_run_keeps_the_pre_run_caret() {
2417        let mut ed = Editor::new_str("\n", Format::Markdown).expect("editor");
2418        ed.set_caret_blob(b"c0").expect("set caret");
2419        ed.edit_range(0, 0, "a").expect("edit");
2420        ed.set_caret_blob(b"c1").expect("set caret");
2421        ed.edit_range(1, 1, "b").expect("edit");
2422        ed.coalesce_last_undo().expect("coalesce");
2423        ed.set_caret_blob(b"c2").expect("set caret");
2424
2425        // One undo folds the run and restores the caret from before it began.
2426        ed.undo().expect("undo ok").expect("something to undo");
2427        assert_eq!(ed.source_str().unwrap(), "\n");
2428        assert_eq!(ed.caret_blob().unwrap(), b"c0");
2429    }
2430
2431    #[test]
2432    fn editor_renumber_ordered_lists_fixes_a_stale_sequence() {
2433        let mut ed =
2434            Editor::new_str("1. a\n2. x\n2. b\n3. c\n", Format::Markdown).expect("editor");
2435        ed.renumber_ordered_lists(0).expect("renumber ok");
2436        assert_eq!(ed.source_str().unwrap(), "1. a\n2. x\n3. b\n4. c\n");
2437    }
2438
2439    #[test]
2440    fn editor_renumber_ordered_lists_off_a_list_is_not_found() {
2441        let mut ed = Editor::new_str("a paragraph\n", Format::Markdown).expect("editor");
2442        assert!(matches!(ed.renumber_ordered_lists(2), Err(Error::NotFound)));
2443    }
2444
2445    #[test]
2446    fn editor_table_insert_row_and_set_alignment() {
2447        let src = "| a | b |\n| --- | --- |\n| 1 | 2 |\n";
2448        let mut ed = Editor::new_str(src, Format::Markdown).expect("editor");
2449        ed.table_insert_row(24, true).expect("insert row"); // caret in body `1`
2450        assert_eq!(
2451            ed.source_str().unwrap(),
2452            "| a | b |\n| --- | --- |\n| 1 | 2 |\n|  |  |\n"
2453        );
2454        ed.table_set_alignment(6, Alignment::Center).expect("align"); // column `b`
2455        assert!(ed.source_str().unwrap().contains("| --- | :---: |"));
2456    }
2457
2458    #[test]
2459    fn editor_table_edit_off_a_table_is_not_found() {
2460        let mut ed = Editor::new_str("nope\n", Format::Markdown).expect("editor");
2461        assert!(matches!(ed.table_delete_row(2), Err(Error::NotFound)));
2462    }
2463
2464    #[test]
2465    fn editor_set_block_converts_setext_heading() {
2466        // A setext heading rebuilt from its content_span collapses the underline.
2467        let mut ed = Editor::new_str("Title\n=====\n\nbody\n", Format::Markdown).expect("editor");
2468        ed.set_block(0, BlockKind::Heading(1)).expect("setext to atx");
2469        assert_eq!(ed.source_str().unwrap(), "# Title\n\nbody\n");
2470    }
2471
2472    #[test]
2473    fn editor_unwrap_and_smart_delete() {
2474        let mut ed = Editor::new_str("<r><box><b/><c/></box></r>", Format::Xml).expect("editor");
2475        ed.unwrap_node("0.0").expect("unwrap"); // <box>
2476        assert_eq!(ed.source_str().expect("source"), "<r><b/><c/></r>");
2477
2478        let mut md = Editor::new_str("A\n\nB\n\nC\n", Format::Markdown).expect("editor");
2479        md.delete_smart("1").expect("delete_smart"); // the "B" paragraph
2480        assert_eq!(md.source_str().expect("source"), "A\n\nC\n");
2481    }
2482
2483    #[test]
2484    fn editor_directives_require_the_extension_flag() {
2485        let src = ":::vis{.public}\nhi\n:::\n";
2486        // Without the flag, the colon-fence lines are plain paragraph text —
2487        // no directive node.
2488        let mut plain = Editor::new_str(src, Format::Markdown).expect("editor");
2489        assert_eq!(plain.query("directive").expect("query").len(), 0);
2490        // With it enabled, the container directive is recognized.
2491        let mut ext = Editor::new_ext(
2492            src.as_bytes(),
2493            Format::Markdown,
2494            MarkdownExtensions { directives: true, ..Default::default() },
2495        )
2496        .expect("editor");
2497        assert_eq!(ext.query("directive").expect("query").len(), 1);
2498    }
2499
2500    #[test]
2501    fn document_html_elements_make_embedded_img_queryable() {
2502        let src = "text <img src=\"a.png\" alt=\"x\"> more\n";
2503        // Without the flag, the `<img>` is opaque raw HTML — no `image` node.
2504        let mut plain = Document::parse_str(src, Format::Markdown).expect("parse");
2505        assert_eq!(plain.query("image").expect("query").len(), 0);
2506        // With it enabled on the read path, the promoted image is queryable.
2507        let mut ext = Document::parse_str_with(
2508            src,
2509            Format::Markdown,
2510            MarkdownExtensions { html_elements: true, ..Default::default() },
2511        )
2512        .expect("parse");
2513        let images = ext.query("image").expect("query");
2514        assert_eq!(images.len(), 1);
2515        assert_eq!(images[0].kind, "image");
2516    }
2517
2518    #[test]
2519    fn editor_filter_public_audience_view() {
2520        let src = "# Archive\n\n:::vis{.public}\nPublic.\n:::\n\n:::vis{.family}\nPrivate.\n:::\n";
2521        let mut ed = Editor::new_ext(
2522            src.as_bytes(),
2523            Format::Markdown,
2524            MarkdownExtensions { directives: true, ..Default::default() },
2525        )
2526        .expect("editor");
2527        // Drop every vis block except the public one, then unwrap it.
2528        ed.filter(
2529            "directive[name=vis]",
2530            Some("directive[class~=public]"),
2531            true,
2532        )
2533        .expect("filter");
2534        assert_eq!(ed.source_str().expect("source"), "# Archive\n\nPublic.\n");
2535    }
2536
2537    #[test]
2538    fn editor_filter_rejects_a_malformed_selector() {
2539        let mut ed = Editor::new_str("hi\n", Format::Markdown).expect("editor");
2540        assert_eq!(ed.filter("list >", None, false), Err(Error::InvalidArgument));
2541    }
2542
2543    #[test]
2544    fn builder_builds_and_renders_a_document() {
2545        let mut b = Builder::new().expect("builder");
2546
2547        // # Title\n\nhello *world*
2548        let title = b.add_text(TextKind::Str, "Title").unwrap();
2549        let heading = b.add_heading(1).unwrap();
2550        b.set_children(heading, &[title]).unwrap();
2551
2552        let hello = b.add_text(TextKind::Str, "hello ").unwrap();
2553        let world = b.add_text(TextKind::Str, "world").unwrap();
2554        let emph = b.add(VoidKind::Emph).unwrap();
2555        b.set_children(emph, &[world]).unwrap();
2556        let para = b.add(VoidKind::Para).unwrap();
2557        b.set_children(para, &[hello, emph]).unwrap();
2558
2559        let doc = b.add(VoidKind::Doc).unwrap();
2560        b.set_children(doc, &[heading, para]).unwrap();
2561
2562        let html = String::from_utf8(b.render_html(doc).unwrap()).unwrap();
2563        assert!(html.contains("<h1>Title</h1>"), "{html}");
2564        assert!(html.contains("<em>world</em>"), "{html}");
2565
2566        let md = String::from_utf8(b.serialize(doc, Format::Markdown).unwrap()).unwrap();
2567        assert!(md.contains("# Title"), "{md}");
2568        assert!(md.contains("*world*"), "{md}");
2569
2570        let matches = b.query(doc, "heading").unwrap();
2571        assert_eq!(matches.len(), 1);
2572        assert_eq!(matches[0].kind, "heading");
2573
2574        let json = String::from_utf8(b.ast_json(doc).unwrap()).unwrap();
2575        assert!(json.contains("\"kind\": \"doc\""), "{json}");
2576    }
2577
2578    #[test]
2579    fn builder_element_with_attributes() {
2580        let mut b = Builder::new().expect("builder");
2581        let inner = b.add_text(TextKind::Str, "hi").unwrap();
2582        let el = b.add_element("section").unwrap();
2583        b.set_children(el, &[inner]).unwrap();
2584        b.set_attrs(el, &[("class", Some("note")), ("hidden", None)]).unwrap();
2585
2586        let html = String::from_utf8(b.render_html(el).unwrap()).unwrap();
2587        assert!(html.contains("<section"), "{html}");
2588        assert!(html.contains("class=\"note\""), "{html}");
2589        assert!(html.contains("hidden"), "{html}");
2590    }
2591
2592    #[test]
2593    fn builder_lists_round_trip_to_markdown() {
2594        let mut b = Builder::new().expect("builder");
2595
2596        // An ordered list: 1. one / 2. two
2597        let one_txt = b.add_text(TextKind::Str, "one").unwrap();
2598        let one_para = b.add(VoidKind::Para).unwrap();
2599        b.set_children(one_para, &[one_txt]).unwrap();
2600        let one = b.add(VoidKind::ListItem).unwrap();
2601        b.set_children(one, &[one_para]).unwrap();
2602
2603        let two_txt = b.add_text(TextKind::Str, "two").unwrap();
2604        let two_para = b.add(VoidKind::Para).unwrap();
2605        b.set_children(two_para, &[two_txt]).unwrap();
2606        let two = b.add(VoidKind::ListItem).unwrap();
2607        b.set_children(two, &[two_para]).unwrap();
2608
2609        let list = b
2610            .add_ordered_list(OrderedNumbering::Decimal, OrderedDelim::Period, true, Some(1))
2611            .unwrap();
2612        b.set_children(list, &[one, two]).unwrap();
2613        let doc = b.add(VoidKind::Doc).unwrap();
2614        b.set_children(doc, &[list]).unwrap();
2615
2616        let md = String::from_utf8(b.serialize(doc, Format::Markdown).unwrap()).unwrap();
2617        assert!(md.contains("1. one"), "{md}");
2618        assert!(md.contains("2. two"), "{md}");
2619    }
2620
2621    #[test]
2622    fn builder_rejects_invalid_kind_and_id() {
2623        let b = Builder::new().expect("builder");
2624        // `heading` (code 2) carries a payload, so the void-kind `add` rejects it
2625        // — the safe `VoidKind` enum has no such variant, so we go through the raw
2626        // ABI to prove the guard.
2627        let mut id = 0u32;
2628        let status = unsafe { ffi::twig_builder_add(b.raw.as_ptr(), 2, &mut id) };
2629        assert_eq!(Error::from_status(status), Err(Error::InvalidArgument));
2630
2631        // A root id past the end can't be rendered.
2632        let mut ptr = std::ptr::null();
2633        let mut len = 0usize;
2634        let status = unsafe { ffi::twig_builder_render_html(b.raw.as_ptr(), 4242, &mut ptr, &mut len) };
2635        assert_eq!(Error::from_status(status), Err(Error::InvalidArgument));
2636    }
2637}