Skip to main content

twig/
lib.rs

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