Skip to main content

twig/
lib.rs

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