Skip to main content

twig/
lib.rs

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