Skip to main content

twig/
lib.rs

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