Skip to main content

twig/
lib.rs

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