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    /// Spell `[start, end)` as an image pointing at `destination` —
995    /// `![alt](destination)`, the selected source becoming the alt text.
996    ///
997    /// The destination is escaped exactly as [`insert_link`](Self::insert_link)
998    /// escapes one, because it is the same grammar production: Markdown moves a
999    /// destination holding whitespace into the `<…>` form, Djot leaves it bare
1000    /// because `<…>` there would read as the URL itself. That is the reason this
1001    /// exists rather than being a `format!` at the call site — `![](my file.png)`
1002    /// is not an image in Markdown at all, and no caller can fix that without
1003    /// reproducing twig's per-format escape table.
1004    ///
1005    /// Two ways it is simpler than a link. An empty range stays empty:
1006    /// `![](destination)` is a perfectly good image, where the childless
1007    /// `[](destination)` that `insert_link` works to avoid has nothing to render
1008    /// or put a caret in. And there is no autolink or re-point reasoning — an
1009    /// image has no bare-URL spelling, and re-pointing an existing one is a read
1010    /// of its destination plus an insert, above this op.
1011    ///
1012    /// Returns [`Error::InvalidArgument`] for a destination holding a newline and
1013    /// [`Error::UnsupportedFormat`] for a parse-only format (XML, HTML).
1014    pub fn insert_image(
1015        &mut self,
1016        start: usize,
1017        end: usize,
1018        destination: &str,
1019    ) -> Result<Change, Error> {
1020        self.change_op(|ed, out| unsafe {
1021            ffi::twig_editor_insert_image(
1022                ed,
1023                start,
1024                end,
1025                destination.as_ptr(),
1026                destination.len(),
1027                out,
1028            )
1029        })
1030    }
1031
1032    /// Insert `text` at `offset` as a literal run: every byte the format reads as
1033    /// markup is backslash-escaped so the run reparses as exactly `text` — a typed
1034    /// `*`, `#` or `` ` `` stays that character rather than opening emphasis, a
1035    /// heading or a code span. This is the inverse of serialization (which writes
1036    /// an already-parsed run verbatim): it is what a WYSIWYG surface calls so that
1037    /// keyboard input can never mint markup, leaving formatting to explicit
1038    /// commands.
1039    ///
1040    /// The escaping is positional and per-format, and neither is the caller's to
1041    /// reproduce: inline specials (`*`, `` ` ``, `[`, `<`…) are escaped anywhere
1042    /// on the line, while block markers (`#`, `>`, `-`…) are escaped only where
1043    /// `offset` sits in its line's leading whitespace — so an inserted "5 - 3"
1044    /// keeps its `-` but "- item" at column zero does not become a bullet. An
1045    /// embedded newline in `text` re-enters that line-start zone.
1046    ///
1047    /// Two constructs a byte-alphabet cannot reach are left as typed: a GFM
1048    /// bare-URL autolink (`https://x.com`, with no delimiter to escape) and an
1049    /// ordered-list marker (`1.`, special only after a digit run). Returns
1050    /// [`Error::UnsupportedFormat`] for a parse-only format (XML, HTML) and
1051    /// [`Error::InvalidArgument`] when `offset` is past the source.
1052    pub fn insert_literal(&mut self, offset: usize, text: &str) -> Result<Change, Error> {
1053        self.change_op(|ed, out| unsafe {
1054            ffi::twig_editor_insert_literal(ed, offset, text.as_ptr(), text.len(), out)
1055        })
1056    }
1057
1058    /// Insert a hard line break *inside a table cell* at `offset`, spelled the
1059    /// format's way (`<br>` for Markdown). A table row is one source line, so the
1060    /// ordinary newline-based hard break can't appear there; the spliced `<br>`
1061    /// reparses as a semantic `hard_break` node — not opaque raw HTML — so the
1062    /// break reads back as structure. Like the other gestures it leans on the
1063    /// splice+reparse+rollback backstop: a break that would no longer parse as the
1064    /// same table yields [`Error::EditConflict`] and changes nothing.
1065    ///
1066    /// Returns [`Error::UnsupportedFormat`] for a format with no in-cell break
1067    /// spelling — djot (no idiomatic in-cell break), HTML and XML (parse-only);
1068    /// [`Error::NotFound`] when `offset` is not inside a table cell (only the
1069    /// in-cell gesture is spelled today); and [`Error::InvalidArgument`] when
1070    /// `offset` is past the source.
1071    pub fn insert_line_break(&mut self, offset: usize) -> Result<Change, Error> {
1072        self.change_op(|ed, out| unsafe { ffi::twig_editor_insert_line_break(ed, offset, out) })
1073    }
1074
1075    /// Shared plumbing for the change-returning ops: run `op` (which fills a
1076    /// `TwigChange` out-param) and wrap the result.
1077    fn change_op(
1078        &mut self,
1079        op: impl FnOnce(*mut ffi::TwigEditor, *mut ffi::TwigChange) -> ffi::TwigStatus,
1080    ) -> Result<Change, Error> {
1081        let mut change = ffi::TwigChange {
1082            old_span: ffi::TwigSpan { start: 0, end: 0 },
1083            new_span: ffi::TwigSpan { start: 0, end: 0 },
1084        };
1085        let status = op(self.raw.as_ptr(), &mut change);
1086        Error::from_status(status)?;
1087        Ok(Change::from_ffi(change))
1088    }
1089
1090    /// Shared plumbing for the `(locator, text)` edit ops.
1091    fn apply(
1092        &mut self,
1093        locator: &str,
1094        text: &str,
1095        op: impl FnOnce(*mut ffi::TwigEditor, *const u8, usize, *const u8, usize) -> ffi::TwigStatus,
1096    ) -> Result<(), Error> {
1097        let status = op(
1098            self.raw.as_ptr(),
1099            locator.as_ptr(),
1100            locator.len(),
1101            text.as_ptr(),
1102            text.len(),
1103        );
1104        Error::from_status(status)
1105    }
1106}
1107
1108impl Drop for Editor {
1109    fn drop(&mut self) {
1110        unsafe { ffi::twig_editor_destroy(self.raw.as_ptr()) }
1111    }
1112}
1113
1114/// Run `call` (which writes a borrowed `(ptr, len)` byte buffer) and copy the
1115/// result into an owned `Vec` — the buffer is only valid until the next
1116/// same-accessor call on the handle, so we copy before returning. Shared by
1117/// [`Document`] and [`Editor`].
1118fn collect_bytes(
1119    call: impl FnOnce(*mut *const u8, *mut usize) -> ffi::TwigStatus,
1120) -> Result<Vec<u8>, Error> {
1121    let mut ptr = std::ptr::null();
1122    let mut len = 0usize;
1123    let status = call(&mut ptr, &mut len);
1124    Error::from_status(status)?;
1125    if len == 0 {
1126        return Ok(Vec::new());
1127    }
1128    if ptr.is_null() {
1129        return Err(Error::Internal);
1130    }
1131    let bytes = unsafe { std::slice::from_raw_parts(ptr, len) };
1132    Ok(bytes.to_vec())
1133}
1134
1135/// Run `call` (which writes a borrowed `(ptr, len)` match array) and copy each
1136/// match into an owned [`QueryMatch`]. Shared by [`Document`] and [`Editor`].
1137fn collect_matches(
1138    call: impl FnOnce(*mut *const ffi::TwigQueryMatch, *mut usize) -> ffi::TwigStatus,
1139) -> Result<Vec<QueryMatch>, Error> {
1140    let mut ptr = std::ptr::null();
1141    let mut len = 0usize;
1142    let status = call(&mut ptr, &mut len);
1143    Error::from_status(status)?;
1144    if len == 0 {
1145        return Ok(Vec::new());
1146    }
1147    if ptr.is_null() {
1148        return Err(Error::Internal);
1149    }
1150    let matches = unsafe { std::slice::from_raw_parts(ptr, len) };
1151    matches.iter().map(query_match_from_ffi).collect()
1152}
1153
1154/// Copy a borrowed C ABI [`ffi::TwigQueryMatch`] into an owned [`QueryMatch`].
1155/// Shared by `collect_matches`, [`Editor::node_at`], and [`Editor::ancestors_at`].
1156fn query_match_from_ffi(m: &ffi::TwigQueryMatch) -> Result<QueryMatch, Error> {
1157    Ok(QueryMatch {
1158        node_id: m.node_id,
1159        span: m.span.start..m.span.end,
1160        content_span: if m.has_content_span != 0 {
1161            Some(m.content_span.start..m.content_span.end)
1162        } else {
1163            None
1164        },
1165        kind: borrowed_cstr(m.kind)?,
1166    })
1167}
1168
1169/// Copy a borrowed C ABI [`ffi::TwigFlatNode`] into an owned [`FlatNode`].
1170fn flat_node_from_ffi(n: &ffi::TwigFlatNode) -> Result<FlatNode, Error> {
1171    let node_id = |v: u32| if v == ffi::TWIG_NO_NODE { None } else { Some(NodeId(v)) };
1172    Ok(FlatNode {
1173        id: NodeId(n.id),
1174        parent: node_id(n.parent),
1175        first_child: node_id(n.first_child),
1176        next_sibling: node_id(n.next_sibling),
1177        span: n.span.start..n.span.end,
1178        content_span: if n.has_content_span != 0 {
1179            Some(n.content_span.start..n.content_span.end)
1180        } else {
1181            None
1182        },
1183        level: if n.level != 0 { Some(n.level) } else { None },
1184        kind: borrowed_cstr(n.kind)?,
1185        text: borrowed_bytes(n.text_ptr, n.text_len),
1186        destination: borrowed_bytes(n.destination_ptr, n.destination_len),
1187        head: match n.head {
1188            ffi::TWIG_HEAD_NONE => None,
1189            v => Some(v != 0),
1190        },
1191        alignment: Alignment::from_c(n.alignment),
1192        name: borrowed_bytes(n.name_ptr, n.name_len),
1193        directive_form: DirectiveForm::from_c(n.directive_form),
1194        attrs: borrowed_attrs(n.attrs_ptr, n.attrs_len),
1195    })
1196}
1197
1198/// Copy a borrowed `TwigKeyVal` array into owned `(key, value)` pairs, or an
1199/// empty vec for a NULL pointer (the node has no attributes). A bare attribute
1200/// (NULL `value`) maps to a `None` value, distinct from a present-but-empty one.
1201fn borrowed_attrs(ptr: *const ffi::TwigKeyVal, len: usize) -> Vec<(String, Option<String>)> {
1202    if ptr.is_null() || len == 0 {
1203        return Vec::new();
1204    }
1205    let kvs = unsafe { std::slice::from_raw_parts(ptr, len) };
1206    kvs.iter()
1207        .map(|kv| {
1208            let key = borrowed_bytes(kv.key, kv.key_len).unwrap_or_default();
1209            (key, borrowed_bytes(kv.value, kv.value_len))
1210        })
1211        .collect()
1212}
1213
1214/// Copy a NUL-terminated, library-owned C string into an owned `String`.
1215fn borrowed_cstr(ptr: *const c_char) -> Result<String, Error> {
1216    if ptr.is_null() {
1217        return Err(Error::Internal);
1218    }
1219    Ok(unsafe { std::ffi::CStr::from_ptr(ptr) }
1220        .to_str()
1221        .map_err(|_| Error::Internal)?
1222        .to_owned())
1223}
1224
1225/// Copy a borrowed `(ptr, len)` payload slice into an owned `String`, or `None`
1226/// for a NULL pointer (the kind carries no such payload). The bytes are a slice
1227/// of a UTF-8 document, so a lossy decode never actually substitutes.
1228fn borrowed_bytes(ptr: *const u8, len: usize) -> Option<String> {
1229    if ptr.is_null() {
1230        return None;
1231    }
1232    let bytes = unsafe { std::slice::from_raw_parts(ptr, len) };
1233    Some(String::from_utf8_lossy(bytes).into_owned())
1234}
1235
1236/// The id of a node added to a [`Builder`], returned by every `add*` method and
1237/// used to wire up the tree via [`Builder::set_children`] and to root a
1238/// render/serialize/query.
1239#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
1240pub struct NodeId(pub u32);
1241
1242/// The void-payload node kinds, addable via [`Builder::add`]. Kinds with a
1243/// payload have their own dedicated `add_*` method instead.
1244#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1245pub enum VoidKind {
1246    Doc,
1247    Para,
1248    ThematicBreak,
1249    Section,
1250    Div,
1251    BlockQuote,
1252    DefinitionList,
1253    Table,
1254    ListItem,
1255    DefinitionListItem,
1256    Term,
1257    Definition,
1258    Caption,
1259    SoftBreak,
1260    HardBreak,
1261    NonBreakingSpace,
1262    Emph,
1263    Strong,
1264    Span,
1265    Mark,
1266    Superscript,
1267    Subscript,
1268    Insert,
1269    Delete,
1270    DoubleQuoted,
1271    SingleQuoted,
1272}
1273
1274impl VoidKind {
1275    fn to_c(self) -> c_int {
1276        // Discriminants match `TwigNodeKind` in the C ABI.
1277        match self {
1278            VoidKind::Doc => 0,
1279            VoidKind::Para => 1,
1280            VoidKind::ThematicBreak => 3,
1281            VoidKind::Section => 4,
1282            VoidKind::Div => 5,
1283            VoidKind::BlockQuote => 9,
1284            VoidKind::DefinitionList => 13,
1285            VoidKind::Table => 14,
1286            VoidKind::ListItem => 15,
1287            VoidKind::DefinitionListItem => 17,
1288            VoidKind::Term => 18,
1289            VoidKind::Definition => 19,
1290            VoidKind::Caption => 22,
1291            VoidKind::SoftBreak => 26,
1292            VoidKind::HardBreak => 27,
1293            VoidKind::NonBreakingSpace => 28,
1294            VoidKind::Emph => 38,
1295            VoidKind::Strong => 39,
1296            VoidKind::Span => 42,
1297            VoidKind::Mark => 43,
1298            VoidKind::Superscript => 44,
1299            VoidKind::Subscript => 45,
1300            VoidKind::Insert => 46,
1301            VoidKind::Delete => 47,
1302            VoidKind::DoubleQuoted => 48,
1303            VoidKind::SingleQuoted => 49,
1304        }
1305    }
1306}
1307
1308/// The single-string-payload node kinds, addable via [`Builder::add_text`].
1309#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1310pub enum TextKind {
1311    Str,
1312    Symb,
1313    Verbatim,
1314    InlineMath,
1315    DisplayMath,
1316    Url,
1317    Email,
1318    FootnoteReference,
1319    Comment,
1320    Doctype,
1321    Cdata,
1322}
1323
1324impl TextKind {
1325    fn to_c(self) -> c_int {
1326        match self {
1327            TextKind::Str => 25,
1328            TextKind::Symb => 29,
1329            TextKind::Verbatim => 30,
1330            TextKind::InlineMath => 32,
1331            TextKind::DisplayMath => 33,
1332            TextKind::Url => 34,
1333            TextKind::Email => 35,
1334            TextKind::FootnoteReference => 36,
1335            TextKind::Comment => 52,
1336            TextKind::Doctype => 53,
1337            TextKind::Cdata => 55,
1338        }
1339    }
1340}
1341
1342/// Bullet marker style for [`Builder::add_bullet_list`].
1343#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1344pub enum BulletStyle {
1345    Dash,
1346    Plus,
1347    Star,
1348}
1349
1350impl BulletStyle {
1351    fn to_c(self) -> c_int {
1352        match self {
1353            BulletStyle::Dash => 0,
1354            BulletStyle::Plus => 1,
1355            BulletStyle::Star => 2,
1356        }
1357    }
1358}
1359
1360/// Numbering scheme for [`Builder::add_ordered_list`].
1361#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1362pub enum OrderedNumbering {
1363    Decimal,
1364    LowerAlpha,
1365    UpperAlpha,
1366    LowerRoman,
1367    UpperRoman,
1368}
1369
1370impl OrderedNumbering {
1371    fn to_c(self) -> c_int {
1372        match self {
1373            OrderedNumbering::Decimal => 0,
1374            OrderedNumbering::LowerAlpha => 1,
1375            OrderedNumbering::UpperAlpha => 2,
1376            OrderedNumbering::LowerRoman => 3,
1377            OrderedNumbering::UpperRoman => 4,
1378        }
1379    }
1380}
1381
1382/// Delimiter around an ordered-list number (`1.`, `1)`, `(1)`).
1383#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1384pub enum OrderedDelim {
1385    Period,
1386    ParenAfter,
1387    ParenBoth,
1388}
1389
1390impl OrderedDelim {
1391    fn to_c(self) -> c_int {
1392        match self {
1393            OrderedDelim::Period => 0,
1394            OrderedDelim::ParenAfter => 1,
1395            OrderedDelim::ParenBoth => 2,
1396        }
1397    }
1398}
1399
1400/// Table-cell alignment: written via [`Builder::add_cell`], read back on
1401/// [`FlatNode::alignment`].
1402#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1403pub enum Alignment {
1404    Default,
1405    Left,
1406    Right,
1407    Center,
1408}
1409
1410impl Alignment {
1411    fn to_c(self) -> c_int {
1412        match self {
1413            Alignment::Default => ffi::TWIG_ALIGN_DEFAULT,
1414            Alignment::Left => ffi::TWIG_ALIGN_LEFT,
1415            Alignment::Right => ffi::TWIG_ALIGN_RIGHT,
1416            Alignment::Center => ffi::TWIG_ALIGN_CENTER,
1417        }
1418    }
1419
1420    /// The inverse of [`Alignment::to_c`]; `None` for [`ffi::TWIG_ALIGN_NONE`]
1421    /// (the node isn't a cell) or any code this binding doesn't know.
1422    fn from_c(v: c_int) -> Option<Self> {
1423        match v {
1424            ffi::TWIG_ALIGN_DEFAULT => Some(Alignment::Default),
1425            ffi::TWIG_ALIGN_LEFT => Some(Alignment::Left),
1426            ffi::TWIG_ALIGN_RIGHT => Some(Alignment::Right),
1427            ffi::TWIG_ALIGN_CENTER => Some(Alignment::Center),
1428            _ => None,
1429        }
1430    }
1431}
1432
1433/// The smart-punctuation kind for [`Builder::add_smart_punctuation`].
1434#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1435pub enum SmartPunctuation {
1436    LeftSingleQuote,
1437    RightSingleQuote,
1438    LeftDoubleQuote,
1439    RightDoubleQuote,
1440    Ellipses,
1441    EmDash,
1442    EnDash,
1443}
1444
1445impl SmartPunctuation {
1446    fn to_c(self) -> c_int {
1447        match self {
1448            SmartPunctuation::LeftSingleQuote => 0,
1449            SmartPunctuation::RightSingleQuote => 1,
1450            SmartPunctuation::LeftDoubleQuote => 2,
1451            SmartPunctuation::RightDoubleQuote => 3,
1452            SmartPunctuation::Ellipses => 4,
1453            SmartPunctuation::EmDash => 5,
1454            SmartPunctuation::EnDash => 6,
1455        }
1456    }
1457}
1458
1459/// The surface form of a generic directive for [`Builder::add_directive`].
1460#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1461pub enum DirectiveForm {
1462    Text,
1463    Leaf,
1464    Container,
1465}
1466
1467impl DirectiveForm {
1468    fn to_c(self) -> c_int {
1469        match self {
1470            DirectiveForm::Text => ffi::TWIG_DIRECTIVE_TEXT,
1471            DirectiveForm::Leaf => ffi::TWIG_DIRECTIVE_LEAF,
1472            DirectiveForm::Container => ffi::TWIG_DIRECTIVE_CONTAINER,
1473        }
1474    }
1475
1476    /// The inverse of [`DirectiveForm::to_c`]; `None` for
1477    /// [`ffi::TWIG_DIRECTIVE_NONE`] (the node isn't a directive) or any code
1478    /// this binding doesn't know.
1479    fn from_c(v: c_int) -> Option<Self> {
1480        match v {
1481            ffi::TWIG_DIRECTIVE_TEXT => Some(DirectiveForm::Text),
1482            ffi::TWIG_DIRECTIVE_LEAF => Some(DirectiveForm::Leaf),
1483            ffi::TWIG_DIRECTIVE_CONTAINER => Some(DirectiveForm::Container),
1484            _ => None,
1485        }
1486    }
1487}
1488
1489/// Decompose an optional string into `(ptr, len, has)` for the C ABI's
1490/// `(ptr, len, has_*)` optional-string triples. The pointer borrows `s` and is
1491/// only used within the same call.
1492fn opt_str(s: Option<&str>) -> (*const u8, usize, c_int) {
1493    match s {
1494        Some(x) => (x.as_ptr(), x.len(), 1),
1495        None => (std::ptr::null(), 0, 0),
1496    }
1497}
1498
1499/// Programmatic construction of a document — the write-path mirror of
1500/// [`Document::parse`]. Build the tree bottom-up (add children, then the
1501/// container, wiring them with [`Builder::set_children`]); every `add*` method
1502/// returns the new node's [`NodeId`]. Then render, serialize, query, or dump the
1503/// subtree rooted at any id, on demand, without consuming the builder. All input
1504/// strings are copied, so caller buffers need not outlive a call.
1505#[derive(Debug)]
1506pub struct Builder {
1507    raw: NonNull<ffi::TwigBuilder>,
1508}
1509
1510impl Builder {
1511    /// Create an empty builder.
1512    pub fn new() -> Result<Self, Error> {
1513        let mut raw = std::ptr::null_mut();
1514        let status = unsafe { ffi::twig_builder_create(&mut raw) };
1515        Error::from_status(status)?;
1516        let raw = NonNull::new(raw).ok_or(Error::Internal)?;
1517        Ok(Self { raw })
1518    }
1519
1520    /// Add a void-payload node (attach children later with
1521    /// [`Builder::set_children`]).
1522    pub fn add(&mut self, kind: VoidKind) -> Result<NodeId, Error> {
1523        self.emit(|b, out| unsafe { ffi::twig_builder_add(b, kind.to_c(), out) })
1524    }
1525
1526    /// Add a single-string-payload node (a `str`, code span, url, comment, …).
1527    pub fn add_text(&mut self, kind: TextKind, text: &str) -> Result<NodeId, Error> {
1528        self.emit(|b, out| unsafe { ffi::twig_builder_add_text(b, kind.to_c(), text.as_ptr(), text.len(), out) })
1529    }
1530
1531    /// Add a heading of the given level (attach its inline children afterward).
1532    pub fn add_heading(&mut self, level: u32) -> Result<NodeId, Error> {
1533        self.emit(|b, out| unsafe { ffi::twig_builder_add_heading(b, level, out) })
1534    }
1535
1536    /// Add a code block, with an optional info-string language.
1537    pub fn add_code_block(&mut self, lang: Option<&str>, text: &str) -> Result<NodeId, Error> {
1538        let (lp, ll, has) = opt_str(lang);
1539        self.emit(|b, out| unsafe { ffi::twig_builder_add_code_block(b, lp, ll, has, text.as_ptr(), text.len(), out) })
1540    }
1541
1542    /// Add a raw block targeting `format` (e.g. `"html"`).
1543    pub fn add_raw_block(&mut self, format: &str, text: &str) -> Result<NodeId, Error> {
1544        self.emit(|b, out| unsafe {
1545            ffi::twig_builder_add_raw_block(b, format.as_ptr(), format.len(), text.as_ptr(), text.len(), out)
1546        })
1547    }
1548
1549    /// Add a document-metadata block written in config language `lang`.
1550    pub fn add_metadata(&mut self, lang: &str, text: &str) -> Result<NodeId, Error> {
1551        self.emit(|b, out| unsafe {
1552            ffi::twig_builder_add_metadata(b, lang.as_ptr(), lang.len(), text.as_ptr(), text.len(), out)
1553        })
1554    }
1555
1556    /// Add a raw inline targeting `format`.
1557    pub fn add_raw_inline(&mut self, format: &str, text: &str) -> Result<NodeId, Error> {
1558        self.emit(|b, out| unsafe {
1559            ffi::twig_builder_add_raw_inline(b, format.as_ptr(), format.len(), text.as_ptr(), text.len(), out)
1560        })
1561    }
1562
1563    /// Add a smart-punctuation node standing for `text` (its source spelling).
1564    pub fn add_smart_punctuation(&mut self, kind: SmartPunctuation, text: &str) -> Result<NodeId, Error> {
1565        self.emit(|b, out| unsafe {
1566            ffi::twig_builder_add_smart_punctuation(b, kind.to_c(), text.as_ptr(), text.len(), out)
1567        })
1568    }
1569
1570    /// Add a link with an optional destination and/or reference label (attach
1571    /// the link text as children).
1572    pub fn add_link(&mut self, destination: Option<&str>, reference: Option<&str>) -> Result<NodeId, Error> {
1573        let (dp, dl, hd) = opt_str(destination);
1574        let (rp, rl, hr) = opt_str(reference);
1575        self.emit(|b, out| unsafe { ffi::twig_builder_add_link(b, dp, dl, hd, rp, rl, hr, out) })
1576    }
1577
1578    /// Add an image — like [`Builder::add_link`], but children are the alt text.
1579    pub fn add_image(&mut self, destination: Option<&str>, reference: Option<&str>) -> Result<NodeId, Error> {
1580        let (dp, dl, hd) = opt_str(destination);
1581        let (rp, rl, hr) = opt_str(reference);
1582        self.emit(|b, out| unsafe { ffi::twig_builder_add_image(b, dp, dl, hd, rp, rl, hr, out) })
1583    }
1584
1585    /// Add a generic directive of the given form and name.
1586    pub fn add_directive(&mut self, form: DirectiveForm, name: &str) -> Result<NodeId, Error> {
1587        self.emit(|b, out| unsafe { ffi::twig_builder_add_directive(b, form.to_c(), name.as_ptr(), name.len(), out) })
1588    }
1589
1590    /// Add a generic named element (the escape hatch for HTML/XML tags).
1591    pub fn add_element(&mut self, name: &str) -> Result<NodeId, Error> {
1592        self.emit(|b, out| unsafe { ffi::twig_builder_add_element(b, name.as_ptr(), name.len(), out) })
1593    }
1594
1595    /// Add an XML processing instruction (`<?target data?>`).
1596    pub fn add_processing_instruction(&mut self, target: &str, data: &str) -> Result<NodeId, Error> {
1597        self.emit(|b, out| unsafe {
1598            ffi::twig_builder_add_processing_instruction(b, target.as_ptr(), target.len(), data.as_ptr(), data.len(), out)
1599        })
1600    }
1601
1602    /// Add a footnote definition with the given label.
1603    pub fn add_footnote(&mut self, label: &str) -> Result<NodeId, Error> {
1604        self.emit(|b, out| unsafe { ffi::twig_builder_add_footnote(b, label.as_ptr(), label.len(), out) })
1605    }
1606
1607    /// Add a link/image reference definition (`label` → `destination`).
1608    pub fn add_reference(&mut self, label: &str, destination: &str) -> Result<NodeId, Error> {
1609        self.emit(|b, out| unsafe {
1610            ffi::twig_builder_add_reference(b, label.as_ptr(), label.len(), destination.as_ptr(), destination.len(), out)
1611        })
1612    }
1613
1614    /// Add a bullet list.
1615    pub fn add_bullet_list(&mut self, style: BulletStyle, tight: bool) -> Result<NodeId, Error> {
1616        self.emit(|b, out| unsafe { ffi::twig_builder_add_bullet_list(b, style.to_c(), tight as c_int, out) })
1617    }
1618
1619    /// Add an ordered list, with an optional explicit start number.
1620    pub fn add_ordered_list(
1621        &mut self,
1622        numbering: OrderedNumbering,
1623        delim: OrderedDelim,
1624        tight: bool,
1625        start: Option<u32>,
1626    ) -> Result<NodeId, Error> {
1627        let (start_val, has_start) = match start {
1628            Some(s) => (s, 1),
1629            None => (0, 0),
1630        };
1631        self.emit(|b, out| unsafe {
1632            ffi::twig_builder_add_ordered_list(b, numbering.to_c(), delim.to_c(), tight as c_int, start_val, has_start, out)
1633        })
1634    }
1635
1636    /// Add a task list.
1637    pub fn add_task_list(&mut self, tight: bool) -> Result<NodeId, Error> {
1638        self.emit(|b, out| unsafe { ffi::twig_builder_add_task_list(b, tight as c_int, out) })
1639    }
1640
1641    /// Add a task-list item with the given checkbox state.
1642    pub fn add_task_list_item(&mut self, checked: bool) -> Result<NodeId, Error> {
1643        self.emit(|b, out| unsafe { ffi::twig_builder_add_task_list_item(b, checked as c_int, out) })
1644    }
1645
1646    /// Add a table row (`head` marks a header row).
1647    pub fn add_row(&mut self, head: bool) -> Result<NodeId, Error> {
1648        self.emit(|b, out| unsafe { ffi::twig_builder_add_row(b, head as c_int, out) })
1649    }
1650
1651    /// Add a table cell (`head` marks a header cell).
1652    pub fn add_cell(&mut self, head: bool, alignment: Alignment) -> Result<NodeId, Error> {
1653        self.emit(|b, out| unsafe { ffi::twig_builder_add_cell(b, head as c_int, alignment.to_c(), out) })
1654    }
1655
1656    /// Set `parent`'s children to `children` (in order), replacing any it had.
1657    /// Each child id should appear in exactly one `set_children` call.
1658    pub fn set_children(&mut self, parent: NodeId, children: &[NodeId]) -> Result<(), Error> {
1659        let ids: Vec<u32> = children.iter().map(|n| n.0).collect();
1660        let status = unsafe { ffi::twig_builder_set_children(self.raw.as_ptr(), parent.0, ids.as_ptr(), ids.len()) };
1661        Error::from_status(status)
1662    }
1663
1664    /// Attach `{...}` attributes to `id` (`(key, Some(value))`, or
1665    /// `(key, None)` for a bare attribute), replacing any it had. An empty slice
1666    /// clears them.
1667    pub fn set_attrs(&mut self, id: NodeId, attrs: &[(&str, Option<&str>)]) -> Result<(), Error> {
1668        let kvs: Vec<ffi::TwigKeyVal> = attrs
1669            .iter()
1670            .map(|(k, v)| ffi::TwigKeyVal {
1671                key: k.as_ptr(),
1672                key_len: k.len(),
1673                value: v.map_or(std::ptr::null(), |s| s.as_ptr()),
1674                value_len: v.map_or(0, |s| s.len()),
1675            })
1676            .collect();
1677        let status = unsafe { ffi::twig_builder_set_attrs(self.raw.as_ptr(), id.0, kvs.as_ptr(), kvs.len()) };
1678        Error::from_status(status)
1679    }
1680
1681    /// Render the subtree rooted at `root` to HTML (generic whole-vocabulary
1682    /// printer — a built tree has no djot/Markdown side tables).
1683    pub fn render_html(&mut self, root: NodeId) -> Result<Vec<u8>, Error> {
1684        let raw = self.raw.as_ptr();
1685        collect_bytes(|ptr, len| unsafe { ffi::twig_builder_render_html(raw, root.0, ptr, len) })
1686    }
1687
1688    /// Serialize the subtree rooted at `root` to `format`'s source syntax.
1689    /// Returns [`Error::UnsupportedFormat`] when the target can't represent the
1690    /// built tree (e.g. semantic kinds into XML).
1691    pub fn serialize(&mut self, root: NodeId, format: Format) -> Result<Vec<u8>, Error> {
1692        let raw = self.raw.as_ptr();
1693        let ffi_format: ffi::TwigFormat = format.into();
1694        collect_bytes(|ptr, len| unsafe { ffi::twig_builder_serialize(raw, root.0, ffi_format as i32, ptr, len) })
1695    }
1696
1697    /// Encode the subtree rooted at `root` as pretty-printed JSON.
1698    pub fn ast_json(&mut self, root: NodeId) -> Result<Vec<u8>, Error> {
1699        let raw = self.raw.as_ptr();
1700        collect_bytes(|ptr, len| unsafe { ffi::twig_builder_ast_json(raw, root.0, ptr, len) })
1701    }
1702
1703    /// Resolve a selector against the subtree rooted at `root` (same grammar as
1704    /// [`Document::query`]).
1705    pub fn query(&mut self, root: NodeId, selector: &str) -> Result<Vec<QueryMatch>, Error> {
1706        let raw = self.raw.as_ptr();
1707        collect_matches(|ptr, len| unsafe {
1708            ffi::twig_builder_query(raw, root.0, selector.as_ptr(), selector.len(), ptr, len)
1709        })
1710    }
1711
1712    /// Shared plumbing for the `add*` constructors: run `call` (which writes the
1713    /// new node's id) and wrap the result.
1714    fn emit(
1715        &mut self,
1716        call: impl FnOnce(*mut ffi::TwigBuilder, *mut u32) -> ffi::TwigStatus,
1717    ) -> Result<NodeId, Error> {
1718        let mut id: u32 = 0;
1719        let status = call(self.raw.as_ptr(), &mut id);
1720        Error::from_status(status)?;
1721        Ok(NodeId(id))
1722    }
1723}
1724
1725impl Drop for Builder {
1726    fn drop(&mut self) {
1727        unsafe { ffi::twig_builder_destroy(self.raw.as_ptr()) }
1728    }
1729}
1730
1731#[cfg(test)]
1732mod tests {
1733    use super::*;
1734
1735    #[test]
1736    fn abi_version_matches() {
1737        // The linked library must speak the exact ABI layout this crate's
1738        // `#[repr(C)]` mirrors assume. If this fails, the Zig `TWIG_ABI_VERSION`
1739        // was bumped without updating `ffi::TWIG_ABI_VERSION` (and the mirrors).
1740        assert_eq!(abi_version(), ffi::TWIG_ABI_VERSION);
1741    }
1742
1743    #[test]
1744    fn parses_and_renders_markdown_html() {
1745        let mut doc = Document::parse_str("# hi\n", Format::Markdown).expect("parse markdown");
1746        let html = doc.render_html().expect("render html");
1747        assert_eq!(String::from_utf8_lossy(&html), "<h1>hi</h1>\n");
1748    }
1749
1750    #[test]
1751    fn parses_html_input() {
1752        let mut doc = Document::parse_str("<p>hi</p>", Format::Html).expect("parse html");
1753        let html = doc.render_html().expect("render html");
1754        assert!(String::from_utf8_lossy(&html).contains("hi"));
1755    }
1756
1757    #[test]
1758    fn serialize_round_trips_and_cross_converts() {
1759        let mut doc = Document::parse_str("# hi\n", Format::Markdown).expect("parse markdown");
1760
1761        let canonical = doc.serialize(Format::Markdown).expect("serialize markdown");
1762        assert!(String::from_utf8_lossy(&canonical).contains("# hi"));
1763
1764        // Cross-format Markdown -> XML has no serializer.
1765        assert_eq!(doc.serialize(Format::Xml), Err(Error::UnsupportedFormat));
1766    }
1767
1768    #[test]
1769    fn serialize_markdown_to_djot() {
1770        let mut doc =
1771            Document::parse_str("This is *markdown*.\n", Format::Markdown).expect("parse markdown");
1772        let djot = doc.serialize(Format::Djot).expect("serialize djot");
1773        assert!(String::from_utf8_lossy(&djot).contains("_markdown_"));
1774    }
1775
1776    #[test]
1777    fn ast_json_dumps_the_tree() {
1778        let mut doc = Document::parse_str("hello\n", Format::Djot).expect("parse djot");
1779        let json = doc.ast_json().expect("ast json");
1780        assert!(String::from_utf8_lossy(&json).contains("\"kind\": \"doc\""));
1781    }
1782
1783    #[test]
1784    fn query_finds_nodes_by_selector() {
1785        let source = "# One\n\n## Two\n";
1786        let mut doc = Document::parse_str(source, Format::Markdown).expect("parse markdown");
1787        let matches = doc.query("heading").expect("query");
1788
1789        assert_eq!(matches.len(), 2);
1790        for m in &matches {
1791            assert_eq!(m.kind, "heading");
1792            assert!(m.span.start < m.span.end);
1793        }
1794    }
1795
1796    #[test]
1797    fn query_recovers_code_spans() {
1798        let source = "prose `code` more prose\n";
1799        let mut doc = Document::parse_str(source, Format::Markdown).expect("parse markdown");
1800        let matches = doc.query("verbatim").expect("query");
1801
1802        assert_eq!(matches.len(), 1);
1803        assert_eq!(&source[matches[0].span.clone()], "`code`");
1804    }
1805
1806    #[test]
1807    fn query_rejects_a_malformed_selector() {
1808        let mut doc = Document::parse_str("hi\n", Format::Markdown).expect("parse markdown");
1809        assert_eq!(doc.query("list >"), Err(Error::InvalidArgument));
1810    }
1811
1812    #[test]
1813    fn editor_edits_by_index_path() {
1814        let mut ed = Editor::new_str("<a><b>hi</b></a>", Format::Xml).expect("editor");
1815        ed.replace_content("0.0", "bye").expect("replace_content");
1816        assert_eq!(ed.source_str().expect("source"), "<a><b>bye</b></a>");
1817    }
1818
1819    #[test]
1820    fn flat_nodes_expose_element_name_and_attrs() {
1821        // A `<picture>` with a theme-switching `<source>`: the dark alternative
1822        // lives only in the `<source>`'s attributes, which the snapshot now
1823        // surfaces (both `<picture>` and `<source>` report `kind == "element"`).
1824        let src = "<picture><source media=\"(prefers-color-scheme: dark)\" srcset=\"d.svg\"><img src=\"l.svg\" alt=\"x\"></picture>\n";
1825        let mut ed = Editor::new_ext(
1826            src.as_bytes(),
1827            Format::Markdown,
1828            MarkdownExtensions { html_elements: true, ..Default::default() },
1829        )
1830        .expect("editor");
1831        let nodes = ed.nodes().expect("nodes");
1832
1833        let source = nodes
1834            .iter()
1835            .find(|n| n.name.as_deref() == Some("source"))
1836            .expect("a <source> element node");
1837        assert_eq!(
1838            source.attrs,
1839            vec![
1840                ("media".to_string(), Some("(prefers-color-scheme: dark)".to_string())),
1841                ("srcset".to_string(), Some("d.svg".to_string())),
1842            ]
1843        );
1844
1845        // The `<img>` fallback stays an `image` node (no element name), and its
1846        // `src` is the ordinary `destination`.
1847        let img = nodes.iter().find(|n| n.kind == "image").expect("an image node");
1848        assert!(img.name.is_none());
1849        assert_eq!(img.destination.as_deref(), Some("l.svg"));
1850
1851        // A semantic node carries neither an element name nor attributes.
1852        let picture_kids_str = nodes.iter().find(|n| n.kind == "str");
1853        if let Some(s) = picture_kids_str {
1854            assert!(s.name.is_none() && s.attrs.is_empty());
1855        }
1856    }
1857
1858    #[test]
1859    fn flat_nodes_expose_directive_name_and_form() {
1860        // All three surface forms report `kind == "directive"`, so the snapshot
1861        // has to carry both halves of a directive's identity: which type it is
1862        // (`name`) and how it was written (`directive_form`). Without them a
1863        // renderer can't tell an `::embed` from a `::toc`, nor an inline span
1864        // from a standalone block.
1865        let src = ":::note{.warning}\nBody\n:::\n\n::embed{src=\"demo.html\"}\n\nSee :abbr[HTML]{title=\"HyperText\"} inline.\n";
1866        let mut ed = Editor::new_ext(
1867            src.as_bytes(),
1868            Format::Markdown,
1869            MarkdownExtensions { directives: true, ..Default::default() },
1870        )
1871        .expect("editor");
1872        let nodes = ed.nodes().expect("nodes");
1873
1874        let forms: Vec<(Option<&str>, Option<DirectiveForm>)> = nodes
1875            .iter()
1876            .filter(|n| n.kind == "directive")
1877            .map(|n| (n.name.as_deref(), n.directive_form))
1878            .collect();
1879        assert_eq!(
1880            forms,
1881            vec![
1882                (Some("note"), Some(DirectiveForm::Container)),
1883                (Some("embed"), Some(DirectiveForm::Leaf)),
1884                (Some("abbr"), Some(DirectiveForm::Text)),
1885            ]
1886        );
1887
1888        // The attributes still ride the ordinary side-table, and a non-directive
1889        // reports no form at all.
1890        let embed = nodes.iter().find(|n| n.name.as_deref() == Some("embed")).expect("embed");
1891        assert_eq!(embed.attrs, vec![("src".to_string(), Some("demo.html".to_string()))]);
1892        let para = nodes.iter().find(|n| n.kind == "para").expect("a para");
1893        assert!(para.directive_form.is_none() && para.name.is_none());
1894    }
1895
1896    #[test]
1897    fn editor_insert_child_and_delete() {
1898        let mut ed = Editor::new_str("<r><a/><c/></r>", Format::Xml).expect("editor");
1899        ed.insert_child("0", 1, "<b/>").expect("insert_child");
1900        assert_eq!(ed.source_str().expect("source"), "<r><a/><b/><c/></r>");
1901        ed.delete("0.1").expect("delete");
1902        assert_eq!(ed.source_str().expect("source"), "<r><a/><c/></r>");
1903    }
1904
1905    #[test]
1906    fn editor_edits_by_selector() {
1907        let mut ed = Editor::new_str("# One\n\n## Two\n", Format::Markdown).expect("editor");
1908        ed.replace("heading(\"Two\")", "## Renamed").expect("replace");
1909        assert_eq!(ed.source_str().expect("source"), "# One\n\n## Renamed\n");
1910    }
1911
1912    #[test]
1913    fn editor_locator_errors_are_distinct() {
1914        let mut ed = Editor::new_str("<r><a/><a/></r>", Format::Xml).expect("editor");
1915        assert_eq!(ed.replace("0.9", "x"), Err(Error::NotFound));
1916        assert_eq!(ed.replace("element", "x"), Err(Error::Ambiguous));
1917        assert_eq!(ed.replace("element(", "x"), Err(Error::InvalidArgument));
1918        // Untouched by the failed edits.
1919        assert_eq!(ed.source_str().expect("source"), "<r><a/><a/></r>");
1920    }
1921
1922    #[test]
1923    fn editor_reparse_break_rolls_back() {
1924        let mut ed = Editor::new_str("<a>ok</a>", Format::Xml).expect("editor");
1925        assert_eq!(ed.replace_content("0", "<b>"), Err(Error::EditConflict));
1926        assert_eq!(ed.source_str().expect("source"), "<a>ok</a>");
1927    }
1928
1929    #[test]
1930    fn editor_leaf_content_is_not_editable() {
1931        let mut ed = Editor::new_str("<a>hi</a>", Format::Xml).expect("editor");
1932        assert_eq!(ed.replace_content("0.0", "x"), Err(Error::NotEditable));
1933    }
1934
1935    #[test]
1936    fn editor_query_reflects_current_tree() {
1937        let mut ed = Editor::new_str("<r><a/></r>", Format::Xml).expect("editor");
1938        ed.insert_child("0", 1, "<b/>").expect("insert_child");
1939        // Root <r> plus <a/> and <b/>.
1940        assert_eq!(ed.query("element").expect("query").len(), 3);
1941        let json = ed.ast_json().expect("ast_json");
1942        assert!(String::from_utf8_lossy(&json).contains("\"kind\": \"doc\""));
1943    }
1944
1945    // ── offset-addressed editing & read-back (P0–P3) ────────────────────────
1946
1947    #[test]
1948    fn editor_edit_range_types_backspaces_and_reports_change() {
1949        let mut ed = Editor::new_str("ab\n", Format::Markdown).expect("editor");
1950
1951        // Type "X" at offset 1 (a zero-width splice = an insertion).
1952        let c = ed.edit_range(1, 1, "X").expect("edit_range insert");
1953        assert_eq!(ed.source_str().unwrap(), "aXb\n");
1954        assert_eq!(c.old, 1..1);
1955        assert_eq!(c.new, 1..2);
1956        assert_eq!(c.delta(), 1);
1957
1958        // Backspace it (delete the "X").
1959        let c2 = ed.edit_range(1, 2, "").expect("edit_range delete");
1960        assert_eq!(ed.source_str().unwrap(), "ab\n");
1961        assert_eq!(c2.old, 1..2);
1962        assert_eq!(c2.new, 1..1);
1963        assert_eq!(c2.delta(), -1);
1964    }
1965
1966    #[test]
1967    fn editor_edit_range_rejects_bad_ranges() {
1968        let mut ed = Editor::new_str("hi\n", Format::Markdown).expect("editor");
1969        assert_eq!(ed.edit_range(0, 99, "x"), Err(Error::InvalidArgument)); // end past len
1970        assert_eq!(ed.edit_range(2, 1, "x"), Err(Error::InvalidArgument)); // start > end
1971        assert_eq!(ed.source_str().unwrap(), "hi\n"); // untouched
1972    }
1973
1974    #[test]
1975    fn editor_last_change_reports_locator_ops_too() {
1976        let mut ed = Editor::new_str("# One\n\n## Two\n", Format::Markdown).expect("editor");
1977        assert_eq!(ed.last_change(), None); // nothing edited yet
1978
1979        ed.replace("heading(\"Two\")", "## Renamed").expect("replace");
1980        assert_eq!(ed.source_str().unwrap(), "# One\n\n## Renamed\n");
1981        let c = ed.last_change().expect("a change was recorded");
1982        // "## Two" occupied [7,13); "## Renamed" (10 bytes) now occupies [7,17).
1983        assert_eq!(c.old, 7..13);
1984        assert_eq!(c.new, 7..17);
1985    }
1986
1987    #[test]
1988    fn editor_nodes_is_a_walkable_flat_tree() {
1989        let mut ed = Editor::new_str("# Hi\n\ntext\n", Format::Markdown).expect("editor");
1990        let nodes = ed.nodes().expect("nodes");
1991        assert!(!nodes.is_empty());
1992
1993        // Dense, index-aligned ids.
1994        for (i, n) in nodes.iter().enumerate() {
1995            assert_eq!(n.id, NodeId(i as u32));
1996        }
1997        // Exactly one root (no parent), and it's the doc.
1998        let roots: Vec<_> = nodes.iter().filter(|n| n.parent.is_none()).collect();
1999        assert_eq!(roots.len(), 1);
2000        assert_eq!(roots[0].kind, "doc");
2001
2002        // The heading carries its level; the "Hi" text is reachable as a payload.
2003        let heading = nodes.iter().find(|n| n.kind == "heading").expect("a heading");
2004        assert_eq!(heading.level, Some(1));
2005        assert!(nodes.iter().any(|n| n.text.as_deref() == Some("Hi")));
2006
2007        // A kind with no row/cell payload reports neither.
2008        assert_eq!(heading.head, None);
2009        assert_eq!(heading.alignment, None);
2010
2011        // Every non-root node's parent links back to a node that lists it as a
2012        // child (via first_child/next_sibling).
2013        for n in nodes.iter().filter(|n| n.parent.is_some()) {
2014            let p = &nodes[n.parent.unwrap().0 as usize];
2015            let mut kid = p.first_child;
2016            let mut seen = false;
2017            while let Some(NodeId(k)) = kid {
2018                if k == n.id.0 {
2019                    seen = true;
2020                    break;
2021                }
2022                kid = nodes[k as usize].next_sibling;
2023            }
2024            assert!(seen, "node {:?} not found among its parent's children", n.id);
2025        }
2026    }
2027
2028    #[test]
2029    fn editor_child_spans_and_subtree_agree_with_nodes() {
2030        let src = "# Title\n\nHello **world** and more.\n\n- one\n- two\n";
2031        let mut ed = Editor::new_str(src, Format::Markdown).expect("editor");
2032        let all = ed.nodes().expect("nodes");
2033        let doc = all.iter().find(|n| n.kind == "doc").expect("doc");
2034
2035        // child_spans(None) == the doc root's children, same ids/kinds/spans and
2036        // in the same order.
2037        let top = ed.child_spans(None).expect("child_spans");
2038        let mut want = Vec::new();
2039        let mut c = doc.first_child;
2040        while let Some(id) = c {
2041            want.push(id);
2042            c = all[id.0 as usize].next_sibling;
2043        }
2044        assert_eq!(top.len(), want.len(), "top-level count");
2045        for (m, id) in top.iter().zip(&want) {
2046            assert_eq!(m.node_id, id.0, "child id");
2047            assert_eq!(m.kind, all[id.0 as usize].kind, "child kind");
2048            assert_eq!(m.span, all[id.0 as usize].span, "child span");
2049        }
2050        // The span addresses the block as written (absolute offsets).
2051        assert!(src[top[0].span.clone()].starts_with('#'), "first block is the heading");
2052
2053        // child_spans works below the top level too.
2054        let list = top.iter().find(|m| m.kind.ends_with("list")).expect("a list");
2055        let items = ed.child_spans(Some(NodeId(list.node_id))).expect("items");
2056        assert_eq!(items.len(), 2);
2057        assert!(items.iter().all(|m| m.kind == "list_item"), "items: {items:?}");
2058
2059        // subtree(para) is self-contained, local-indexed, and spans stay absolute.
2060        let para = top.iter().find(|m| m.kind == "para").expect("a para").node_id;
2061        let sub = ed.subtree(NodeId(para)).expect("subtree");
2062        assert_eq!(sub[0].id, NodeId(0), "root is local id 0");
2063        assert_eq!(sub[0].parent, None, "root has no parent inside the subtree");
2064        assert_eq!(sub[0].next_sibling, None, "root's sibling is severed");
2065        assert_eq!(sub[0].kind, "para");
2066        for (i, n) in sub.iter().enumerate() {
2067            assert_eq!(n.id, NodeId(i as u32), "dense local ids");
2068            for link in [n.parent, n.first_child, n.next_sibling].into_iter().flatten() {
2069                assert!((link.0 as usize) < sub.len(), "link {link:?} escapes the subtree");
2070            }
2071        }
2072        assert!(
2073            src[sub[0].span.clone()].starts_with("Hello"),
2074            "absolute span: {:?}",
2075            &src[sub[0].span.clone()]
2076        );
2077
2078        // Same multiset of node kinds as the paragraph's arena subtree.
2079        fn arena_kinds(all: &[FlatNode], root: NodeId) -> Vec<String> {
2080            let mut out = Vec::new();
2081            let mut stack = vec![root];
2082            while let Some(id) = stack.pop() {
2083                let n = &all[id.0 as usize];
2084                out.push(n.kind.clone());
2085                let mut c = n.first_child;
2086                while let Some(cid) = c {
2087                    stack.push(cid);
2088                    c = all[cid.0 as usize].next_sibling;
2089                }
2090            }
2091            out
2092        }
2093        let mut want_kinds = arena_kinds(&all, NodeId(para));
2094        let mut got_kinds: Vec<String> = sub.iter().map(|n| n.kind.clone()).collect();
2095        want_kinds.sort();
2096        got_kinds.sort();
2097        assert_eq!(got_kinds, want_kinds, "subtree kinds match the arena");
2098
2099        // Out-of-range id is rejected.
2100        assert!(matches!(ed.subtree(NodeId(9999)), Err(Error::InvalidArgument)));
2101    }
2102
2103    #[test]
2104    fn flat_nodes_carry_table_head_and_alignment() {
2105        // The delimiter row (`|:-----|----:|`) is consumed by the parser and has
2106        // no node of its own, so `alignment` on the cells is the only way a
2107        // consumer can recover the column alignment from a snapshot.
2108        let src = "| Name | Qty |\n|:-----|----:|\n| Pear | 3 |\n";
2109        let mut ed = Editor::new_str(src, Format::Markdown).expect("editor");
2110        let nodes = ed.nodes().expect("nodes");
2111
2112        let rows: Vec<_> = nodes.iter().filter(|n| n.kind == "row").collect();
2113        assert_eq!(rows.len(), 2, "a header row and one body row");
2114        assert_eq!(rows[0].head, Some(true), "first row is the header");
2115        assert_eq!(rows[1].head, Some(false), "second row is a body row");
2116
2117        let cells: Vec<_> = nodes.iter().filter(|n| n.kind == "cell").collect();
2118        assert_eq!(cells.len(), 4);
2119        // Alignment comes from the delimiter row and applies down the column.
2120        assert_eq!(cells[0].alignment, Some(Alignment::Left));
2121        assert_eq!(cells[1].alignment, Some(Alignment::Right));
2122        assert_eq!(cells[2].alignment, Some(Alignment::Left));
2123        assert_eq!(cells[3].alignment, Some(Alignment::Right));
2124        // Header cells are flagged too, not just their row.
2125        assert_eq!(cells[0].head, Some(true));
2126        assert_eq!(cells[2].head, Some(false));
2127
2128        // A table with no alignment spelled out reports Default — a real value,
2129        // distinct from the None a non-cell reports.
2130        let mut plain = Editor::new_str("| A |\n| --- |\n| b |\n", Format::Markdown).expect("editor");
2131        let pnodes = plain.nodes().expect("nodes");
2132        let pcell = pnodes.iter().find(|n| n.kind == "cell").expect("a cell");
2133        assert_eq!(pcell.alignment, Some(Alignment::Default));
2134    }
2135
2136    #[test]
2137    fn editor_node_at_and_ancestors_hit_test_offsets() {
2138        let mut ed = Editor::new_str("# Hi\n\ntext\n", Format::Markdown).expect("editor");
2139
2140        // Offset 2 is the "H" of the heading "# Hi" [0,4).
2141        let m = ed.node_at(2).expect("node_at").expect("a node covers offset 2");
2142        assert!(m.span.contains(&2));
2143
2144        // The ancestor chain is root-first and ends at the deepest (== node_at).
2145        let chain = ed.ancestors_at(2).expect("ancestors_at");
2146        assert!(!chain.is_empty());
2147        assert_eq!(chain[0].kind, "doc");
2148        assert_eq!(chain.last().unwrap().node_id, m.node_id);
2149
2150        // An out-of-range offset is an error; a gap covers nothing deeper than doc.
2151        assert_eq!(ed.node_at(999), Err(Error::InvalidArgument));
2152    }
2153
2154    // ── range-oriented rich-text ops (P5) ───────────────────────────────────
2155
2156    #[test]
2157    fn editor_wrap_and_toggle_inline_round_trip() {
2158        let mut ed = Editor::new_str("a word b\n", Format::Markdown).expect("editor");
2159
2160        // Bold "word" [2,6); the Change reports the new "**word**" region.
2161        let c = ed.wrap_range(2, 6, InlineKind::Strong).expect("wrap");
2162        assert_eq!(ed.source_str().unwrap(), "a **word** b\n");
2163        assert_eq!(&ed.source_str().unwrap()[c.new.clone()], "**word**");
2164
2165        // Toggle it off by selecting the strong node's interior [4,8).
2166        ed.toggle_inline(4, 8, InlineKind::Strong).expect("toggle off");
2167        assert_eq!(ed.source_str().unwrap(), "a word b\n");
2168
2169        // Toggle emphasis on when the range isn't already marked.
2170        ed.toggle_inline(2, 6, InlineKind::Emph).expect("toggle on");
2171        assert_eq!(ed.source_str().unwrap(), "a *word* b\n");
2172    }
2173
2174    #[test]
2175    fn editor_inline_kind_support_is_format_specific() {
2176        // Markdown has no highlight/mark spelling.
2177        let mut md = Editor::new_str("a word b\n", Format::Markdown).expect("editor");
2178        assert_eq!(md.wrap_range(2, 6, InlineKind::Mark), Err(Error::UnsupportedFormat));
2179
2180        // Djot spells it {=…=}.
2181        let mut dj = Editor::new_str("a word b\n", Format::Djot).expect("editor");
2182        dj.wrap_range(2, 6, InlineKind::Mark).expect("djot mark");
2183        assert_eq!(dj.source_str().unwrap(), "a {=word=} b\n");
2184    }
2185
2186    #[test]
2187    fn editor_toggle_strips_verbatim_via_content_span() {
2188        let mut ed = Editor::new_str("a `code` b\n", Format::Markdown).expect("editor");
2189        // The verbatim node [2,8) reports content_span [3,7); toggle peels it.
2190        ed.toggle_inline(2, 8, InlineKind::Verbatim).expect("toggle code off");
2191        assert_eq!(ed.source_str().unwrap(), "a code b\n");
2192
2193        // A multi-backtick span peels BOTH runs via content_span, not by
2194        // stripping a single delimiter (which would corrupt it to "`x`").
2195        let mut ed2 = Editor::new_str("a ``x`` b\n", Format::Markdown).expect("editor");
2196        ed2.toggle_inline(2, 7, InlineKind::Verbatim).expect("toggle multi off");
2197        assert_eq!(ed2.source_str().unwrap(), "a x b\n");
2198    }
2199
2200    #[test]
2201    fn editor_set_block_switches_para_and_heading_levels() {
2202        let mut ed = Editor::new_str("Title\n\nbody text\n", Format::Markdown).expect("editor");
2203
2204        // Paragraph -> H2 (offset 0 is inside "Title").
2205        ed.set_block(0, BlockKind::Heading(2)).expect("to h2");
2206        assert_eq!(ed.source_str().unwrap(), "## Title\n\nbody text\n");
2207
2208        // H2 -> H1 (offset now inside "## Title").
2209        ed.set_block(3, BlockKind::Heading(1)).expect("to h1");
2210        assert_eq!(ed.source_str().unwrap(), "# Title\n\nbody text\n");
2211
2212        // Heading -> paragraph, dropping the marker.
2213        ed.set_block(2, BlockKind::Paragraph).expect("to para");
2214        assert_eq!(ed.source_str().unwrap(), "Title\n\nbody text\n");
2215    }
2216
2217    #[test]
2218    fn editor_set_block_rejects_bad_level_and_format() {
2219        let mut md = Editor::new_str("hi\n", Format::Markdown).expect("editor");
2220        assert_eq!(md.set_block(0, BlockKind::Heading(9)), Err(Error::InvalidArgument));
2221
2222        let mut xml = Editor::new_str("<a>hi</a>", Format::Xml).expect("editor");
2223        assert_eq!(xml.set_block(1, BlockKind::Heading(1)), Err(Error::UnsupportedFormat));
2224    }
2225
2226    #[test]
2227    fn editor_toggle_block_container_round_trips() {
2228        let mut ed = Editor::new_str("a\n", Format::Djot).expect("editor");
2229
2230        let c = ed
2231            .toggle_block_container(0, 1, BlockContainerKind::BlockQuote)
2232            .expect("quote on");
2233        assert_eq!(ed.source_str().unwrap(), "> a\n");
2234        assert_eq!(&ed.source_str().unwrap()[c.new.clone()], "> a\n");
2235
2236        ed.toggle_block_container(2, 3, BlockContainerKind::BlockQuote)
2237            .expect("quote off");
2238        assert_eq!(ed.source_str().unwrap(), "a\n");
2239    }
2240
2241    #[test]
2242    fn editor_toggle_block_container_nests_a_partial_selection() {
2243        let mut ed = Editor::new_str("> a\n>\n> b\n", Format::Djot).expect("editor");
2244
2245        // Only the first paragraph is covered, so the quote is not fully
2246        // selected: nest rather than drag `b` out of the quote too.
2247        ed.toggle_block_container(2, 3, BlockContainerKind::BlockQuote)
2248            .expect("nest");
2249        assert_eq!(ed.source_str().unwrap(), "> > a\n>\n> b\n");
2250
2251        // Peel the inner level back off, leaving the outer quote intact.
2252        ed.toggle_block_container(4, 5, BlockContainerKind::BlockQuote)
2253            .expect("peel");
2254        assert_eq!(ed.source_str().unwrap(), "> a\n>\n> b\n");
2255    }
2256
2257    #[test]
2258    fn editor_toggle_block_container_numbers_and_converts_lists() {
2259        let mut ed = Editor::new_str("a\n\nb\n", Format::Djot).expect("editor");
2260
2261        // Each covered block becomes its own numbered item.
2262        ed.toggle_block_container(0, 4, BlockContainerKind::OrderedList)
2263            .expect("ordered on");
2264        assert_eq!(ed.source_str().unwrap(), "1. a\n\n2. b\n");
2265
2266        // The other list kind converts in place instead of nesting.
2267        ed.toggle_block_container(3, 9, BlockContainerKind::BulletList)
2268            .expect("convert");
2269        assert_eq!(ed.source_str().unwrap(), "- a\n\n- b\n");
2270    }
2271
2272    #[test]
2273    fn editor_toggle_block_container_rejects_unspellable_format() {
2274        let mut xml = Editor::new_str("<a>hi</a>", Format::Xml).expect("editor");
2275        assert_eq!(
2276            xml.toggle_block_container(3, 5, BlockContainerKind::BlockQuote),
2277            Err(Error::UnsupportedFormat)
2278        );
2279    }
2280
2281    #[test]
2282    fn editor_insert_link_wraps_and_repoints() {
2283        let mut ed = Editor::new_str("a word b\n", Format::Djot).expect("editor");
2284
2285        ed.insert_link(2, 6, "http://x.dev").expect("link");
2286        assert_eq!(ed.source_str().unwrap(), "a [word](http://x.dev) b\n");
2287
2288        // A caret inside the existing link re-points it rather than nesting.
2289        ed.insert_link(3, 7, "http://y.dev").expect("re-point");
2290        assert_eq!(ed.source_str().unwrap(), "a [word](http://y.dev) b\n");
2291    }
2292
2293    #[test]
2294    fn editor_insert_link_repoints_an_autolink() {
2295        // The regression: an autolink is a `url`/`email` node whose text IS its
2296        // destination. Read as ordinary text, a caret inside it spliced a whole
2297        // new link into the middle of the old URL —
2298        // `see <https<https://y.dev>://x.dev> ok`.
2299        for format in [Format::Markdown, Format::Djot] {
2300            let mut ed = Editor::new_str("see <https://x.dev> ok\n", format).expect("editor");
2301            ed.insert_link(10, 10, "https://y.dev").expect("re-point");
2302            assert_eq!(ed.source_str().unwrap(), "see <https://y.dev> ok\n");
2303
2304            // Source that looks right can still parse wrong: assert the reparse.
2305            let nodes = ed.nodes().expect("nodes");
2306            let url = nodes
2307                .iter()
2308                .find(|n| n.kind == "url")
2309                .expect("still an autolink");
2310            assert_eq!(url.text.as_deref(), Some("https://y.dev"));
2311            assert!(!nodes.iter().any(|n| n.kind == "link"));
2312        }
2313    }
2314
2315    #[test]
2316    fn editor_insert_link_escapes_the_destination() {
2317        // Unescaped, the `)` would close the link early and spill `b` into the
2318        // paragraph as literal text.
2319        let mut dj = Editor::new_str("w\n", Format::Djot).expect("editor");
2320        dj.insert_link(0, 1, "a)b").expect("link");
2321        assert_eq!(dj.source_str().unwrap(), "[w](a\\)b)\n");
2322
2323        // Whitespace is where the formats part ways: Markdown needs the angle
2324        // form (a bare space ends the destination and kills the link outright),
2325        // Djot must NOT use it (it would link to the literal text `<a b>`).
2326        let mut md = Editor::new_str("w\n", Format::Markdown).expect("editor");
2327        md.insert_link(0, 1, "a b").expect("link");
2328        assert_eq!(md.source_str().unwrap(), "[w](<a b>)\n");
2329
2330        let mut dj2 = Editor::new_str("w\n", Format::Djot).expect("editor");
2331        dj2.insert_link(0, 1, "a b").expect("link");
2332        assert_eq!(dj2.source_str().unwrap(), "[w](a b)\n");
2333    }
2334
2335    #[test]
2336    fn editor_insert_image_escapes_the_destination_per_format() {
2337        // The whole point of the op: a caller's `![](my cat.png)` is not an image
2338        // in Markdown, and the correct repair differs by format.
2339        let mut md = Editor::new_str("w\n", Format::Markdown).expect("editor");
2340        md.insert_image(0, 1, "my cat.png").expect("image");
2341        assert_eq!(md.source_str().unwrap(), "![w](<my cat.png>)\n");
2342
2343        let mut dj = Editor::new_str("w\n", Format::Djot).expect("editor");
2344        dj.insert_image(0, 1, "my cat.png").expect("image");
2345        assert_eq!(dj.source_str().unwrap(), "![w](my cat.png)\n");
2346
2347        // A `)` would close the image early and spill the rest as literal text.
2348        let mut paren = Editor::new_str("w\n", Format::Djot).expect("editor");
2349        paren.insert_image(0, 1, "a)b.png").expect("image");
2350        assert_eq!(paren.source_str().unwrap(), "![w](a\\)b.png)\n");
2351    }
2352
2353    #[test]
2354    fn editor_insert_image_keeps_an_empty_alt_empty() {
2355        // Unlike a link, where an empty range spells an autolink or doubles the
2356        // destination as text — an image with no alt is ordinary.
2357        let mut ed = Editor::new_str("ab\n", Format::Markdown).expect("editor");
2358        ed.insert_image(1, 1, "cat.png").expect("image");
2359        assert_eq!(ed.source_str().unwrap(), "a![](cat.png)b\n");
2360    }
2361
2362    #[test]
2363    fn editor_insert_image_rejects_a_newline_destination() {
2364        let mut ed = Editor::new_str("w\n", Format::Djot).expect("editor");
2365        assert_eq!(ed.insert_image(0, 1, "a\nb.png"), Err(Error::InvalidArgument));
2366
2367        let mut xml = Editor::new_str("<a>hi</a>", Format::Xml).expect("editor");
2368        assert_eq!(xml.insert_image(3, 5, "x.png"), Err(Error::UnsupportedFormat));
2369    }
2370
2371    #[test]
2372    fn editor_insert_link_rejects_a_newline_destination() {
2373        let mut ed = Editor::new_str("w\n", Format::Djot).expect("editor");
2374        assert_eq!(ed.insert_link(0, 1, "a\nb"), Err(Error::InvalidArgument));
2375
2376        let mut xml = Editor::new_str("<a>hi</a>", Format::Xml).expect("editor");
2377        assert_eq!(xml.insert_link(3, 5, "u"), Err(Error::UnsupportedFormat));
2378    }
2379
2380    #[test]
2381    fn editor_insert_literal_keeps_typed_specials_literal() {
2382        for format in [Format::Markdown, Format::Djot] {
2383            let mut ed = Editor::new_str("z\n", format).expect("editor");
2384            // A `*` at a line start would open emphasis unescaped.
2385            ed.insert_literal(0, "*hi*").expect("literal");
2386
2387            // Source that looks right can still parse wrong: assert the reparse.
2388            let nodes = ed.nodes().expect("nodes");
2389            assert!(!nodes.iter().any(|n| n.kind == "emph" || n.kind == "strong"));
2390            let text: String = nodes
2391                .iter()
2392                .filter(|n| n.kind == "str")
2393                .filter_map(|n| n.text.clone())
2394                .collect();
2395            assert_eq!(text, "*hi*z");
2396        }
2397    }
2398
2399    #[test]
2400    fn editor_insert_literal_escapes_block_markers_only_at_line_start() {
2401        // Mid-line, a `#` opens nothing and is left as typed.
2402        let mut ed = Editor::new_str("az\n", Format::Markdown).expect("editor");
2403        ed.insert_literal(1, "# ").expect("literal");
2404        assert_eq!(ed.source_str().unwrap(), "a# z\n");
2405
2406        // At a line start it would open a heading, so it is escaped.
2407        let mut ed2 = Editor::new_str("z\n", Format::Markdown).expect("editor");
2408        ed2.insert_literal(0, "# ").expect("literal");
2409        assert_eq!(ed2.source_str().unwrap(), "\\# z\n");
2410        assert!(!ed2.nodes().expect("nodes").iter().any(|n| n.kind == "heading"));
2411    }
2412
2413    #[test]
2414    fn editor_insert_literal_rejects_bad_offset_and_parse_only_format() {
2415        let mut ed = Editor::new_str("ab\n", Format::Markdown).expect("editor");
2416        assert_eq!(ed.insert_literal(99, "x"), Err(Error::InvalidArgument));
2417
2418        let mut xml = Editor::new_str("<a>hi</a>", Format::Xml).expect("editor");
2419        assert_eq!(xml.insert_literal(3, "x"), Err(Error::UnsupportedFormat));
2420    }
2421
2422    #[test]
2423    fn editor_insert_line_break_splices_in_cell_br() {
2424        let mut ed =
2425            Editor::new_str("| a | b |\n| --- | --- |\n", Format::Markdown).expect("editor");
2426        // Caret just after `a` in the header cell.
2427        ed.insert_line_break(3).expect("line break");
2428        assert_eq!(ed.source_str().unwrap(), "| a<br> | b |\n| --- | --- |\n");
2429        // The break reads back as a semantic node, not raw HTML.
2430        let nodes = ed.nodes().expect("nodes");
2431        assert!(nodes.iter().any(|n| n.kind == "hard_break"));
2432        assert!(!nodes.iter().any(|n| n.kind == "raw_inline"));
2433    }
2434
2435    #[test]
2436    fn editor_insert_line_break_rejects_off_cell_off_format_and_bad_offset() {
2437        // Not inside a cell → NotFound.
2438        let mut para = Editor::new_str("just text\n", Format::Markdown).expect("editor");
2439        assert_eq!(para.insert_line_break(3), Err(Error::NotFound));
2440
2441        // Djot has no in-cell break spelling → UnsupportedFormat.
2442        let mut dj =
2443            Editor::new_str("| a | b |\n| --- | --- |\n", Format::Djot).expect("editor");
2444        assert_eq!(dj.insert_line_break(3), Err(Error::UnsupportedFormat));
2445
2446        // Out-of-range offset → InvalidArgument.
2447        let mut ed =
2448            Editor::new_str("| a | b |\n| --- | --- |\n", Format::Markdown).expect("editor");
2449        assert_eq!(ed.insert_line_break(9999), Err(Error::InvalidArgument));
2450    }
2451
2452    #[test]
2453    fn editor_undo_redo_round_trip() {
2454        let mut ed = Editor::new_str("hello\n", Format::Markdown).expect("editor");
2455        ed.edit_range(5, 5, "!").expect("edit");
2456        assert_eq!(ed.source_str().unwrap(), "hello!\n");
2457
2458        let change = ed.undo().expect("undo ok").expect("something to undo");
2459        assert_eq!(ed.source_str().unwrap(), "hello\n");
2460        assert_eq!(change.new.end, 5);
2461        assert!(ed.undo().expect("undo ok").is_none(), "history exhausted");
2462
2463        ed.redo().expect("redo ok").expect("something to redo");
2464        assert_eq!(ed.source_str().unwrap(), "hello!\n");
2465    }
2466
2467    #[test]
2468    fn editor_coalesce_folds_a_run() {
2469        let mut ed = Editor::new_str("\n", Format::Markdown).expect("editor");
2470        ed.edit_range(0, 0, "a").expect("edit");
2471        ed.edit_range(1, 1, "b").expect("edit");
2472        ed.coalesce_last_undo().expect("coalesce");
2473        assert_eq!(ed.source_str().unwrap(), "ab\n");
2474        // One undo removes the whole coalesced run.
2475        ed.undo().expect("undo ok").expect("something to undo");
2476        assert_eq!(ed.source_str().unwrap(), "\n");
2477        assert!(ed.undo().expect("undo ok").is_none());
2478    }
2479
2480    #[test]
2481    fn editor_revision_bumps_per_successful_mutation() {
2482        let mut ed = Editor::new_str("x\n", Format::Markdown).expect("editor");
2483        assert_eq!(ed.revision(), 0);
2484        ed.edit_range(1, 1, "y").expect("edit");
2485        assert_eq!(ed.revision(), 1);
2486
2487        // A reparse-breaking edit is rolled back and must not bump the revision.
2488        let mut xml = Editor::new_str("<a>ok</a>", Format::Xml).expect("editor");
2489        assert_eq!(xml.revision(), 0);
2490        assert!(xml.replace_content("0", "<b>").is_err());
2491        assert_eq!(xml.revision(), 0);
2492
2493        // undo and redo are mutations too.
2494        ed.undo().expect("undo ok").expect("something to undo");
2495        assert_eq!(ed.revision(), 2);
2496        ed.redo().expect("redo ok").expect("something to redo");
2497        assert_eq!(ed.revision(), 3);
2498    }
2499
2500    #[test]
2501    fn editor_dirty_range_tracks_and_clears() {
2502        let mut ed = Editor::new_str("abcdefgh\n", Format::Markdown).expect("editor");
2503        // Clean to start.
2504        assert_eq!(ed.dirty_range(), None);
2505
2506        // One insertion of two bytes at offset 2 dirties exactly [2, 4).
2507        ed.edit_range(2, 2, "XY").expect("edit");
2508        assert_eq!(ed.dirty_range(), Some(2..4));
2509
2510        // A second, disjoint edit near the end accumulates conservatively: the
2511        // reported range is a superset covering both edits.
2512        ed.edit_range(9, 9, "Z").expect("edit"); // source is now "abXYcdefgZh\n"
2513        let d = ed.dirty_range().expect("dirty");
2514        assert!(d.start <= 2 && d.end >= 10, "range {d:?} must cover both edits");
2515
2516        // clear_dirty acknowledges without moving the revision.
2517        let rev = ed.revision();
2518        ed.clear_dirty();
2519        assert_eq!(ed.dirty_range(), None);
2520        assert_eq!(ed.revision(), rev);
2521
2522        // Post-clear, only new mutations show up — and undo counts as one.
2523        ed.undo().expect("undo ok").expect("something to undo");
2524        assert!(ed.dirty_range().is_some());
2525    }
2526
2527    #[test]
2528    fn editor_caret_blob_follows_undo_and_redo() {
2529        let mut ed = Editor::new_str("hello\n", Format::Markdown).expect("editor");
2530        assert!(ed.caret_blob().unwrap().is_empty());
2531
2532        // Set the pre-edit caret, then edit: the retired undo step captures it.
2533        ed.set_caret_blob(b"before").expect("set caret");
2534        ed.edit_range(5, 5, "!").expect("edit");
2535        // A fresh state starts caret-less until the host sets one.
2536        assert!(ed.caret_blob().unwrap().is_empty());
2537        ed.set_caret_blob(b"after").expect("set caret");
2538
2539        // Undo restores the pre-edit source AND the pre-edit caret.
2540        ed.undo().expect("undo ok").expect("something to undo");
2541        assert_eq!(ed.source_str().unwrap(), "hello\n");
2542        assert_eq!(ed.caret_blob().unwrap(), b"before");
2543
2544        // Redo restores the post-edit source AND the post-edit caret.
2545        ed.redo().expect("redo ok").expect("something to redo");
2546        assert_eq!(ed.source_str().unwrap(), "hello!\n");
2547        assert_eq!(ed.caret_blob().unwrap(), b"after");
2548    }
2549
2550    #[test]
2551    fn editor_coalesced_run_keeps_the_pre_run_caret() {
2552        let mut ed = Editor::new_str("\n", Format::Markdown).expect("editor");
2553        ed.set_caret_blob(b"c0").expect("set caret");
2554        ed.edit_range(0, 0, "a").expect("edit");
2555        ed.set_caret_blob(b"c1").expect("set caret");
2556        ed.edit_range(1, 1, "b").expect("edit");
2557        ed.coalesce_last_undo().expect("coalesce");
2558        ed.set_caret_blob(b"c2").expect("set caret");
2559
2560        // One undo folds the run and restores the caret from before it began.
2561        ed.undo().expect("undo ok").expect("something to undo");
2562        assert_eq!(ed.source_str().unwrap(), "\n");
2563        assert_eq!(ed.caret_blob().unwrap(), b"c0");
2564    }
2565
2566    #[test]
2567    fn editor_renumber_ordered_lists_fixes_a_stale_sequence() {
2568        let mut ed =
2569            Editor::new_str("1. a\n2. x\n2. b\n3. c\n", Format::Markdown).expect("editor");
2570        ed.renumber_ordered_lists(0).expect("renumber ok");
2571        assert_eq!(ed.source_str().unwrap(), "1. a\n2. x\n3. b\n4. c\n");
2572    }
2573
2574    #[test]
2575    fn editor_renumber_ordered_lists_off_a_list_is_not_found() {
2576        let mut ed = Editor::new_str("a paragraph\n", Format::Markdown).expect("editor");
2577        assert!(matches!(ed.renumber_ordered_lists(2), Err(Error::NotFound)));
2578    }
2579
2580    #[test]
2581    fn editor_table_insert_row_and_set_alignment() {
2582        let src = "| a | b |\n| --- | --- |\n| 1 | 2 |\n";
2583        let mut ed = Editor::new_str(src, Format::Markdown).expect("editor");
2584        ed.table_insert_row(24, true).expect("insert row"); // caret in body `1`
2585        assert_eq!(
2586            ed.source_str().unwrap(),
2587            "| a | b |\n| --- | --- |\n| 1 | 2 |\n|  |  |\n"
2588        );
2589        ed.table_set_alignment(6, Alignment::Center).expect("align"); // column `b`
2590        assert!(ed.source_str().unwrap().contains("| --- | :---: |"));
2591    }
2592
2593    #[test]
2594    fn editor_table_edit_off_a_table_is_not_found() {
2595        let mut ed = Editor::new_str("nope\n", Format::Markdown).expect("editor");
2596        assert!(matches!(ed.table_delete_row(2), Err(Error::NotFound)));
2597    }
2598
2599    #[test]
2600    fn editor_set_block_converts_setext_heading() {
2601        // A setext heading rebuilt from its content_span collapses the underline.
2602        let mut ed = Editor::new_str("Title\n=====\n\nbody\n", Format::Markdown).expect("editor");
2603        ed.set_block(0, BlockKind::Heading(1)).expect("setext to atx");
2604        assert_eq!(ed.source_str().unwrap(), "# Title\n\nbody\n");
2605    }
2606
2607    #[test]
2608    fn editor_unwrap_and_smart_delete() {
2609        let mut ed = Editor::new_str("<r><box><b/><c/></box></r>", Format::Xml).expect("editor");
2610        ed.unwrap_node("0.0").expect("unwrap"); // <box>
2611        assert_eq!(ed.source_str().expect("source"), "<r><b/><c/></r>");
2612
2613        let mut md = Editor::new_str("A\n\nB\n\nC\n", Format::Markdown).expect("editor");
2614        md.delete_smart("1").expect("delete_smart"); // the "B" paragraph
2615        assert_eq!(md.source_str().expect("source"), "A\n\nC\n");
2616    }
2617
2618    #[test]
2619    fn editor_directives_require_the_extension_flag() {
2620        let src = ":::vis{.public}\nhi\n:::\n";
2621        // Without the flag, the colon-fence lines are plain paragraph text —
2622        // no directive node.
2623        let mut plain = Editor::new_str(src, Format::Markdown).expect("editor");
2624        assert_eq!(plain.query("directive").expect("query").len(), 0);
2625        // With it enabled, the container directive is recognized.
2626        let mut ext = Editor::new_ext(
2627            src.as_bytes(),
2628            Format::Markdown,
2629            MarkdownExtensions { directives: true, ..Default::default() },
2630        )
2631        .expect("editor");
2632        assert_eq!(ext.query("directive").expect("query").len(), 1);
2633    }
2634
2635    #[test]
2636    fn document_html_elements_make_embedded_img_queryable() {
2637        let src = "text <img src=\"a.png\" alt=\"x\"> more\n";
2638        // Without the flag, the `<img>` is opaque raw HTML — no `image` node.
2639        let mut plain = Document::parse_str(src, Format::Markdown).expect("parse");
2640        assert_eq!(plain.query("image").expect("query").len(), 0);
2641        // With it enabled on the read path, the promoted image is queryable.
2642        let mut ext = Document::parse_str_with(
2643            src,
2644            Format::Markdown,
2645            MarkdownExtensions { html_elements: true, ..Default::default() },
2646        )
2647        .expect("parse");
2648        let images = ext.query("image").expect("query");
2649        assert_eq!(images.len(), 1);
2650        assert_eq!(images[0].kind, "image");
2651    }
2652
2653    #[test]
2654    fn editor_filter_public_audience_view() {
2655        let src = "# Archive\n\n:::vis{.public}\nPublic.\n:::\n\n:::vis{.family}\nPrivate.\n:::\n";
2656        let mut ed = Editor::new_ext(
2657            src.as_bytes(),
2658            Format::Markdown,
2659            MarkdownExtensions { directives: true, ..Default::default() },
2660        )
2661        .expect("editor");
2662        // Drop every vis block except the public one, then unwrap it.
2663        ed.filter(
2664            "directive[name=vis]",
2665            Some("directive[class~=public]"),
2666            true,
2667        )
2668        .expect("filter");
2669        assert_eq!(ed.source_str().expect("source"), "# Archive\n\nPublic.\n");
2670    }
2671
2672    #[test]
2673    fn editor_filter_rejects_a_malformed_selector() {
2674        let mut ed = Editor::new_str("hi\n", Format::Markdown).expect("editor");
2675        assert_eq!(ed.filter("list >", None, false), Err(Error::InvalidArgument));
2676    }
2677
2678    #[test]
2679    fn builder_builds_and_renders_a_document() {
2680        let mut b = Builder::new().expect("builder");
2681
2682        // # Title\n\nhello *world*
2683        let title = b.add_text(TextKind::Str, "Title").unwrap();
2684        let heading = b.add_heading(1).unwrap();
2685        b.set_children(heading, &[title]).unwrap();
2686
2687        let hello = b.add_text(TextKind::Str, "hello ").unwrap();
2688        let world = b.add_text(TextKind::Str, "world").unwrap();
2689        let emph = b.add(VoidKind::Emph).unwrap();
2690        b.set_children(emph, &[world]).unwrap();
2691        let para = b.add(VoidKind::Para).unwrap();
2692        b.set_children(para, &[hello, emph]).unwrap();
2693
2694        let doc = b.add(VoidKind::Doc).unwrap();
2695        b.set_children(doc, &[heading, para]).unwrap();
2696
2697        let html = String::from_utf8(b.render_html(doc).unwrap()).unwrap();
2698        assert!(html.contains("<h1>Title</h1>"), "{html}");
2699        assert!(html.contains("<em>world</em>"), "{html}");
2700
2701        let md = String::from_utf8(b.serialize(doc, Format::Markdown).unwrap()).unwrap();
2702        assert!(md.contains("# Title"), "{md}");
2703        assert!(md.contains("*world*"), "{md}");
2704
2705        let matches = b.query(doc, "heading").unwrap();
2706        assert_eq!(matches.len(), 1);
2707        assert_eq!(matches[0].kind, "heading");
2708
2709        let json = String::from_utf8(b.ast_json(doc).unwrap()).unwrap();
2710        assert!(json.contains("\"kind\": \"doc\""), "{json}");
2711    }
2712
2713    #[test]
2714    fn builder_element_with_attributes() {
2715        let mut b = Builder::new().expect("builder");
2716        let inner = b.add_text(TextKind::Str, "hi").unwrap();
2717        let el = b.add_element("section").unwrap();
2718        b.set_children(el, &[inner]).unwrap();
2719        b.set_attrs(el, &[("class", Some("note")), ("hidden", None)]).unwrap();
2720
2721        let html = String::from_utf8(b.render_html(el).unwrap()).unwrap();
2722        assert!(html.contains("<section"), "{html}");
2723        assert!(html.contains("class=\"note\""), "{html}");
2724        assert!(html.contains("hidden"), "{html}");
2725    }
2726
2727    #[test]
2728    fn builder_lists_round_trip_to_markdown() {
2729        let mut b = Builder::new().expect("builder");
2730
2731        // An ordered list: 1. one / 2. two
2732        let one_txt = b.add_text(TextKind::Str, "one").unwrap();
2733        let one_para = b.add(VoidKind::Para).unwrap();
2734        b.set_children(one_para, &[one_txt]).unwrap();
2735        let one = b.add(VoidKind::ListItem).unwrap();
2736        b.set_children(one, &[one_para]).unwrap();
2737
2738        let two_txt = b.add_text(TextKind::Str, "two").unwrap();
2739        let two_para = b.add(VoidKind::Para).unwrap();
2740        b.set_children(two_para, &[two_txt]).unwrap();
2741        let two = b.add(VoidKind::ListItem).unwrap();
2742        b.set_children(two, &[two_para]).unwrap();
2743
2744        let list = b
2745            .add_ordered_list(OrderedNumbering::Decimal, OrderedDelim::Period, true, Some(1))
2746            .unwrap();
2747        b.set_children(list, &[one, two]).unwrap();
2748        let doc = b.add(VoidKind::Doc).unwrap();
2749        b.set_children(doc, &[list]).unwrap();
2750
2751        let md = String::from_utf8(b.serialize(doc, Format::Markdown).unwrap()).unwrap();
2752        assert!(md.contains("1. one"), "{md}");
2753        assert!(md.contains("2. two"), "{md}");
2754    }
2755
2756    #[test]
2757    fn builder_rejects_invalid_kind_and_id() {
2758        let b = Builder::new().expect("builder");
2759        // `heading` (code 2) carries a payload, so the void-kind `add` rejects it
2760        // — the safe `VoidKind` enum has no such variant, so we go through the raw
2761        // ABI to prove the guard.
2762        let mut id = 0u32;
2763        let status = unsafe { ffi::twig_builder_add(b.raw.as_ptr(), 2, &mut id) };
2764        assert_eq!(Error::from_status(status), Err(Error::InvalidArgument));
2765
2766        // A root id past the end can't be rendered.
2767        let mut ptr = std::ptr::null();
2768        let mut len = 0usize;
2769        let status = unsafe { ffi::twig_builder_render_html(b.raw.as_ptr(), 4242, &mut ptr, &mut len) };
2770        assert_eq!(Error::from_status(status), Err(Error::InvalidArgument));
2771    }
2772}