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