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/// Every format Twig can **parse** — the input axis, as opposed to [`Target`],
18/// which is where output bytes can go.
19///
20/// `#[non_exhaustive]` for the same reason [`Target`] is: Twig's parser list
21/// grows (reStructuredText is written and awaiting a registry entry), and a
22/// caller matching on this enum should not have to be recompiled to keep
23/// compiling. Match with a `_` arm.
24#[derive(Clone, Copy, Debug, Eq, PartialEq)]
25#[non_exhaustive]
26pub enum Format {
27    Djot,
28    Markdown,
29    Xml,
30    Html,
31    /// Parsed and rendered, but **not** serialized: `Target::Asciidoc` reports
32    /// [`Error::UnsupportedFormat`], and no [`Editor`] gesture applies to an
33    /// AsciiDoc document.
34    ///
35    /// The parser also covers a *slice* of AsciiDoc rather than all of it —
36    /// the header, paragraphs, sections, lists, delimited blocks and the inline
37    /// spans. What it does not implement survives as literal source text rather
38    /// than failing the parse, so a successful parse is not by itself a claim
39    /// that the whole document was understood.
40    Asciidoc,
41}
42
43impl From<Format> for ffi::TwigFormat {
44    fn from(value: Format) -> Self {
45        match value {
46            Format::Djot => ffi::TwigFormat::Djot,
47            Format::Markdown => ffi::TwigFormat::Markdown,
48            Format::Xml => ffi::TwigFormat::Xml,
49            Format::Html => ffi::TwigFormat::Html,
50            Format::Asciidoc => ffi::TwigFormat::Asciidoc,
51        }
52    }
53}
54
55/// Every format Twig can **write** — the output axis, as opposed to [`Format`],
56/// which is what Twig can **parse**.
57///
58/// Every [`Format`] is also a `Target` (use `Target::from(format)`), so the two
59/// lists coincide today and the distinction costs nothing to ignore. It exists
60/// because only one of them can grow freely: a [`Format`] must have a parser
61/// behind it, while a target only needs somewhere for bytes to go. That makes an
62/// *export-only* target — one Twig can write and no parser reads back, PDF being
63/// the motivating case — expressible here and nowhere else. See the two format
64/// axes in the Zig library's `DESIGN.md`.
65///
66/// `#[non_exhaustive]` for exactly that reason: a future export-only variant is
67/// then an additive change rather than a breaking one for callers that match on
68/// this enum.
69#[derive(Clone, Copy, Debug, Eq, PartialEq)]
70#[non_exhaustive]
71pub enum Target {
72    Djot,
73    Markdown,
74    Xml,
75    Html,
76    /// Nameable, and always [`Error::UnsupportedFormat`] at the moment of
77    /// serializing — AsciiDoc has a parser and no serializer. Present for the
78    /// reason [`From<Format> for Target`](#impl-From<Format>-for-Target) is
79    /// total: an input format Twig cannot write back is a *runtime* answer, not
80    /// an unnameable target.
81    Asciidoc,
82}
83
84impl Target {
85    /// The [`Format`] whose parser reads this target's own output back, or
86    /// `None` for an export-only target.
87    ///
88    /// Always `Some` today. It is the question to ask before assuming a target
89    /// can be round-tripped: `None` means bytes go out and nothing comes back,
90    /// so there is no "parse it again and compare" available for that target.
91    pub fn as_format(self) -> Option<Format> {
92        match self {
93            Target::Djot => Some(Format::Djot),
94            Target::Markdown => Some(Format::Markdown),
95            Target::Xml => Some(Format::Xml),
96            Target::Html => Some(Format::Html),
97            Target::Asciidoc => Some(Format::Asciidoc),
98        }
99    }
100}
101
102/// Total: every input format is also an output target, even the ones with no
103/// serializer yet (converting *into* XML reports [`Error::UnsupportedFormat`]
104/// rather than being unnameable).
105impl From<Format> for Target {
106    fn from(value: Format) -> Self {
107        match value {
108            Format::Djot => Target::Djot,
109            Format::Markdown => Target::Markdown,
110            Format::Xml => Target::Xml,
111            Format::Html => Target::Html,
112            Format::Asciidoc => Target::Asciidoc,
113        }
114    }
115}
116
117impl From<Target> for ffi::TwigFormat {
118    fn from(value: Target) -> Self {
119        match value {
120            Target::Djot => ffi::TwigFormat::Djot,
121            Target::Markdown => ffi::TwigFormat::Markdown,
122            Target::Xml => ffi::TwigFormat::Xml,
123            Target::Html => ffi::TwigFormat::Html,
124            Target::Asciidoc => ffi::TwigFormat::Asciidoc,
125        }
126    }
127}
128
129/// A node's kind, as the shared vocabulary publishes it.
130///
131/// A typed enum rather than the `String` this used to be, because the string
132/// made a whole class of upstream change invisible here. When twig collapsed
133/// its four generic container kinds (`div`, `span`, `directive`, `element`)
134/// into one `container`, every site in this crate that compared a kind name
135/// kept compiling and started being wrong at runtime. With this, each of those
136/// sites is a compile error pointing at the exact line.
137///
138/// `#[non_exhaustive]`, and with an [`Other`](Kind::Other) arm, for the two
139/// different ways the vocabulary can outrun a given build of this crate:
140/// `#[non_exhaustive]` makes ADDING a variant here a non-breaking change for
141/// callers, and `Other` carries a name the linked library published that this
142/// crate has no variant for at all. Match with a `_` arm.
143///
144/// ## What is one variant here and two in the core
145///
146/// The nine inline marks share a single `inline_mark` kind in twig's own AST,
147/// and the nine text leaves share a single `text_leaf`; both publish their
148/// MEMBER name (`"superscript"`, not `"inline_mark"`). This enum follows the
149/// published vocabulary, so they are variants here — the grouping is an
150/// implementation detail of the core, not something a consumer should have to
151/// know.
152///
153/// ## No `PartialEq<&str>`
154///
155/// Deliberately absent, though it would be one impl and would keep every
156/// `node.kind == Kind::Image` in existing code compiling. That is precisely the
157/// property this type exists to remove: a comparison against a string literal
158/// is exactly what survived the container rename and went silently wrong.
159/// Compare against a variant; reach for [`as_str`](Kind::as_str) only when you
160/// genuinely want the name (logging it, or forwarding it to something that
161/// speaks the wire vocabulary).
162#[derive(Clone, Debug, Eq, PartialEq, Hash)]
163#[non_exhaustive]
164pub enum Kind {
165    // ── Document root ─────────────────────────────────────────────────────
166    Doc,
167    // ── Blocks ────────────────────────────────────────────────────────────
168    Para,
169    Heading,
170    ThematicBreak,
171    Section,
172    CodeBlock,
173    RawBlock,
174    Metadata,
175    BlockQuote,
176    BulletList,
177    OrderedList,
178    TaskList,
179    DefinitionList,
180    LineBlock,
181    Table,
182    // ── Structural children, and the document-level definitions ───────────
183    ListItem,
184    TaskListItem,
185    DefinitionListItem,
186    Term,
187    Definition,
188    Line,
189    Row,
190    Cell,
191    Column,
192    Caption,
193    Footnote,
194    Reference,
195    Citation,
196    Substitution,
197    // ── Inlines ───────────────────────────────────────────────────────────
198    Str,
199    SoftBreak,
200    HardBreak,
201    NonBreakingSpace,
202    RawInline,
203    SmartPunctuation,
204    Link,
205    Image,
206    // ── Inline marks — one `inline_mark` kind in the core, published apart
207    Emph,
208    Strong,
209    Mark,
210    Superscript,
211    Subscript,
212    Insert,
213    Delete,
214    DoubleQuoted,
215    SingleQuoted,
216    // ── Text leaves — one `text_leaf` kind in the core, published apart ───
217    Symb,
218    Verbatim,
219    InlineMath,
220    DisplayMath,
221    Url,
222    Email,
223    FootnoteReference,
224    CitationReference,
225    SubstitutionReference,
226    // ── Generic markup ────────────────────────────────────────────────────
227    Container,
228    ProcessingInstruction,
229    Comment,
230    Doctype,
231    Cdata,
232    /// A kind name the linked library published that this crate has no variant
233    /// for — a newer twig against an older binding.
234    ///
235    /// Deliberately not an error: a node whose kind this crate cannot name is
236    /// still a node with a span, children and attributes, and a renderer that
237    /// wants to pass it through unchanged should not be stopped from doing so.
238    Other(String),
239}
240
241impl Kind {
242    /// The name twig publishes for this kind — the exact string the C ABI's
243    /// `TwigFlatNode.kind` carries.
244    pub fn as_str(&self) -> &str {
245        match self {
246            Kind::Doc => "doc",
247            Kind::Para => "para",
248            Kind::Heading => "heading",
249            Kind::ThematicBreak => "thematic_break",
250            Kind::Section => "section",
251            Kind::CodeBlock => "code_block",
252            Kind::RawBlock => "raw_block",
253            Kind::Metadata => "metadata",
254            Kind::BlockQuote => "block_quote",
255            Kind::BulletList => "bullet_list",
256            Kind::OrderedList => "ordered_list",
257            Kind::TaskList => "task_list",
258            Kind::DefinitionList => "definition_list",
259            Kind::LineBlock => "line_block",
260            Kind::Table => "table",
261            Kind::ListItem => "list_item",
262            Kind::TaskListItem => "task_list_item",
263            Kind::DefinitionListItem => "definition_list_item",
264            Kind::Term => "term",
265            Kind::Definition => "definition",
266            Kind::Line => "line",
267            Kind::Row => "row",
268            Kind::Cell => "cell",
269            Kind::Column => "column",
270            Kind::Caption => "caption",
271            Kind::Footnote => "footnote",
272            Kind::Reference => "reference",
273            Kind::Citation => "citation",
274            Kind::Substitution => "substitution",
275            Kind::Str => "str",
276            Kind::SoftBreak => "soft_break",
277            Kind::HardBreak => "hard_break",
278            Kind::NonBreakingSpace => "non_breaking_space",
279            Kind::RawInline => "raw_inline",
280            Kind::SmartPunctuation => "smart_punctuation",
281            Kind::Link => "link",
282            Kind::Image => "image",
283            Kind::Container => "container",
284            Kind::ProcessingInstruction => "processing_instruction",
285            Kind::Emph => "emph",
286            Kind::Strong => "strong",
287            Kind::Mark => "mark",
288            Kind::Superscript => "superscript",
289            Kind::Subscript => "subscript",
290            Kind::Insert => "insert",
291            Kind::Delete => "delete",
292            Kind::DoubleQuoted => "double_quoted",
293            Kind::SingleQuoted => "single_quoted",
294            Kind::Symb => "symb",
295            Kind::Verbatim => "verbatim",
296            Kind::InlineMath => "inline_math",
297            Kind::DisplayMath => "display_math",
298            Kind::Url => "url",
299            Kind::Email => "email",
300            Kind::FootnoteReference => "footnote_reference",
301            Kind::CitationReference => "citation_reference",
302            Kind::SubstitutionReference => "substitution_reference",
303            Kind::Comment => "comment",
304            Kind::Doctype => "doctype",
305            Kind::Cdata => "cdata",
306            Kind::Other(name) => name.as_str(),
307        }
308    }
309
310    /// Whether this is a kind the linked library named and this crate could
311    /// not — the [`Other`](Kind::Other) case, and the one worth logging when a
312    /// renderer meets a node it has no arm for.
313    pub fn is_unknown(&self) -> bool {
314        matches!(self, Kind::Other(_))
315    }
316}
317
318impl From<&str> for Kind {
319    fn from(name: &str) -> Self {
320        match name {
321            "doc" => Kind::Doc,
322            "para" => Kind::Para,
323            "heading" => Kind::Heading,
324            "thematic_break" => Kind::ThematicBreak,
325            "section" => Kind::Section,
326            "code_block" => Kind::CodeBlock,
327            "raw_block" => Kind::RawBlock,
328            "metadata" => Kind::Metadata,
329            "block_quote" => Kind::BlockQuote,
330            "bullet_list" => Kind::BulletList,
331            "ordered_list" => Kind::OrderedList,
332            "task_list" => Kind::TaskList,
333            "definition_list" => Kind::DefinitionList,
334            "line_block" => Kind::LineBlock,
335            "table" => Kind::Table,
336            "list_item" => Kind::ListItem,
337            "task_list_item" => Kind::TaskListItem,
338            "definition_list_item" => Kind::DefinitionListItem,
339            "term" => Kind::Term,
340            "definition" => Kind::Definition,
341            "line" => Kind::Line,
342            "row" => Kind::Row,
343            "cell" => Kind::Cell,
344            "column" => Kind::Column,
345            "caption" => Kind::Caption,
346            "footnote" => Kind::Footnote,
347            "reference" => Kind::Reference,
348            "citation" => Kind::Citation,
349            "substitution" => Kind::Substitution,
350            "str" => Kind::Str,
351            "soft_break" => Kind::SoftBreak,
352            "hard_break" => Kind::HardBreak,
353            "non_breaking_space" => Kind::NonBreakingSpace,
354            "raw_inline" => Kind::RawInline,
355            "smart_punctuation" => Kind::SmartPunctuation,
356            "link" => Kind::Link,
357            "image" => Kind::Image,
358            "container" => Kind::Container,
359            "processing_instruction" => Kind::ProcessingInstruction,
360            "emph" => Kind::Emph,
361            "strong" => Kind::Strong,
362            "mark" => Kind::Mark,
363            "superscript" => Kind::Superscript,
364            "subscript" => Kind::Subscript,
365            "insert" => Kind::Insert,
366            "delete" => Kind::Delete,
367            "double_quoted" => Kind::DoubleQuoted,
368            "single_quoted" => Kind::SingleQuoted,
369            "symb" => Kind::Symb,
370            "verbatim" => Kind::Verbatim,
371            "inline_math" => Kind::InlineMath,
372            "display_math" => Kind::DisplayMath,
373            "url" => Kind::Url,
374            "email" => Kind::Email,
375            "footnote_reference" => Kind::FootnoteReference,
376            "citation_reference" => Kind::CitationReference,
377            "substitution_reference" => Kind::SubstitutionReference,
378            "comment" => Kind::Comment,
379            "doctype" => Kind::Doctype,
380            "cdata" => Kind::Cdata,
381            other => Kind::Other(other.to_string()),
382        }
383    }
384}
385
386impl std::fmt::Display for Kind {
387    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
388        f.write_str(self.as_str())
389    }
390}
391
392/// One node returned by [`Document::query`]: its AST id, byte spans, and kind.
393#[derive(Clone, Debug, Eq, PartialEq)]
394pub struct QueryMatch {
395    /// The node's id in the shared AST.
396    pub node_id: u32,
397    /// The node's whole byte range in the source.
398    pub span: Range<usize>,
399    /// The node's interior byte range (between its delimiters), or `None` for
400    /// a leaf / a container with no known interior.
401    pub content_span: Option<Range<usize>>,
402    /// The node's kind. See [`Kind`] for why this is an enum and not the
403    /// name string the C ABI carries.
404    pub kind: Kind,
405}
406
407/// The byte-level effect of an [`Editor`] edit: `old` is the range of the
408/// pre-edit source that was replaced, `new` the range the replacement now
409/// occupies in the post-edit source (they share a start). An insertion has an
410/// empty `old`; a deletion an empty `new`. Everything a caret/selection needs
411/// to re-anchor across an edit without re-diffing: shift any offset `>= old.end`
412/// by `new.len() - old.len()`.
413#[derive(Clone, Debug, Eq, PartialEq)]
414pub struct Change {
415    pub old: Range<usize>,
416    pub new: Range<usize>,
417}
418
419impl Change {
420    /// The net change in source length (`new.len() - old.len()`).
421    pub fn delta(&self) -> isize {
422        self.new.len() as isize - self.old.len() as isize
423    }
424
425    fn from_ffi(c: ffi::TwigChange) -> Self {
426        Change {
427            old: c.old_span.start..c.old_span.end,
428            new: c.new_span.start..c.new_span.end,
429        }
430    }
431}
432
433/// One node of an [`Editor::nodes`] snapshot — the flat AST arena as owned Rust
434/// data (the JSON-free read path). `id` indexes the snapshot; `parent`,
435/// `first_child`, and `next_sibling` link the tree (`None` where absent).
436/// `text` is the node's primary payload (a `str`'s bytes, a `code_block`'s
437/// body, …) and `destination` a link/image target, each `None` when the kind
438/// carries no such payload.
439/// `#[non_exhaustive]`: a snapshot node is something twig *hands you*, never
440/// something you build, so it gains a field whenever a node kind's payload is
441/// surfaced (as `head`/`alignment` were for tables). Sealing construction here
442/// keeps every future addition a minor release instead of a major one.
443#[derive(Clone, Debug, Eq, PartialEq)]
444#[non_exhaustive]
445pub struct FlatNode {
446    pub id: NodeId,
447    pub parent: Option<NodeId>,
448    pub first_child: Option<NodeId>,
449    pub next_sibling: Option<NodeId>,
450    pub span: Range<usize>,
451    pub content_span: Option<Range<usize>>,
452    /// A heading's level; `None` for every other kind.
453    pub level: Option<u32>,
454    pub kind: Kind,
455    pub text: Option<String>,
456    pub destination: Option<String>,
457    /// Whether a `row`/`cell` belongs to the table head; `None` for every other
458    /// kind.
459    pub head: Option<bool>,
460    /// A `cell`'s column alignment; `None` for every other kind. The delimiter
461    /// row (`|:--|--:|`) that spells the alignment out is consumed by the parser
462    /// and has no node of its own, so this is the only way to recover it.
463    /// [`Alignment::Default`] is a real, unspecified alignment (a bare `---`) —
464    /// distinct from the `None` a non-cell node reports.
465    pub alignment: Option<Alignment>,
466    /// The name a generic container carries in its own payload rather than in
467    /// `kind`: an HTML/XML tag (`"picture"`, `"source"`, …) or a directive type
468    /// (`"note"`, `"embed"`, `"vis"`, …, no leading colons). `None` for every
469    /// semantic kind, whose identity is `kind` alone. With this an
470    /// `html_elements` parse's `<picture>`/`<source>` are distinguishable — both
471    /// report `kind == "container"` — and so are a `::embed` and a `::toc`.
472    ///
473    /// A tag and a directive type share one `kind` because they are one concept
474    /// in the core: a named container with attributes and children. `name` is
475    /// what tells them apart, which is why it is not optional in practice for
476    /// anything a renderer cares about.
477    pub name: Option<String>,
478    /// Which of the three generic-container SPELLINGS this node's producer
479    /// draws; `None` when it draws none. Pairs with [`name`](Self::name): the
480    /// name says *which* container, this says *how it is written*, and a
481    /// renderer needs both — the same type is a span inline
482    /// ([`DirectiveForm::Text`]), a standalone block with no body
483    /// ([`DirectiveForm::Leaf`]), and a wrapper around blocks
484    /// ([`DirectiveForm::Container`]).
485    ///
486    /// **This does not answer "is it a directive?"** — use
487    /// [`origin`](Self::origin). HTML's parser sets a form on `<div>` and
488    /// `<span>`, the two tags djot and Markdown have generic spellings for, so
489    /// this reports `Some(Container)` for a `<div>` and `None` for a
490    /// `<video>`: right often enough to look usable, wrong on the two tags you
491    /// meet first.
492    pub directive_form: Option<DirectiveForm>,
493    /// Whether a generic container was WRITTEN as a tag or as a directive;
494    /// `None` when nothing recorded it — the node is not a container, or no
495    /// parser produced it (a [`Builder`] tree).
496    ///
497    /// This is the field that separates an HTML `<div>` from a Markdown
498    /// `:::div`. Those two agree on [`kind`](Self::kind) (`"container"`), on
499    /// [`name`](Self::name) (`"div"`) and on
500    /// [`directive_form`](Self::directive_form) (`Container`), field for field,
501    /// so none of the three can tell you which one you have.
502    pub origin: Option<ContainerOrigin>,
503    /// The node's own MARKER — the leading bytes a rich view HIDES, on its
504    /// opening line: a heading's `#`s and the space after them, a list item's
505    /// `- ` / `1. `, a task item's marker plus its `[x] ` box, a block quote's
506    /// `> `. `None` for a node with no leading marker (every inline, a
507    /// paragraph, a SETEXT heading whose `---` sits *under* the block).
508    ///
509    /// **Not derivable from [`span`](Self::span) and
510    /// [`content_span`](Self::content_span).** For a heading it happens to be
511    /// `span.start..content_span.start`; for a marker-prefixed container it is
512    /// not, because those report `content_span == span` — a prefix repeating on
513    /// every line has no contiguous interior to point at. Before this field the
514    /// answer was recoverable only by a per-format rule (from the item's inner
515    /// paragraph in Markdown, from the item itself in Djot), which is the
516    /// "which parser produced this?" reasoning a shared AST exists to remove.
517    ///
518    /// Covers ONE LINE, and this node's own marker alone. For the whole prefix a
519    /// nested construct sits behind (`>   1. [ ] ` is four nodes' markers plus
520    /// the indent between them), call [`Document::line_prefix`].
521    pub marker_span: Option<Range<usize>>,
522    /// A task list item's checkbox state; `None` for every other kind.
523    ///
524    /// The parser has always known this — it is what decides
525    /// [`Kind::TaskListItem`] over [`Kind::ListItem`] in the first place — and
526    /// until now nothing surfaced it, so a consumer rendering a clickable
527    /// checkbox re-derived the state by scanning the source for `[x]`. That scan
528    /// is fooled by a `[` in prose, and it asks the bytes a question the tree
529    /// had already answered. Twig would WRITE a checkbox
530    /// ([`Editor::set_task_checked`]) and not read one back.
531    ///
532    /// `None` is distinct from `Some(false)`: a consumer treating "not a task
533    /// item" as unchecked draws an empty box beside every paragraph.
534    pub checked: Option<bool>,
535    /// The node's `{...}` / HTML attributes as `(key, value)` pairs in source
536    /// order (empty when it has none). A bare attribute (HTML `disabled`, or a
537    /// `<source media=…>` used as a flag) has a `None` value.
538    pub attrs: Vec<(String, Option<String>)>,
539}
540
541/// A synthesized line prefix — what [`Document::continuation_prefix`] and
542/// [`Document::blank_line_prefix`] build.
543///
544/// `columns` is deliberately not `text.len()`: a tab in a marker advances to a
545/// tab stop, so `-\tx` yields a four-column prefix from a two-byte marker. An
546/// editor sizing a Tab step, a caret's horizontal home, or an outdent wants the
547/// column count; one writing the prefix into the document wants the bytes.
548#[derive(Clone, Debug, Default, Eq, PartialEq)]
549pub struct LinePrefix {
550    /// The bytes to write at the head of the line.
551    pub text: String,
552    /// Their width in columns.
553    pub columns: usize,
554}
555
556/// An inline mark for [`Editor::wrap_range`] / [`Editor::toggle_inline`] — a
557/// rich editor's Bold / Italic / Code / … buttons. Markdown spells only
558/// [`InlineKind::Strong`], [`InlineKind::Emph`], and [`InlineKind::Verbatim`];
559/// Djot spells all of them. An unsupported kind yields [`Error::UnsupportedFormat`].
560#[derive(Clone, Copy, Debug, Eq, PartialEq)]
561pub enum InlineKind {
562    Strong,
563    Emph,
564    Verbatim,
565    Mark,
566    Superscript,
567    Subscript,
568    Insert,
569    Delete,
570}
571
572impl InlineKind {
573    fn to_c(self) -> c_int {
574        match self {
575            InlineKind::Strong => 0,
576            InlineKind::Emph => 1,
577            InlineKind::Verbatim => 2,
578            InlineKind::Mark => 3,
579            InlineKind::Superscript => 4,
580            InlineKind::Subscript => 5,
581            InlineKind::Insert => 6,
582            InlineKind::Delete => 7,
583        }
584    }
585}
586
587/// A block target for [`Editor::set_block`] — the toolbar's H1…H6 / Body switch.
588#[derive(Clone, Copy, Debug, Eq, PartialEq)]
589pub enum BlockKind {
590    Paragraph,
591    /// A heading of the given level (1–6; out of range is [`Error::InvalidArgument`]).
592    Heading(u32),
593}
594
595impl BlockKind {
596    /// `(block_kind_code, level)` for the C ABI.
597    fn to_c(self) -> (c_int, u32) {
598        match self {
599            BlockKind::Paragraph => (0, 0),
600            BlockKind::Heading(level) => (1, level),
601        }
602    }
603}
604
605/// A block container for [`Editor::toggle_block_container`] — the toolbar's
606/// Quote / Bulleted list / Numbered list buttons. Where a [`BlockKind`] rewrites
607/// one block's leading marker, a container prefixes every line of a range and
608/// nests. Djot and Markdown spell all three; other formats yield
609/// [`Error::UnsupportedFormat`].
610#[derive(Clone, Copy, Debug, Eq, PartialEq)]
611pub enum BlockContainerKind {
612    BlockQuote,
613    BulletList,
614    OrderedList,
615}
616
617impl BlockContainerKind {
618    fn to_c(self) -> c_int {
619        match self {
620            BlockContainerKind::BlockQuote => 0,
621            BlockContainerKind::BulletList => 1,
622            BlockContainerKind::OrderedList => 2,
623        }
624    }
625}
626
627#[derive(Clone, Copy, Debug, Eq, PartialEq)]
628pub struct Version {
629    pub major: u8,
630    pub minor: u8,
631    pub patch: u8,
632}
633
634pub fn version() -> Version {
635    let packed = unsafe { ffi::twig_version() };
636    Version {
637        major: (packed >> 16) as u8,
638        minor: (packed >> 8) as u8,
639        patch: packed as u8,
640    }
641}
642
643/// The C ABI contract version this crate was **compiled** against — the
644/// compile-time counterpart to [`abi_version`] (which reports the **linked
645/// library's**). This crate builds and links its own vendored copy of the Zig
646/// source, so the two always agree; the pair is exposed so a consumer embedding
647/// a separately-built library can verify layout compatibility at load time.
648pub const ABI_VERSION: u32 = ffi::TWIG_ABI_VERSION;
649
650/// The C ABI contract version of the linked library. This crate is written
651/// against [`ABI_VERSION`]; the two agreeing is what makes the `#[repr(C)]`
652/// mirrors in `ffi` sound. It is bumped only on a breaking ABI change (a struct
653/// layout change or a renumbered enum value), never on an additive one (a new
654/// format code or a new function).
655pub fn abi_version() -> u32 {
656    unsafe { ffi::twig_abi_version() }
657}
658
659pub fn version_string() -> &'static str {
660    let ptr = unsafe { ffi::twig_version_string() };
661    unsafe { std::ffi::CStr::from_ptr(ptr) }
662        .to_str()
663        .unwrap_or("")
664}
665
666#[derive(Debug)]
667pub struct Document {
668    raw: NonNull<ffi::TwigDocument>,
669}
670
671impl Document {
672    pub fn parse(input: &[u8], format: Format) -> Result<Self, Error> {
673        Self::parse_with(input, format, MarkdownExtensions::default())
674    }
675
676    pub fn parse_str(input: &str, format: Format) -> Result<Self, Error> {
677        Self::parse(input.as_bytes(), format)
678    }
679
680    /// Like [`Document::parse`], plus Markdown `extensions` to enable (ignored
681    /// for other formats) — the read-path counterpart of [`Editor::new_ext`].
682    /// Enable [`MarkdownExtensions::html_elements`] here to make embedded HTML
683    /// (`<img>`, `<picture>`, …) queryable via [`Document::query`] instead of
684    /// arriving as opaque raw HTML.
685    pub fn parse_with(
686        input: &[u8],
687        format: Format,
688        extensions: MarkdownExtensions,
689    ) -> Result<Self, Error> {
690        let mut raw = std::ptr::null_mut();
691        let ffi_format: ffi::TwigFormat = format.into();
692        let status = unsafe {
693            ffi::twig_parse_ext(
694                input.as_ptr(),
695                input.len(),
696                ffi_format as i32,
697                extensions.to_flags(),
698                &mut raw,
699            )
700        };
701        Error::from_status(status)?;
702        let raw = NonNull::new(raw).ok_or(Error::Internal)?;
703        Ok(Self { raw })
704    }
705
706    /// [`Document::parse_with`] for a `&str`.
707    pub fn parse_str_with(
708        input: &str,
709        format: Format,
710        extensions: MarkdownExtensions,
711    ) -> Result<Self, Error> {
712        Self::parse_with(input.as_bytes(), format, extensions)
713    }
714
715    /// Render the document to HTML. For Djot/Markdown this is the rich
716    /// rendering path that resolves reference/footnote side tables.
717    pub fn render_html(&mut self) -> Result<Vec<u8>, Error> {
718        let raw = self.raw.as_ptr();
719        collect_bytes(|ptr, len| unsafe { ffi::twig_document_render_html(raw, ptr, len) })
720    }
721
722    /// Serialize the document to `target`'s own syntax: a round-trip when
723    /// `target` names the document's own format, cross-format conversion
724    /// otherwise (e.g. parse Markdown, serialize as Djot). Returns
725    /// [`Error::UnsupportedFormat`] when the requested direction has no
726    /// serializer (today: converting into XML from another format).
727    ///
728    /// Prefer this over [`Document::serialize`]: serializing is a question about
729    /// where the bytes are going, so it takes a [`Target`]. The older spelling
730    /// takes a [`Format`] and still works — every `Format` is a `Target` — but
731    /// it cannot name an export-only target, and this one can.
732    pub fn serialize_to(&mut self, target: Target) -> Result<Vec<u8>, Error> {
733        let raw = self.raw.as_ptr();
734        let ffi_target: ffi::TwigFormat = target.into();
735        collect_bytes(|ptr, len| unsafe {
736            ffi::twig_document_serialize(raw, ffi_target as i32, ptr, len)
737        })
738    }
739
740    /// Serialize the document to `format`'s own source syntax.
741    ///
742    /// The original spelling of [`Document::serialize_to`], kept for
743    /// compatibility and defined in terms of it. It types the output axis as
744    /// [`Format`], which is the input vocabulary; reach for `serialize_to` in
745    /// new code.
746    pub fn serialize(&mut self, format: Format) -> Result<Vec<u8>, Error> {
747        self.serialize_to(format.into())
748    }
749
750    /// Encode the document's AST as pretty-printed JSON (the same encoding as
751    /// `twig convert -o ast`).
752    pub fn ast_json(&mut self) -> Result<Vec<u8>, Error> {
753        let raw = self.raw.as_ptr();
754        collect_bytes(|ptr, len| unsafe { ffi::twig_document_ast_json(raw, ptr, len) })
755    }
756
757    /// Resolve a CSS-lite selector (e.g. `heading[level=2]`,
758    /// `link[dest^="http"]`, `code`, `list > item`) against the document,
759    /// returning one [`QueryMatch`] per matching node in document order. A
760    /// malformed selector yields [`Error::InvalidArgument`].
761    ///
762    /// This is the general replacement for scanning code spans by hand: a
763    /// `verbatim` / `code_block` / `raw_inline` / `raw_block` selector recovers
764    /// those, and every other node kind is reachable too.
765    pub fn query(&mut self, selector: &str) -> Result<Vec<QueryMatch>, Error> {
766        let raw = self.raw.as_ptr();
767        collect_matches(|ptr, len| unsafe {
768            ffi::twig_document_query(raw, selector.as_ptr(), selector.len(), ptr, len)
769        })
770    }
771
772    /// Return the whole source span of `node` without running a selector query.
773    pub fn span(&mut self, node: NodeId) -> Result<Range<usize>, Error> {
774        let mut span = ffi::TwigSpan { start: 0, end: 0 };
775        let status = unsafe { ffi::twig_document_node_span(self.raw.as_ptr(), node.0, &mut span) };
776        Error::from_status(status)?;
777        Ok(span.start..span.end)
778    }
779
780    /// Return the interior span of `node`, or `None` when the node has no
781    /// recorded content span.
782    pub fn content_span(&mut self, node: NodeId) -> Result<Option<Range<usize>>, Error> {
783        let mut span = ffi::TwigSpan { start: 0, end: 0 };
784        let status =
785            unsafe { ffi::twig_document_node_content_span(self.raw.as_ptr(), node.0, &mut span) };
786        match status.0 {
787            ffi::TwigStatus::OK => Ok(Some(span.start..span.end)),
788            ffi::TwigStatus::NOT_FOUND => Ok(None),
789            _ => Err(Error::from_status(status).unwrap_err()),
790        }
791    }
792
793    /// The span of `node`'s own leading MARKER — the leading bytes a rich view
794    /// HIDES on its opening line — or `None` when it has none. See
795    /// [`FlatNode::marker_span`], which is the same answer inside a snapshot.
796    pub fn marker_span(&mut self, node: NodeId) -> Result<Option<Range<usize>>, Error> {
797        let mut span = ffi::TwigSpan { start: 0, end: 0 };
798        let status =
799            unsafe { ffi::twig_document_node_marker_span(self.raw.as_ptr(), node.0, &mut span) };
800        match status.0 {
801            ffi::TwigStatus::OK => Ok(Some(span.start..span.end)),
802            ffi::TwigStatus::NOT_FOUND => Ok(None),
803            _ => Err(Error::from_status(status).unwrap_err()),
804        }
805    }
806
807    /// Everything HIDDEN before the content on the line byte `offset` sits on:
808    /// every marker a node OPENS that line with, and the indentation between
809    /// them, as one range running from the line start.
810    ///
811    /// This is the assembled form of [`FlatNode::marker_span`], which records
812    /// each node's own marker alone. `>   1. [ ] ` is four nodes' markers plus
813    /// the spaces between them, and the union is contiguous from the line start
814    /// — so a caller gets one range to hide, or one width for a caret to step
815    /// over, rather than a chain to walk and stitch together itself.
816    ///
817    /// `None` when nothing opens on this line — a CONTINUATION line, the second
818    /// line of a wrapped paragraph or of a block quote. That is a real answer
819    /// rather than a gap: what a continuation line repeats is a different
820    /// question (a quote re-emits `> `, a list item re-emits spaces) and is not
821    /// answerable from marker spans. [`Error::InvalidArgument`] if `offset`
822    /// exceeds the source length.
823    pub fn line_prefix(&mut self, offset: usize) -> Result<Option<Range<usize>>, Error> {
824        let mut span = ffi::TwigSpan { start: 0, end: 0 };
825        let status =
826            unsafe { ffi::twig_document_line_prefix(self.raw.as_ptr(), offset, &mut span) };
827        match status.0 {
828            ffi::TwigStatus::OK => Ok(Some(span.start..span.end)),
829            ffi::TwigStatus::NOT_FOUND => Ok(None),
830            _ => Err(Error::from_status(status).unwrap_err()),
831        }
832    }
833
834    /// What a CONTINUATION LINE at `offset` must open with to stay inside every
835    /// container holding it.
836    ///
837    /// The other half of [`Document::line_prefix`], and not derivable from it.
838    /// That one reports the bytes ALREADY THERE on a line something opens, so it
839    /// hands back a range into the source. This one reports the bytes that WOULD
840    /// HAVE TO BE WRITTEN on a line nothing opens — a list item's continuation
841    /// is spaces where its marker was, which is not source at all, so it is
842    /// built rather than pointed at.
843    ///
844    /// A quote's `> ` is REPRODUCED (dropping it ends the quote); a list item's
845    /// marker becomes its WIDTH IN SPACES (repeating it would open a second
846    /// item). Each container on the caret's chain contributes the columns its
847    /// own marker occupies, on its own opening line — which may be a different
848    /// line for each of them, and is why this is a tree walk rather than a
849    /// re-read of one line:
850    ///
851    /// ```text
852    /// > - a      quote "> " + item "- " as width   ->  ">   "
853    /// - a
854    ///   - b      outer item + inner item           ->  "    "
855    /// ```
856    ///
857    /// Empty at the top level, which is the correct prefix there: none.
858    /// [`Error::InvalidArgument`] if `offset` exceeds the source length.
859    pub fn continuation_prefix(&mut self, offset: usize) -> Result<LinePrefix, Error> {
860        self.prefix_via(offset, ffi::twig_document_continuation_prefix)
861    }
862
863    /// What a BLANK line inside the containers at `offset` must carry.
864    ///
865    /// A quote's blank line still has to carry its `>` or the quote ENDS there;
866    /// a list item's must carry nothing, because a blank line between two of an
867    /// item's blocks is what makes its list loose and indenting it changes
868    /// nothing about that. So this is [`Document::continuation_prefix`] with its
869    /// trailing spaces cut back — which drops an item's indent entirely and
870    /// leaves a quote marker standing.
871    ///
872    /// The quote form is `>` and not `> `, because the space after the marker is
873    /// content indentation and a blank line has no content.
874    pub fn blank_line_prefix(&mut self, offset: usize) -> Result<LinePrefix, Error> {
875        self.prefix_via(offset, ffi::twig_document_blank_line_prefix)
876    }
877
878    /// Shared marshalling for the two prefix builders above.
879    fn prefix_via(
880        &mut self,
881        offset: usize,
882        f: unsafe extern "C" fn(
883            *mut ffi::TwigDocument,
884            usize,
885            *mut *const u8,
886            *mut usize,
887            *mut usize,
888        ) -> ffi::TwigStatus,
889    ) -> Result<LinePrefix, Error> {
890        let mut ptr: *const u8 = std::ptr::null();
891        let mut len = 0usize;
892        let mut columns = 0usize;
893        let status = unsafe { f(self.raw.as_ptr(), offset, &mut ptr, &mut len, &mut columns) };
894        Error::from_status(status)?;
895        let text = if ptr.is_null() || len == 0 {
896            String::new()
897        } else {
898            let bytes = unsafe { std::slice::from_raw_parts(ptr, len) };
899            String::from_utf8(bytes.to_vec()).map_err(|_| Error::Internal)?
900        };
901        Ok(LinePrefix { text, columns })
902    }
903
904    /// The grid extent of the cell at `node` — how many `(columns, rows)` it
905    /// occupies — or `None` when the node is not a cell. Both are at least 1,
906    /// and `(1, 1)` is the ordinary one-square cell; anything larger is a merged
907    /// cell from a format with a real grid (HTML's `colspan`/`rowspan`, an rST
908    /// grid table). GFM and djot pipe tables always report `(1, 1)`.
909    ///
910    /// HTML's `rowspan="0"` ("to the end of the row group") is not a count and
911    /// reports 1; the source spelling survives on the node's attributes.
912    ///
913    /// This is an accessor rather than a [`FlatNode`] field because the C struct
914    /// it snapshots is ABI-frozen — see [`Document::span`] for the same shape.
915    pub fn cell_extent(&mut self, node: NodeId) -> Result<Option<(u32, u32)>, Error> {
916        let raw = self.raw.as_ptr();
917        let mut colspan: u32 = 0;
918        let status = unsafe { ffi::twig_document_cell_colspan(raw, node.0, &mut colspan) };
919        match status.0 {
920            ffi::TwigStatus::OK => {}
921            ffi::TwigStatus::NOT_FOUND => return Ok(None),
922            _ => return Err(Error::from_status(status).unwrap_err()),
923        }
924        let mut rowspan: u32 = 0;
925        Error::from_status(unsafe { ffi::twig_document_cell_rowspan(raw, node.0, &mut rowspan) })?;
926        Ok(Some((colspan, rowspan)))
927    }
928
929    /// Snapshot the whole tree as a flat [`FlatNode`] array (the JSON-free read
930    /// path for a renderer), indexed so `nodes[i].id == NodeId(i)`. Walk it via
931    /// the `parent`/`first_child`/`next_sibling` links; the root is the node
932    /// whose `parent` is `None`.
933    pub fn nodes(&mut self) -> Result<Vec<FlatNode>, Error> {
934        let raw = self.raw.as_ptr();
935        collect_flat_nodes(|ptr, len| unsafe { ffi::twig_document_nodes(raw, ptr, len) })
936    }
937
938    /// The document-level **definitions**: every node that hangs off no parent
939    /// and is not the document root, in arena order. Usually empty.
940    ///
941    /// A parsed document is not one tree. Footnote definitions and
942    /// link-reference definitions are resolved by LABEL rather than by
943    /// position, so twig attaches them to nothing — walking from the root over
944    /// [`FlatNode::first_child`] never reaches them, and a renderer that wants
945    /// to resolve `[^1]` has to find the definition some other way. This is
946    /// that way, and it replaces scanning the whole [`Document::nodes`] array
947    /// for entries whose `parent` is `None`.
948    ///
949    /// Not filtered to a kind list: WHICH kinds end up detached is a property
950    /// of how a format resolves its definitions (djot and Markdown detach
951    /// [`Kind::Footnote`] and [`Kind::Reference`]; rST adds [`Kind::Citation`]
952    /// and [`Kind::Substitution`]), not something a caller should enumerate.
953    /// Read the [`kind`](QueryMatch::kind) on each match.
954    pub fn definitions(&mut self) -> Result<Vec<QueryMatch>, Error> {
955        let raw = self.raw.as_ptr();
956        collect_matches(|ptr, len| unsafe { ffi::twig_document_definitions(raw, ptr, len) })
957    }
958
959    /// What converting this document to `target` would silently **lose**: one
960    /// [`Warning`] per lossy node, in document order. An empty vec means the
961    /// conversion is lossless.
962    ///
963    /// Twig's serializers degrade or drop a node whenever the target has no
964    /// spelling for it — a djot `{=mark=}` written into Markdown comes back as
965    /// plain text, an HTML comment converted to djot vanishes entirely. None of
966    /// it is an error, so all of it happens quietly. This is the call that makes
967    /// it loud, and it replaces guessing from the outside: the answers are
968    /// measured against the serializers by a round-trip probe in the Zig
969    /// library, not asserted.
970    ///
971    /// The answer belongs to the (document, target) PAIR, not to the document —
972    /// the same document has different answers for different targets, which is
973    /// why this takes one and why nothing is cached on [`Document`] itself.
974    ///
975    /// [`Error::UnsupportedFormat`] for a target with no serializer at all
976    /// ([`Target::Xml`], [`Target::Asciidoc`]): "this cannot be written" is a
977    /// capability answer, not a per-node diagnosis.
978    pub fn diagnostics(&mut self, target: Target) -> Result<Vec<Warning>, Error> {
979        let raw = self.raw.as_ptr();
980        let code = ffi::TwigFormat::from(target) as c_int;
981        let mut ptr: *const ffi::TwigWarning = std::ptr::null();
982        let mut len = 0usize;
983        let status = unsafe { ffi::twig_document_diagnostics(raw, code, &mut ptr, &mut len) };
984        Error::from_status(status)?;
985        if len == 0 || ptr.is_null() {
986            return Ok(Vec::new());
987        }
988        let raw_warnings = unsafe { std::slice::from_raw_parts(ptr, len) };
989        Ok(raw_warnings
990            .iter()
991            .map(|w| Warning {
992                fidelity: Fidelity::from_c(w.fidelity),
993                path: borrowed_bytes(w.path_ptr, w.path_len).unwrap_or_default(),
994                kind: Kind::from(borrowed_cstr(w.kind).unwrap_or_default().as_str()),
995            })
996            .collect())
997    }
998
999    /// The direct children of `node` as [`QueryMatch`]es (id, span, kind) —
1000    /// `None` enumerates the document root's children (the top-level blocks).
1001    /// The cheap enumeration an incremental renderer walks to decide which
1002    /// blocks to re-marshal with [`Document::subtree`]. A childless node yields
1003    /// an empty vec.
1004    pub fn children(&mut self, node: Option<NodeId>) -> Result<Vec<QueryMatch>, Error> {
1005        let raw = self.raw.as_ptr();
1006        let id = node.map_or(ffi::TWIG_NO_NODE, |n| n.0);
1007        collect_matches(|ptr, len| unsafe { ffi::twig_document_children(raw, id, ptr, len) })
1008    }
1009
1010    /// Snapshot the subtree rooted at `node` as a self-contained [`FlatNode`]
1011    /// array with *local* ids: `array[0]` is the root, every link is an index
1012    /// into the returned vec (or `None`), and spans stay absolute. The root's
1013    /// `parent` and `next_sibling` are `None`, so a walk from index 0 stays
1014    /// inside the subtree. [`Error::InvalidArgument`] if `node` is out of range.
1015    pub fn subtree(&mut self, node: NodeId) -> Result<Vec<FlatNode>, Error> {
1016        let raw = self.raw.as_ptr();
1017        collect_flat_nodes(|ptr, len| unsafe { ffi::twig_document_subtree(raw, node.0, ptr, len) })
1018    }
1019
1020    /// The deepest node whose span contains byte `offset` (with `offset` equal
1021    /// to the source length treated as inside the root) — hit-testing and
1022    /// cursor context. `Ok(None)` if no node covers the offset;
1023    /// [`Error::InvalidArgument`] if `offset` exceeds the source length.
1024    pub fn node_at(&mut self, offset: usize) -> Result<Option<QueryMatch>, Error> {
1025        let mut m = empty_ffi_match();
1026        let status = unsafe { ffi::twig_document_node_at(self.raw.as_ptr(), offset, &mut m) };
1027        match status.0 {
1028            ffi::TwigStatus::OK => Ok(Some(query_match_from_ffi(&m)?)),
1029            ffi::TwigStatus::NOT_FOUND => Ok(None),
1030            _ => Err(Error::from_status(status).unwrap_err()),
1031        }
1032    }
1033
1034    /// The chain of nodes containing byte `offset`, root-first down to the
1035    /// deepest (the node [`Document::node_at`] returns) — the ancestor path for
1036    /// a breadcrumb. Empty if no node covers the offset.
1037    pub fn ancestors_at(&mut self, offset: usize) -> Result<Vec<QueryMatch>, Error> {
1038        let raw = self.raw.as_ptr();
1039        let mut ptr: *const ffi::TwigQueryMatch = std::ptr::null();
1040        let mut len = 0usize;
1041        let status = unsafe { ffi::twig_document_nodes_at(raw, offset, &mut ptr, &mut len) };
1042        match status.0 {
1043            ffi::TwigStatus::OK => {}
1044            ffi::TwigStatus::NOT_FOUND => return Ok(Vec::new()),
1045            _ => return Err(Error::from_status(status).unwrap_err()),
1046        }
1047        if len == 0 || ptr.is_null() {
1048            return Ok(Vec::new());
1049        }
1050        let raw_matches = unsafe { std::slice::from_raw_parts(ptr, len) };
1051        raw_matches.iter().map(query_match_from_ffi).collect()
1052    }
1053
1054    /// [`Document::node_at`] under CARET containment — the same descent, under
1055    /// the rule an editing caret needs rather than the one a byte range needs.
1056    ///
1057    /// Two differences, both because a caret is a position BETWEEN bytes while a
1058    /// span is a range OF bytes:
1059    ///
1060    /// 1. **A block's end is inside it.** A caret after the last character of a
1061    ///    paragraph is *in* that paragraph — it is where you stand to type the
1062    ///    rest of it. Half-open containment puts it outside, which is why a
1063    ///    consumer probing [`Document::ancestors_at`] ends up guessing at
1064    ///    contrived offsets (the content start, `caret - 1`, a marker byte) to
1065    ///    find the block it was plainly inside of.
1066    ///
1067    /// 2. **A trailing newline is not part of the block**, which is what makes
1068    ///    the two authorable formats AGREE. Djot ends a paragraph's span after
1069    ///    its newline and Markdown before it, so on `"a\n\nb\n"` the caret at
1070    ///    offset 1 read as `para` through Djot and `doc` through Markdown — the
1071    ///    same caret, two answers, decided by which parser produced the tree.
1072    ///
1073    /// Never `Ok(None)` for a non-empty document: a caret in the gap between two
1074    /// blocks reports the container holding the gap (usually the root) rather
1075    /// than nothing at all.
1076    pub fn node_at_caret(&mut self, offset: usize) -> Result<Option<QueryMatch>, Error> {
1077        let mut m = empty_ffi_match();
1078        let status = unsafe { ffi::twig_document_node_at_caret(self.raw.as_ptr(), offset, &mut m) };
1079        match status.0 {
1080            ffi::TwigStatus::OK => Ok(Some(query_match_from_ffi(&m)?)),
1081            ffi::TwigStatus::NOT_FOUND => Ok(None),
1082            _ => Err(Error::from_status(status).unwrap_err()),
1083        }
1084    }
1085
1086    /// [`Document::ancestors_at`] under caret containment — root-first down to
1087    /// the node [`Document::node_at_caret`] returns. See that method for the
1088    /// containment rule and why it differs.
1089    pub fn ancestors_at_caret(&mut self, offset: usize) -> Result<Vec<QueryMatch>, Error> {
1090        let raw = self.raw.as_ptr();
1091        let mut ptr: *const ffi::TwigQueryMatch = std::ptr::null();
1092        let mut len = 0usize;
1093        let status = unsafe { ffi::twig_document_nodes_at_caret(raw, offset, &mut ptr, &mut len) };
1094        match status.0 {
1095            ffi::TwigStatus::OK => {}
1096            ffi::TwigStatus::NOT_FOUND => return Ok(Vec::new()),
1097            _ => return Err(Error::from_status(status).unwrap_err()),
1098        }
1099        if len == 0 || ptr.is_null() {
1100            return Ok(Vec::new());
1101        }
1102        let raw_matches = unsafe { std::slice::from_raw_parts(ptr, len) };
1103        raw_matches.iter().map(query_match_from_ffi).collect()
1104    }
1105}
1106
1107/// A [`Document`] borrowed from an [`Editor`] (see [`Editor::document`]): the
1108/// editor's live tree behind the whole document read surface, without a parse.
1109///
1110/// It holds the editor mutably borrowed for as long as it lives, so the tree —
1111/// and every node id and span read out of it — cannot change underneath it.
1112/// Dropping it frees nothing; the editor owns the tree.
1113///
1114/// [`Document::render_html`] and [`Document::serialize`] are the two methods it
1115/// cannot serve ([`Error::UnsupportedFormat`] — they need a real parse's
1116/// language tag and side tables). Parse [`Editor::source`] for those.
1117#[derive(Debug)]
1118pub struct DocumentView<'a> {
1119    doc: Document,
1120    _editor: PhantomData<&'a mut Editor>,
1121}
1122
1123impl std::ops::Deref for DocumentView<'_> {
1124    type Target = Document;
1125
1126    fn deref(&self) -> &Document {
1127        &self.doc
1128    }
1129}
1130
1131impl std::ops::DerefMut for DocumentView<'_> {
1132    fn deref_mut(&mut self) -> &mut Document {
1133        &mut self.doc
1134    }
1135}
1136
1137impl Drop for Document {
1138    fn drop(&mut self) {
1139        unsafe { ffi::twig_document_destroy(self.raw.as_ptr()) }
1140    }
1141}
1142
1143/// Opt-in Markdown extensions to enable for a parse — for either the read path
1144/// ([`Document::parse_with`]) or the edit path ([`Editor::new_ext`]). Ignored
1145/// for non-Markdown formats. Every field defaults off, matching the library; the
1146/// default-on extensions (tables, strikethrough, task lists, …) are always on
1147/// and need no flag here.
1148#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
1149pub struct MarkdownExtensions {
1150    /// Generic directives: `:name`, `::name`, `:::name`.
1151    pub directives: bool,
1152    /// `$...$` / `$$...$$` math.
1153    pub math: bool,
1154    /// Parse recognized raw HTML into semantic AST nodes — an `<img>` becomes an
1155    /// [`image` node](FlatNode) instead of an opaque `raw_block`/`raw_inline`, so
1156    /// it is addressable by [`Document::query`] and the tree read paths. Only
1157    /// tags that map verbatim onto the source are promoted; the rest stay raw.
1158    pub html_elements: bool,
1159}
1160
1161impl MarkdownExtensions {
1162    fn to_flags(self) -> u32 {
1163        let mut flags = 0;
1164        if self.directives {
1165            flags |= ffi::TWIG_MD_DIRECTIVES;
1166        }
1167        if self.math {
1168            flags |= ffi::TWIG_MD_MATH;
1169        }
1170        if self.html_elements {
1171            flags |= ffi::TWIG_MD_HTML_ELEMENTS;
1172        }
1173        flags
1174    }
1175}
1176
1177/// A span-splice editor over a document: applies lossless, in-place edits and
1178/// reparses after each one, so node addressing stays valid as the document
1179/// evolves. Every op is addressed by a `locator` — a dot-separated index path
1180/// (`"0.3.1"`) or a selector that must match exactly one node
1181/// (`heading("Status")`). A failed edit leaves the document unchanged.
1182#[derive(Debug)]
1183pub struct Editor {
1184    raw: NonNull<ffi::TwigEditor>,
1185}
1186
1187impl Editor {
1188    /// Create an editor over a private copy of `input`, parsed as `format` with
1189    /// default options.
1190    pub fn new(input: &[u8], format: Format) -> Result<Self, Error> {
1191        let mut raw = std::ptr::null_mut();
1192        let ffi_format: ffi::TwigFormat = format.into();
1193        let status = unsafe {
1194            ffi::twig_editor_create(input.as_ptr(), input.len(), ffi_format as i32, &mut raw)
1195        };
1196        Error::from_status(status)?;
1197        let raw = NonNull::new(raw).ok_or(Error::Internal)?;
1198        Ok(Self { raw })
1199    }
1200
1201    pub fn new_str(input: &str, format: Format) -> Result<Self, Error> {
1202        Self::new(input.as_bytes(), format)
1203    }
1204
1205    /// Like [`Editor::new`], plus Markdown `extensions` to enable (ignored for
1206    /// other formats). The editor reparses with these after every edit, so a
1207    /// directive-bearing document stays parseable — needed before
1208    /// [`Editor::filter`] can match `directive[...]` selectors.
1209    pub fn new_ext(
1210        input: &[u8],
1211        format: Format,
1212        extensions: MarkdownExtensions,
1213    ) -> Result<Self, Error> {
1214        let mut raw = std::ptr::null_mut();
1215        let ffi_format: ffi::TwigFormat = format.into();
1216        let status = unsafe {
1217            ffi::twig_editor_create_ext(
1218                input.as_ptr(),
1219                input.len(),
1220                ffi_format as i32,
1221                extensions.to_flags(),
1222                &mut raw,
1223            )
1224        };
1225        Error::from_status(status)?;
1226        let raw = NonNull::new(raw).ok_or(Error::Internal)?;
1227        Ok(Self { raw })
1228    }
1229
1230    /// Replace the whole source of the located node with `text`.
1231    pub fn replace(&mut self, locator: &str, text: &str) -> Result<(), Error> {
1232        self.apply(locator, text, |ed, loc, loc_len, txt, txt_len| unsafe {
1233            ffi::twig_editor_replace(ed, loc, loc_len, txt, txt_len)
1234        })
1235    }
1236
1237    /// Replace the interior (between-delimiters content) of the located
1238    /// container.
1239    pub fn replace_content(&mut self, locator: &str, text: &str) -> Result<(), Error> {
1240        self.apply(locator, text, |ed, loc, loc_len, txt, txt_len| unsafe {
1241            ffi::twig_editor_replace_content(ed, loc, loc_len, txt, txt_len)
1242        })
1243    }
1244
1245    /// Insert `text` immediately before the located node.
1246    pub fn insert_before(&mut self, locator: &str, text: &str) -> Result<(), Error> {
1247        self.apply(locator, text, |ed, loc, loc_len, txt, txt_len| unsafe {
1248            ffi::twig_editor_insert_before(ed, loc, loc_len, txt, txt_len)
1249        })
1250    }
1251
1252    /// Insert `text` immediately after the located node.
1253    pub fn insert_after(&mut self, locator: &str, text: &str) -> Result<(), Error> {
1254        self.apply(locator, text, |ed, loc, loc_len, txt, txt_len| unsafe {
1255            ffi::twig_editor_insert_after(ed, loc, loc_len, txt, txt_len)
1256        })
1257    }
1258
1259    /// Insert `text` as the `index`-th child of the located container (an index
1260    /// at or past the child count appends).
1261    pub fn insert_child(&mut self, locator: &str, index: usize, text: &str) -> Result<(), Error> {
1262        let status = unsafe {
1263            ffi::twig_editor_insert_child(
1264                self.raw.as_ptr(),
1265                locator.as_ptr(),
1266                locator.len(),
1267                index,
1268                text.as_ptr(),
1269                text.len(),
1270            )
1271        };
1272        Error::from_status(status)
1273    }
1274
1275    /// Delete the located node (removes exactly its span; no whitespace
1276    /// cleanup).
1277    pub fn delete(&mut self, locator: &str) -> Result<(), Error> {
1278        let status =
1279            unsafe { ffi::twig_editor_delete(self.raw.as_ptr(), locator.as_ptr(), locator.len()) };
1280        Error::from_status(status)
1281    }
1282
1283    /// Delete the located node, tidying surrounding blank lines for a
1284    /// whole-line (block) node; an inline node degrades to the exact delete.
1285    pub fn delete_smart(&mut self, locator: &str) -> Result<(), Error> {
1286        let status = unsafe {
1287            ffi::twig_editor_delete_smart(self.raw.as_ptr(), locator.as_ptr(), locator.len())
1288        };
1289        Error::from_status(status)
1290    }
1291
1292    /// Unwrap the located node: replace it with its interior (drop the wrapper,
1293    /// keep the children) — e.g. peel a `:::vis{...}` container. A node with no
1294    /// interior (a leaf, or an empty container) is removed.
1295    pub fn unwrap_node(&mut self, locator: &str) -> Result<(), Error> {
1296        let status =
1297            unsafe { ffi::twig_editor_unwrap(self.raw.as_ptr(), locator.as_ptr(), locator.len()) };
1298        Error::from_status(status)
1299    }
1300
1301    /// Prune the document in place: remove every node matching the `drop`
1302    /// selector except those also matching `keep` (`None` spares nothing),
1303    /// then — if `unwrap_kept` — unwrap the survivors. Read the result with
1304    /// [`Editor::source`].
1305    pub fn filter(
1306        &mut self,
1307        drop: &str,
1308        keep: Option<&str>,
1309        unwrap_kept: bool,
1310    ) -> Result<(), Error> {
1311        let (keep_ptr, keep_len) = match keep {
1312            Some(k) => (k.as_ptr(), k.len()),
1313            None => (std::ptr::null(), 0),
1314        };
1315        let status = unsafe {
1316            ffi::twig_editor_filter(
1317                self.raw.as_ptr(),
1318                drop.as_ptr(),
1319                drop.len(),
1320                keep_ptr,
1321                keep_len,
1322                unwrap_kept as i32,
1323            )
1324        };
1325        Error::from_status(status)
1326    }
1327
1328    /// The editor's current (edited) source bytes.
1329    pub fn source(&mut self) -> Result<Vec<u8>, Error> {
1330        let raw = self.raw.as_ptr();
1331        collect_bytes(|ptr, len| unsafe { ffi::twig_editor_source(raw, ptr, len) })
1332    }
1333
1334    /// The editor's current source bytes as a UTF-8 string.
1335    pub fn source_str(&mut self) -> Result<String, Error> {
1336        String::from_utf8(self.source()?).map_err(|_| Error::Internal)
1337    }
1338
1339    /// Encode the editor's current tree as pretty-printed JSON — the live
1340    /// counterpart of [`Document::ast_json`], for inspecting between edits.
1341    pub fn ast_json(&mut self) -> Result<Vec<u8>, Error> {
1342        let raw = self.raw.as_ptr();
1343        collect_bytes(|ptr, len| unsafe { ffi::twig_editor_ast_json(raw, ptr, len) })
1344    }
1345
1346    /// Resolve a selector against the editor's current tree — the live
1347    /// counterpart of [`Document::query`].
1348    pub fn query(&mut self, selector: &str) -> Result<Vec<QueryMatch>, Error> {
1349        let raw = self.raw.as_ptr();
1350        collect_matches(|ptr, len| unsafe {
1351            ffi::twig_editor_query(raw, selector.as_ptr(), selector.len(), ptr, len)
1352        })
1353    }
1354
1355    // ── offset-addressed editing & read-back ────────────────────────────────
1356
1357    /// Splice `[start, end)` of the current source with `text`, reparse, and
1358    /// return the [`Change`] the edit produced — the offset-addressed primitive
1359    /// a caret editor is built on: a keystroke is `edit_range(c, c, "x")`,
1360    /// backspace `edit_range(c - 1, c, "")`, a selection replace
1361    /// `edit_range(a, b, s)`. `start <= end <= ` source length, else
1362    /// [`Error::InvalidArgument`]. A reparse-breaking edit is rolled back and
1363    /// returns [`Error::EditConflict`], leaving the document untouched.
1364    pub fn edit_range(&mut self, start: usize, end: usize, text: &str) -> Result<Change, Error> {
1365        let mut change = ffi::TwigChange {
1366            old_span: ffi::TwigSpan { start: 0, end: 0 },
1367            new_span: ffi::TwigSpan { start: 0, end: 0 },
1368        };
1369        let status = unsafe {
1370            ffi::twig_editor_edit_range(
1371                self.raw.as_ptr(),
1372                start,
1373                end,
1374                text.as_ptr(),
1375                text.len(),
1376                &mut change,
1377            )
1378        };
1379        Error::from_status(status)?;
1380        Ok(Change::from_ffi(change))
1381    }
1382
1383    /// The byte effect of the last successful edit — including the locator ops
1384    /// ([`Editor::replace`], [`Editor::delete_smart`], …), so any edit can
1385    /// re-anchor a caret without re-diffing. `None` before the first successful
1386    /// edit. (A multi-splice op such as [`Editor::filter`] reports only its
1387    /// final splice.)
1388    pub fn last_change(&mut self) -> Option<Change> {
1389        let mut change = ffi::TwigChange {
1390            old_span: ffi::TwigSpan { start: 0, end: 0 },
1391            new_span: ffi::TwigSpan { start: 0, end: 0 },
1392        };
1393        let status = unsafe { ffi::twig_editor_last_change(self.raw.as_ptr(), &mut change) };
1394        match status.0 {
1395            ffi::TwigStatus::OK => Some(Change::from_ffi(change)),
1396            _ => None,
1397        }
1398    }
1399
1400    /// Undo the last edit step, restoring the previous source and reparsing.
1401    /// Returns the [`Change`] the undo produced (current → restored) so a caret
1402    /// can re-anchor, or `None` when there's nothing to undo. History accrues
1403    /// across every successful edit that funnels through the splice primitive.
1404    pub fn undo(&mut self) -> Result<Option<Change>, Error> {
1405        let mut change = ffi::TwigChange {
1406            old_span: ffi::TwigSpan { start: 0, end: 0 },
1407            new_span: ffi::TwigSpan { start: 0, end: 0 },
1408        };
1409        let status = unsafe { ffi::twig_editor_undo(self.raw.as_ptr(), &mut change) };
1410        if status.0 == ffi::TwigStatus::NOT_FOUND {
1411            return Ok(None);
1412        }
1413        Error::from_status(status)?;
1414        Ok(Some(Change::from_ffi(change)))
1415    }
1416
1417    /// Redo the most recently undone edit step; the inverse of [`Editor::undo`].
1418    /// Returns `None` when the redo stack is empty (nothing undone, or a fresh
1419    /// edit has invalidated it).
1420    pub fn redo(&mut self) -> Result<Option<Change>, Error> {
1421        let mut change = ffi::TwigChange {
1422            old_span: ffi::TwigSpan { start: 0, end: 0 },
1423            new_span: ffi::TwigSpan { start: 0, end: 0 },
1424        };
1425        let status = unsafe { ffi::twig_editor_redo(self.raw.as_ptr(), &mut change) };
1426        if status.0 == ffi::TwigStatus::NOT_FOUND {
1427            return Ok(None);
1428        }
1429        Error::from_status(status)?;
1430        Ok(Some(Change::from_ffi(change)))
1431    }
1432
1433    /// Fold the most recent edit into the undo step before it, so a caret editor
1434    /// can coalesce a run of keystrokes into a single undo. Call right after an
1435    /// `edit_range` that continues a run (same kind, no intervening caret move);
1436    /// a no-op unless there are at least two steps to merge.
1437    pub fn coalesce_last_undo(&mut self) -> Result<(), Error> {
1438        let status = unsafe { ffi::twig_editor_coalesce_last(self.raw.as_ptr()) };
1439        Error::from_status(status)
1440    }
1441
1442    /// A monotonic change token, bumped once per successful mutation of the
1443    /// document (every edit and every undo/redo). Never decreases and never
1444    /// repeats for the life of the editor; the initial parse is revision 0.
1445    /// Equal revision means a byte-identical document, so it can key a cache
1446    /// instead of hand-tracking "did anything change?".
1447    pub fn revision(&mut self) -> u64 {
1448        unsafe { ffi::twig_editor_revision(self.raw.as_ptr()) }
1449    }
1450
1451    /// The cumulative dirty byte range since the last [`Editor::clear_dirty`]
1452    /// (or since the editor was created) — the union of every mutation's byte
1453    /// effect over that window, in current source coordinates — or `None` when
1454    /// the document is clean relative to the last clear.
1455    ///
1456    /// The incremental-rebuild companion to [`Editor::revision`]: `revision`
1457    /// says *whether* a cached view (glyph rows, syntax spans) needs rebuilding,
1458    /// this says *which bytes* changed, so a consumer rebuilds only the affected
1459    /// part instead of the whole document. A single conservative interval: it
1460    /// always covers every changed byte and may over-cover the gap between edits
1461    /// to disjoint regions, but never under-covers.
1462    ///
1463    /// It reports where *bytes* differ — exact, because twig splices losslessly
1464    /// and never reflows untouched bytes — not where the *parse* differs. An
1465    /// edit can reinterpret bytes outside the range (opening a code fence, a `#`
1466    /// promoting a paragraph to a heading), so a consumer rebuilding *structure*
1467    /// from it should widen the range to the enclosing block(s) itself (e.g. via
1468    /// [`Editor::node_at`] on each end). Typical loop: on a repaint, if
1469    /// [`Editor::revision`] moved, read this range, rebuild the rows it (widened)
1470    /// covers, then call [`Editor::clear_dirty`].
1471    pub fn dirty_range(&mut self) -> Option<Range<usize>> {
1472        let mut span = ffi::TwigSpan { start: 0, end: 0 };
1473        let status = unsafe { ffi::twig_editor_dirty_range(self.raw.as_ptr(), &mut span) };
1474        match status.0 {
1475            ffi::TwigStatus::OK => Some(span.start..span.end),
1476            _ => None,
1477        }
1478    }
1479
1480    /// Acknowledge the current dirty range: mark the document clean so a later
1481    /// [`Editor::dirty_range`] reports only mutations made after this call. Call
1482    /// it once you've consumed the range (rebuilt the affected view). Leaves the
1483    /// document, [`Editor::revision`], and [`Editor::last_change`] untouched.
1484    pub fn clear_dirty(&mut self) {
1485        unsafe { ffi::twig_editor_clear_dirty(self.raw.as_ptr()) };
1486    }
1487
1488    /// Attach an opaque, caller-owned blob (e.g. a serialized caret/selection)
1489    /// to the editor's current document state. Twig copies the bytes and never
1490    /// interprets them; it only carries them through the undo history so
1491    /// [`Editor::undo`]/[`Editor::redo`] hand back the caret matching the
1492    /// restored source (via [`Editor::caret_blob`]). Set it with the pre-edit
1493    /// caret *before* an edit so the retired undo step captures it. An empty
1494    /// blob clears the current caret.
1495    pub fn set_caret_blob(&mut self, blob: &[u8]) -> Result<(), Error> {
1496        let status = unsafe {
1497            ffi::twig_editor_set_caret_blob(self.raw.as_ptr(), blob.as_ptr(), blob.len())
1498        };
1499        Error::from_status(status)
1500    }
1501
1502    /// The opaque caret blob for the editor's current document state (see
1503    /// [`Editor::set_caret_blob`]). After [`Editor::undo`]/[`Editor::redo`] this
1504    /// is the restored state's caret; after an edit it is empty until set again.
1505    /// Returns an owned copy, so it outlives the next edit.
1506    pub fn caret_blob(&mut self) -> Result<Vec<u8>, Error> {
1507        let raw = self.raw.as_ptr();
1508        collect_bytes(|ptr, len| unsafe { ffi::twig_editor_caret_blob(raw, ptr, len) })
1509    }
1510
1511    /// The editor's current tree as a borrowed [`Document`], so the whole
1512    /// document read surface ([`Document::nodes`], [`Document::children`],
1513    /// [`Document::subtree`], [`Document::node_at`], [`Document::query`],
1514    /// [`Document::span`], …) applies to a document being edited.
1515    ///
1516    /// The view borrows the editor mutably, so no edit can land while it is
1517    /// alive and the ids it yields cannot go stale; drop it to edit again. See
1518    /// [`DocumentView`] for the two methods it cannot serve.
1519    pub fn document(&mut self) -> Result<DocumentView<'_>, Error> {
1520        let mut raw = std::ptr::null_mut();
1521        let status = unsafe { ffi::twig_editor_document(self.raw.as_ptr(), &mut raw) };
1522        Error::from_status(status)?;
1523        let raw = NonNull::new(raw).ok_or(Error::Internal)?;
1524        Ok(DocumentView {
1525            doc: Document { raw },
1526            _editor: PhantomData,
1527        })
1528    }
1529
1530    /// Snapshot the current tree as a flat [`FlatNode`] array (the JSON-free
1531    /// read path for a renderer), indexed so `nodes[i].id == NodeId(i)`. Walk it
1532    /// via the `parent`/`first_child`/`next_sibling` links; the root is the node
1533    /// whose `parent` is `None`.
1534    pub fn nodes(&mut self) -> Result<Vec<FlatNode>, Error> {
1535        let mut ptr: *const ffi::TwigFlatNode = std::ptr::null();
1536        let mut len = 0usize;
1537        let status = unsafe { ffi::twig_editor_nodes(self.raw.as_ptr(), &mut ptr, &mut len) };
1538        Error::from_status(status)?;
1539        if len == 0 {
1540            return Ok(Vec::new());
1541        }
1542        if ptr.is_null() {
1543            return Err(Error::Internal);
1544        }
1545        let raw = unsafe { std::slice::from_raw_parts(ptr, len) };
1546        raw.iter().map(flat_node_from_ffi).collect()
1547    }
1548
1549    /// The direct children of `node` as [`QueryMatch`]es (id, span, kind) —
1550    /// `None` enumerates the document root's children (the top-level blocks). The
1551    /// cheap top-level enumeration an incremental renderer walks to decide which
1552    /// blocks changed, without marshalling the whole arena; pair it with
1553    /// [`Editor::subtree`] to then re-marshal only those that did. A childless
1554    /// node yields an empty vec.
1555    pub fn child_spans(&mut self, node: Option<NodeId>) -> Result<Vec<QueryMatch>, Error> {
1556        let id = node.map_or(ffi::TWIG_NO_NODE, |n| n.0);
1557        let mut ptr: *const ffi::TwigQueryMatch = std::ptr::null();
1558        let mut len = 0usize;
1559        let status =
1560            unsafe { ffi::twig_editor_child_spans(self.raw.as_ptr(), id, &mut ptr, &mut len) };
1561        Error::from_status(status)?;
1562        if len == 0 || ptr.is_null() {
1563            return Ok(Vec::new());
1564        }
1565        let raw = unsafe { std::slice::from_raw_parts(ptr, len) };
1566        raw.iter().map(query_match_from_ffi).collect()
1567    }
1568
1569    /// Snapshot the subtree rooted at `node` as a self-contained [`FlatNode`]
1570    /// array with *local* ids: `array[0]` is the root, every link is an index
1571    /// into the returned vec (or `None`), and spans stay absolute. The
1572    /// incremental-render companion to [`Editor::nodes`] — re-marshal one edited
1573    /// block's subtree instead of the whole document. The root's `parent` and
1574    /// `next_sibling` are `None`, so a walk from index 0 stays inside the
1575    /// subtree. [`Error::InvalidArgument`] if `node` is out of range.
1576    pub fn subtree(&mut self, node: NodeId) -> Result<Vec<FlatNode>, Error> {
1577        let mut ptr: *const ffi::TwigFlatNode = std::ptr::null();
1578        let mut len = 0usize;
1579        let status =
1580            unsafe { ffi::twig_editor_subtree(self.raw.as_ptr(), node.0, &mut ptr, &mut len) };
1581        Error::from_status(status)?;
1582        if len == 0 || ptr.is_null() {
1583            return Ok(Vec::new());
1584        }
1585        let raw = unsafe { std::slice::from_raw_parts(ptr, len) };
1586        raw.iter().map(flat_node_from_ffi).collect()
1587    }
1588
1589    /// The deepest node whose span contains byte `offset` (with `offset` equal
1590    /// to the source length treated as inside the root) — mouse hit-testing and
1591    /// cursor context. `Ok(None)` if no node covers the offset;
1592    /// [`Error::InvalidArgument`] if `offset` exceeds the source length.
1593    pub fn node_at(&mut self, offset: usize) -> Result<Option<QueryMatch>, Error> {
1594        let mut m = ffi::TwigQueryMatch {
1595            node_id: 0,
1596            span: ffi::TwigSpan { start: 0, end: 0 },
1597            content_span: ffi::TwigSpan { start: 0, end: 0 },
1598            has_content_span: 0,
1599            kind: std::ptr::null(),
1600        };
1601        let status = unsafe { ffi::twig_editor_node_at(self.raw.as_ptr(), offset, &mut m) };
1602        match status.0 {
1603            ffi::TwigStatus::OK => Ok(Some(query_match_from_ffi(&m)?)),
1604            ffi::TwigStatus::NOT_FOUND => Ok(None),
1605            _ => Err(Error::from_status(status).unwrap_err()),
1606        }
1607    }
1608
1609    /// The chain of nodes containing byte `offset`, root-first down to the
1610    /// deepest (the node [`Editor::node_at`] returns) — the ancestor path for a
1611    /// breadcrumb or context-scoped edit. Empty if no node covers the offset.
1612    pub fn ancestors_at(&mut self, offset: usize) -> Result<Vec<QueryMatch>, Error> {
1613        let mut ptr: *const ffi::TwigQueryMatch = std::ptr::null();
1614        let mut len = 0usize;
1615        let status =
1616            unsafe { ffi::twig_editor_nodes_at(self.raw.as_ptr(), offset, &mut ptr, &mut len) };
1617        match status.0 {
1618            ffi::TwigStatus::OK => {}
1619            ffi::TwigStatus::NOT_FOUND => return Ok(Vec::new()),
1620            _ => return Err(Error::from_status(status).unwrap_err()),
1621        }
1622        if len == 0 || ptr.is_null() {
1623            return Ok(Vec::new());
1624        }
1625        let raw = unsafe { std::slice::from_raw_parts(ptr, len) };
1626        raw.iter().map(query_match_from_ffi).collect()
1627    }
1628
1629    // ── range-oriented rich-text ops (the toolbar) ──────────────────────────
1630
1631    /// Wrap `[start, end)` with `kind`'s delimiters — the unconditional half of
1632    /// the inline toolbar (always adds a mark; `*word*` → `**word**` stacks).
1633    /// [`Error::UnsupportedFormat`] if the document's format can't spell `kind`
1634    /// (e.g. a Markdown [`InlineKind::Mark`]); [`Error::InvalidArgument`] for a
1635    /// bad range; [`Error::EditConflict`] if the result doesn't reparse.
1636    pub fn wrap_range(
1637        &mut self,
1638        start: usize,
1639        end: usize,
1640        kind: InlineKind,
1641    ) -> Result<Change, Error> {
1642        self.change_op(|ed, out| unsafe {
1643            ffi::twig_editor_wrap_range(ed, start, end, kind.to_c(), out)
1644        })
1645    }
1646
1647    /// Toggle `kind` over `[start, end)`: remove the mark if the range already
1648    /// *is* a node of `kind` (its whole span or its rendered interior), else
1649    /// wrap it — a rich editor's Cmd-B. Same error rules as
1650    /// [`Editor::wrap_range`].
1651    pub fn toggle_inline(
1652        &mut self,
1653        start: usize,
1654        end: usize,
1655        kind: InlineKind,
1656    ) -> Result<Change, Error> {
1657        self.change_op(|ed, out| unsafe {
1658            ffi::twig_editor_toggle_inline(ed, start, end, kind.to_c(), out)
1659        })
1660    }
1661
1662    /// Convert the innermost heading/paragraph covering byte `offset` to `kind`,
1663    /// rewriting its leading marker while keeping its inline content (the
1664    /// toolbar's H1…H6 / Body switch). Djot and Markdown only, else
1665    /// [`Error::UnsupportedFormat`]; [`Error::InvalidArgument`] for a heading
1666    /// level outside 1–6.
1667    ///
1668    /// On a BLANK LINE this OPENS the block rather than converting one, so
1669    /// "H2, then type" works from an empty line the way it works from a full
1670    /// one — there is no node there to rewrite, since no format spells an empty
1671    /// paragraph. The marker is blank-separated from whatever precedes it (Djot
1672    /// does not let a heading interrupt a paragraph, so a marker flush under one
1673    /// is read as that paragraph's text) and carries the line's quote markers,
1674    /// so a heading opened on a quote's blank line stays inside the quote.
1675    /// [`BlockKind::Paragraph`] there is a no-op: a blank line already holds no
1676    /// marker.
1677    ///
1678    /// [`Error::NotEditable`] when the blank line is INTERIOR to a block rather
1679    /// than between blocks — inside a fenced code block, or a table.
1680    pub fn set_block(&mut self, offset: usize, kind: BlockKind) -> Result<Change, Error> {
1681        let (block_kind, level) = kind.to_c();
1682        self.change_op(|ed, out| unsafe {
1683            ffi::twig_editor_set_block(ed, offset, block_kind, level, out)
1684        })
1685    }
1686
1687    /// Toggle a block container over the blocks `[start, end)` covers — the
1688    /// toolbar's Quote / Bulleted list / Numbered list buttons. Djot and Markdown
1689    /// only, else [`Error::UnsupportedFormat`]; [`Error::NotFound`] if the range
1690    /// covers no block; [`Error::InvalidArgument`] for a bad range.
1691    ///
1692    /// The range widens to whole lines of the blocks it touches (you cannot quote
1693    /// half a paragraph), and the prefix lands at column 0, so a container wraps
1694    /// the outermost structure on those lines.
1695    ///
1696    /// Whether this adds or removes is decided from the **AST** — the ancestors
1697    /// of `start` — not by looking for a `>` in the source. It removes the
1698    /// container only when the range covers every block that container holds, and
1699    /// then only one level (`> > a` → `> a`). A partly covered container **nests**
1700    /// instead, since removing it would drag its uncovered siblings out with it:
1701    /// selecting the first paragraph of `> a\n>\n> b\n` gives `> > a\n>\n> b\n`.
1702    /// Toggling one list kind while inside the other **converts** in place
1703    /// (`- a` → `1. a`) rather than nesting.
1704    ///
1705    /// Each covered block becomes one item, so an ordered list numbers a
1706    /// multi-block range `1.`, `2.`, `3.`… Removing a list inserts a blank line
1707    /// between items that lacked one, keeping them separate blocks (a tight
1708    /// `- a\n- b\n` stripped bare would be a single two-line paragraph).
1709    pub fn toggle_block_container(
1710        &mut self,
1711        start: usize,
1712        end: usize,
1713        kind: BlockContainerKind,
1714    ) -> Result<Change, Error> {
1715        self.change_op(|ed, out| unsafe {
1716            ffi::twig_editor_toggle_block_container(ed, start, end, kind.to_c(), out)
1717        })
1718    }
1719
1720    /// Renumber the ordered list at byte `offset` so its markers run `1, 2, 3, …`,
1721    /// each nesting level restarting at 1 — the numbering a caret editor keeps as
1722    /// items are inserted, deleted, and nested, where a raw splice leaves the
1723    /// source numbers stale (`1. 2. 2. 3.`). Djot and Markdown; the display of an
1724    /// ordered list is renumbered by any CommonMark renderer regardless, so this
1725    /// is source hygiene, not a render fix.
1726    ///
1727    /// [`Error::NotFound`] when `offset` is not inside an ordered list. When the
1728    /// numbering is already sequential this is a no-op that still returns `Ok` —
1729    /// the source is left byte-for-byte unchanged. The `Change` is not returned
1730    /// because a no-op has none; re-read [`Editor::source_str`] for the result.
1731    ///
1732    /// Only lines the PARSER reads as items are touched, so this never rewrites a
1733    /// digit the author wrote as prose. That is not a corner case across formats:
1734    /// Djot doesn't let a list marker interrupt a paragraph, so in
1735    /// `1. a\n   2. b` the second line is text inside item `a`, while Markdown
1736    /// reads it as a nested item — the same bytes, renumbered in one format and
1737    /// left alone in the other.
1738    pub fn renumber_ordered_lists(&mut self, offset: usize) -> Result<(), Error> {
1739        self.change_op(|ed, out| unsafe {
1740            ffi::twig_editor_renumber_ordered_lists(ed, offset, out)
1741        })?;
1742        Ok(())
1743    }
1744
1745    // ── Tables ───────────────────────────────────────────────────────────────
1746    // Structural editing of the pipe table at a byte `offset`: the caret's cell
1747    // is the anchor. The whole table is re-spelled and spliced in one edit, so a
1748    // caller re-reads [`Editor::source_str`] and re-places its caret rather than
1749    // leaning on the returned span. [`Error::NotFound`] when `offset` is not in a
1750    // table; [`Error::NotEditable`] for a refused (degenerate) edit.
1751
1752    /// Insert an empty row below (`below`) or above the caret's row.
1753    pub fn table_insert_row(&mut self, offset: usize, below: bool) -> Result<(), Error> {
1754        self.table_edit(offset, ffi::TWIG_TABLE_INSERT_ROW, below as c_int)
1755    }
1756
1757    /// Delete the caret's row. [`Error::NotEditable`] for the header row or the
1758    /// last remaining body row.
1759    pub fn table_delete_row(&mut self, offset: usize) -> Result<(), Error> {
1760        self.table_edit(offset, ffi::TWIG_TABLE_DELETE_ROW, 0)
1761    }
1762
1763    /// Insert an empty column right (`right`) or left of the caret's column.
1764    pub fn table_insert_column(&mut self, offset: usize, right: bool) -> Result<(), Error> {
1765        self.table_edit(offset, ffi::TWIG_TABLE_INSERT_COLUMN, right as c_int)
1766    }
1767
1768    /// Delete the caret's column. [`Error::NotEditable`] when it is the only one.
1769    pub fn table_delete_column(&mut self, offset: usize) -> Result<(), Error> {
1770        self.table_edit(offset, ffi::TWIG_TABLE_DELETE_COLUMN, 0)
1771    }
1772
1773    /// Set the caret's column to `alignment`.
1774    pub fn table_set_alignment(
1775        &mut self,
1776        offset: usize,
1777        alignment: Alignment,
1778    ) -> Result<(), Error> {
1779        self.table_edit(offset, ffi::TWIG_TABLE_SET_ALIGNMENT, alignment.to_c())
1780    }
1781
1782    /// Move the caret's row one place down (`down`) or up, within the body rows.
1783    pub fn table_move_row(&mut self, offset: usize, down: bool) -> Result<(), Error> {
1784        self.table_edit(offset, ffi::TWIG_TABLE_MOVE_ROW, down as c_int)
1785    }
1786
1787    /// Move the caret's column one place right (`right`) or left.
1788    pub fn table_move_column(&mut self, offset: usize, right: bool) -> Result<(), Error> {
1789        self.table_edit(offset, ffi::TWIG_TABLE_MOVE_COLUMN, right as c_int)
1790    }
1791
1792    fn table_edit(&mut self, offset: usize, op: c_int, arg: c_int) -> Result<(), Error> {
1793        self.change_op(|ed, out| unsafe { ffi::twig_editor_table_edit(ed, offset, op, arg, out) })?;
1794        Ok(())
1795    }
1796
1797    /// Link `[start, end)` to `destination` — `[text](destination)`. Djot and
1798    /// Markdown only, else [`Error::UnsupportedFormat`];
1799    /// [`Error::InvalidArgument`] for a bad range or a destination containing a
1800    /// newline (neither format can carry one, and quietly rewriting the URL would
1801    /// be worse than refusing).
1802    ///
1803    /// An existing link covering the range has its destination **replaced** and
1804    /// its text kept, so re-linking fixes a URL instead of nesting
1805    /// `[[t](a)](b)`; to unlink, use [`Editor::unwrap_node`].
1806    ///
1807    /// A **range inside an existing autolink** (`<https://x.dev>`) re-points it
1808    /// the same way, but there is no text to keep — an autolink's text *is* its
1809    /// destination — so the node is replaced whole, respelled canonically for the
1810    /// new destination. This covers a caret and any selection the autolink
1811    /// contains, including one covering it exactly: an autolink's URL is not
1812    /// editable text, so no part of it can host a `[`, and "link half this URL"
1813    /// has no spelling. A caret inside both an autolink and a link
1814    /// (`[<https://x.dev>](d)`) re-points the link, whose text is separable from
1815    /// its destination and so survives.
1816    ///
1817    /// A selection starting or ending strictly **inside** an autolink without
1818    /// being contained by it — running from ordinary text into the middle of a
1819    /// URL — is refused with [`Error::NotEditable`]: half of it is real text,
1820    /// so there is nothing to re-point, and any splice would rewrite the URL.
1821    /// A selection that *contains* an autolink whole is unaffected — it splices
1822    /// at the edges and wraps as usual.
1823    ///
1824    /// A link with **no text** — an empty range, or re-pointing an existing
1825    /// `[](old)` — is spelled canonically for the destination given, never as
1826    /// `[](destination)`: a childless link has nothing to render, so consumers
1827    /// fall back to showing the destination and a caret has nowhere to sit. A
1828    /// destination the format can autolink (an absolute URL or an email, by that
1829    /// format's own rules) yields `<destination>`; anything else yields
1830    /// `[destination](destination)`, the destination doubling as the text so it
1831    /// stays visible and editable. Which destinations autolink is not the
1832    /// caller's to guess — `<foo>` is raw HTML in Markdown, a relative path goes
1833    /// literal in both, and the formats disagree (`<mailto:a@b.dev>` is a url in
1834    /// Markdown, an email in Djot), so each is asked its own parser.
1835    ///
1836    /// The destination is escaped for the format, so a `)` or a space in it
1837    /// cannot break the markup — and the two formats genuinely differ: Markdown
1838    /// ends a destination at the first space (`[t](a b)` is not a link at all) so
1839    /// whitespace moves it into the `<…>` form, while Djot takes spaces literally
1840    /// and would read `<a b>` as the URL itself.
1841    pub fn insert_link(
1842        &mut self,
1843        start: usize,
1844        end: usize,
1845        destination: &str,
1846    ) -> Result<Change, Error> {
1847        self.change_op(|ed, out| unsafe {
1848            ffi::twig_editor_insert_link(
1849                ed,
1850                start,
1851                end,
1852                destination.as_ptr(),
1853                destination.len(),
1854                out,
1855            )
1856        })
1857    }
1858
1859    /// Spell `[start, end)` as an image pointing at `destination` —
1860    /// `![alt](destination)`, the selected source becoming the alt text.
1861    ///
1862    /// The destination is escaped exactly as [`insert_link`](Self::insert_link)
1863    /// escapes one, because it is the same grammar production: Markdown moves a
1864    /// destination holding whitespace into the `<…>` form, Djot leaves it bare
1865    /// because `<…>` there would read as the URL itself. That is the reason this
1866    /// exists rather than being a `format!` at the call site — `![](my file.png)`
1867    /// is not an image in Markdown at all, and no caller can fix that without
1868    /// reproducing twig's per-format escape table.
1869    ///
1870    /// Two ways it is simpler than a link. An empty range stays empty:
1871    /// `![](destination)` is a perfectly good image, where the childless
1872    /// `[](destination)` that `insert_link` works to avoid has nothing to render
1873    /// or put a caret in. And there is no autolink or re-point reasoning — an
1874    /// image has no bare-URL spelling, and re-pointing an existing one is a read
1875    /// of its destination plus an insert, above this op.
1876    ///
1877    /// Returns [`Error::InvalidArgument`] for a destination holding a newline and
1878    /// [`Error::UnsupportedFormat`] for a parse-only format (XML, HTML).
1879    pub fn insert_image(
1880        &mut self,
1881        start: usize,
1882        end: usize,
1883        destination: &str,
1884    ) -> Result<Change, Error> {
1885        self.change_op(|ed, out| unsafe {
1886            ffi::twig_editor_insert_image(
1887                ed,
1888                start,
1889                end,
1890                destination.as_ptr(),
1891                destination.len(),
1892                out,
1893            )
1894        })
1895    }
1896
1897    /// Insert `text` at `offset` as a literal run: every byte the format reads as
1898    /// markup is backslash-escaped so the run reparses as exactly `text` — a typed
1899    /// `*`, `#` or `` ` `` stays that character rather than opening emphasis, a
1900    /// heading or a code span. This is the inverse of serialization (which writes
1901    /// an already-parsed run verbatim): it is what a WYSIWYG surface calls so that
1902    /// keyboard input can never mint markup, leaving formatting to explicit
1903    /// commands.
1904    ///
1905    /// The escaping is positional and per-format, and neither is the caller's to
1906    /// reproduce: inline specials (`*`, `` ` ``, `[`, `<`…) are escaped anywhere
1907    /// on the line, while block markers (`#`, `>`, `-`…) are escaped only where
1908    /// `offset` sits in its line's leading whitespace — so an inserted "5 - 3"
1909    /// keeps its `-` but "- item" at column zero does not become a bullet. An
1910    /// embedded newline in `text` re-enters that line-start zone.
1911    ///
1912    /// Two constructs a byte-alphabet cannot reach are left as typed: a GFM
1913    /// bare-URL autolink (`https://x.com`, with no delimiter to escape) and an
1914    /// ordered-list marker (`1.`, special only after a digit run). Returns
1915    /// [`Error::UnsupportedFormat`] for a parse-only format (XML, HTML) and
1916    /// [`Error::InvalidArgument`] when `offset` is past the source.
1917    pub fn insert_literal(&mut self, offset: usize, text: &str) -> Result<Change, Error> {
1918        self.change_op(|ed, out| unsafe {
1919            ffi::twig_editor_insert_literal(ed, offset, text.as_ptr(), text.len(), out)
1920        })
1921    }
1922
1923    /// Insert a hard line break *inside a table cell* at `offset`, spelled the
1924    /// format's way (`<br>` for Markdown). A table row is one source line, so the
1925    /// ordinary newline-based hard break can't appear there; the spliced `<br>`
1926    /// reparses as a semantic `hard_break` node — not opaque raw HTML — so the
1927    /// break reads back as structure. Like the other gestures it leans on the
1928    /// splice+reparse+rollback backstop: a break that would no longer parse as the
1929    /// same table yields [`Error::EditConflict`] and changes nothing.
1930    ///
1931    /// Returns [`Error::UnsupportedFormat`] for a format with no in-cell break
1932    /// spelling — djot (no idiomatic in-cell break), HTML and XML (parse-only);
1933    /// [`Error::NotFound`] when `offset` is not inside a table cell (only the
1934    /// in-cell gesture is spelled today); and [`Error::InvalidArgument`] when
1935    /// `offset` is past the source.
1936    pub fn insert_line_break(&mut self, offset: usize) -> Result<Change, Error> {
1937        self.change_op(|ed, out| unsafe { ffi::twig_editor_insert_line_break(ed, offset, out) })
1938    }
1939
1940    /// Insert a thematic break (a horizontal rule) as its own block, on the line
1941    /// after the block `offset` sits in. A rule is a block, so there is no
1942    /// spelling for one mid-paragraph.
1943    ///
1944    /// The rule is blank-line separated from its neighbours, and that is
1945    /// load-bearing rather than cosmetic: Markdown reads `---` on the line
1946    /// directly under a paragraph as a setext `<h2>` underline, so a rule written
1947    /// flush against its predecessor silently becomes a heading and swallows it.
1948    /// The blank below is added only when the next line isn't already blank. The
1949    /// spelling is the format's (`---` for Markdown, `* * *` for djot) and not
1950    /// the caller's to reproduce.
1951    ///
1952    /// Inside a block quote the rule inherits the quote's prefix and stays in the
1953    /// quote. Inside a list it lands at column zero after the caret's item, which
1954    /// splits the list in two with the rule between — a real document, nothing
1955    /// swallowed. There is no [`Error::NotFound`]: an empty document is a fine
1956    /// place for a rule. [`Error::UnsupportedFormat`] for a parse-only format
1957    /// (XML, HTML); [`Error::InvalidArgument`] when `offset` is past the source.
1958    pub fn insert_thematic_break(&mut self, offset: usize) -> Result<Change, Error> {
1959        self.change_op(|ed, out| unsafe { ffi::twig_editor_insert_thematic_break(ed, offset, out) })
1960    }
1961
1962    /// Split the block at `offset` in two at the caret, both halves the same
1963    /// kind — Enter in the middle of a paragraph, and the gesture
1964    /// [`Editor::insert_thematic_break`] deliberately is not. A host wanting
1965    /// "rule at the caret" calls this and then that.
1966    ///
1967    /// Nearly a pure insertion at `offset`: what is minted is the separator
1968    /// between the halves, and the only bytes removed are the second half's
1969    /// leading spaces and tabs, which are structure rather than content at the
1970    /// start of a block — a split at `- b| c` that kept its space would write
1971    /// `-  c`, setting that item's content indent to three. A code block sheds
1972    /// nothing, because there leading whitespace *is* the content.
1973    ///
1974    /// * A **paragraph** gets a blank line. Inside a quote the blank carries the
1975    ///   quote's marker and the second half its full prefix, so the split
1976    ///   happens inside the quote rather than ending it.
1977    /// * A paragraph in a **list item** gets the item's marker instead of a
1978    ///   blank, so the second half is a sibling item: `- this is |a list item`
1979    ///   becomes `- this is ` and `- a list item`. The marker is repeated
1980    ///   verbatim, ordered numbers included, so a split `1.` item yields two
1981    ///   `1.` items — both formats renumber on render, and
1982    ///   [`Editor::renumber_ordered_lists`] is the gesture for fixing the
1983    ///   source. A **task** item's new half is an unchecked box whatever the
1984    ///   original's state. A **nested** item's leading indent rides along with
1985    ///   its marker, so the new sibling stays in its own list rather than
1986    ///   dropping to column zero and joining the enclosing one.
1987    /// * A **heading** repeats its own marker at its own level;
1988    ///   [`Editor::set_block`] is how a caller demotes the second half instead.
1989    /// * A **code block** becomes two code blocks, the opening fence line
1990    ///   reproduced verbatim so its width and info string both survive. A
1991    ///   consumer that doesn't want the gesture offered there can ask the tree
1992    ///   what block the caret is in before calling.
1993    ///
1994    /// At a block boundary this still splits, which is what makes it Enter: at
1995    /// the end of a list item it opens an empty sibling item, which is the block
1996    /// the caller wants to type into. A paragraph is the one place that empty
1997    /// block cannot be spelled — no format has an empty paragraph — so the
1998    /// source gains a blank line and reparses as one paragraph; the node appears
1999    /// when there is text to hold.
2000    ///
2001    /// [`Error::NotEditable`] where a caret-split has no honest meaning: a
2002    /// **table** (a newline mid-cell destroys rather than divides; splitting one
2003    /// table into two is a table gesture, not this one), a **setext heading**
2004    /// (whose `---` underline would end up under the second half alone —
2005    /// [`Editor::set_block`] normalises one to ATX, which makes this work), and
2006    /// an **indented code block** (where a blank line is interior, so the split
2007    /// would parse back as one block). [`Error::NotFound`] when nothing covers
2008    /// `offset`; [`Error::InvalidArgument`] when `offset` is past the source.
2009    pub fn split_block(&mut self, offset: usize) -> Result<Change, Error> {
2010        self.change_op(|ed, out| unsafe { ffi::twig_editor_split_block(ed, offset, out) })
2011    }
2012
2013    /// Toggle a fenced code block over the blocks `[start, end)` covers: fence
2014    /// them if the caret is not in a code block, unfence the one it is in if it
2015    /// is. `language` tags the opening fence and is ignored when unfencing.
2016    ///
2017    /// `None` and `Some("")` are different requests: both write a bare fence, but
2018    /// the second says the caller asked for an empty info string. Reading the
2019    /// language back gives `None` either way — the distinction is in the ask, not
2020    /// the bytes. (Across the C ABI this rides as the `(ptr, len, has_*)` triple,
2021    /// the same spelling [`Builder::add_code_block`] uses for the same value.)
2022    ///
2023    /// Fencing *inserts* at the covered region's edges rather than rewriting its
2024    /// lines, so a body already carrying a quote's `> ` keeps it and the fence
2025    /// lines get the same prefix. The fence is **measured** — one character
2026    /// longer than the longest run of the fence character in the body — so
2027    /// fencing text that itself contains a fence nests instead of closing early.
2028    ///
2029    /// Unfencing peels the opening line and, when there is one, the closing fence
2030    /// line; a Markdown *indented* code block has no fence to peel and is
2031    /// dedented instead, so the toggle stays reversible on the older spelling.
2032    /// Note that unfencing can yield a different tree than the one that was
2033    /// fenced: a code body is by definition text the parser did not read as
2034    /// markup, so `# x` inside a fence becomes a heading once the fence is gone.
2035    ///
2036    /// [`Error::NotEditable`] **inside a list item**, in both directions: a
2037    /// quote's marker is on every line, a list item's is on its first line only,
2038    /// so a fence at column zero there would pull the `- ` into the code body and
2039    /// the item would stop being an item. [`Error::InvalidArgument`] for an info
2040    /// string the fence cannot carry (a line end, the fence character, or — in
2041    /// Markdown, whose info string ends at whitespace — a space);
2042    /// [`Error::UnsupportedFormat`] for a parse-only format;
2043    /// [`Error::NotFound`] when no block covers the range.
2044    pub fn toggle_code_block(
2045        &mut self,
2046        start: usize,
2047        end: usize,
2048        language: Option<&str>,
2049    ) -> Result<Change, Error> {
2050        let (ptr, len, has) = opt_str(language);
2051        self.change_op(|ed, out| unsafe {
2052            ffi::twig_editor_toggle_code_block(ed, start, end, ptr, len, has, out)
2053        })
2054    }
2055
2056    /// Retag the code block at `offset` with `language`, or clear its info string
2057    /// with `None` — the language dropdown beside a code block. Same
2058    /// `None`/`Some("")` distinction as [`Editor::toggle_code_block`].
2059    ///
2060    /// Only the info string is rewritten; the fence's own width is kept, because
2061    /// it was measured against a body this does not touch. [`Error::NotEditable`]
2062    /// for an *indented* Markdown code block, which has no fence and so nowhere
2063    /// to carry a language; [`Error::NotFound`] when `offset` is not in a code
2064    /// block.
2065    pub fn set_code_language(
2066        &mut self,
2067        offset: usize,
2068        language: Option<&str>,
2069    ) -> Result<Change, Error> {
2070        let (ptr, len, has) = opt_str(language);
2071        self.change_op(|ed, out| unsafe {
2072            ffi::twig_editor_set_code_language(ed, offset, ptr, len, has, out)
2073        })
2074    }
2075
2076    /// Add a checkbox to the list item at `offset`, or take one away — the
2077    /// gesture that converts between a plain list item and a task list item. A
2078    /// box is added unchecked; [`Editor::set_task_checked`] ticks it.
2079    ///
2080    /// The box is inline content of the item's first paragraph, not part of its
2081    /// marker, so adding or removing one leaves the item's continuation-line
2082    /// indentation alone. An item inside a quote is found past the quote markers.
2083    /// [`Error::NotFound`] when `offset` is in no list item;
2084    /// [`Error::NotEditable`] when the item's line carries no recognizable list
2085    /// marker; [`Error::UnsupportedFormat`] for a format with no checkbox.
2086    pub fn toggle_task_item(&mut self, offset: usize) -> Result<Change, Error> {
2087        self.change_op(|ed, out| unsafe { ffi::twig_editor_toggle_task_item(ed, offset, out) })
2088    }
2089
2090    /// Tick or untick the task item at `offset` — a checkbox click when the
2091    /// caller knows which way it should end up.
2092    ///
2093    /// Rewrites the box alone, never the space after it, so an item spelled with
2094    /// unusual spacing keeps it. A capital `[X]` is read as checked.
2095    ///
2096    /// When the box is already in the requested state this is a no-op that still
2097    /// returns `Ok` — the source is left byte-for-byte unchanged. The `Change` is
2098    /// not returned because a no-op has none; re-read [`Editor::source_str`].
2099    ///
2100    /// [`Error::NotEditable`] when the item has no box: minting one here would
2101    /// make "set checked" silently convert a bullet into a task, which is
2102    /// [`Editor::toggle_task_item`]'s job to do explicitly.
2103    pub fn set_task_checked(&mut self, offset: usize, checked: bool) -> Result<(), Error> {
2104        self.change_op(|ed, out| unsafe {
2105            ffi::twig_editor_set_task_checked(ed, offset, checked as c_int, out)
2106        })?;
2107        Ok(())
2108    }
2109
2110    /// Flip the task item at `offset` — what a checkbox click actually is when
2111    /// the caller does not already know the state. Always edits or fails, so
2112    /// unlike [`Editor::set_task_checked`] there is no silent no-op and the
2113    /// `Change` is always real.
2114    pub fn toggle_task_checked(&mut self, offset: usize) -> Result<Change, Error> {
2115        self.change_op(|ed, out| unsafe { ffi::twig_editor_toggle_task_checked(ed, offset, out) })
2116    }
2117
2118    /// Insert a footnote reference at `offset` and, unless the label is already
2119    /// defined, the matching definition at the end of the document.
2120    ///
2121    /// It writes **both halves**, because in neither format is half a footnote a
2122    /// footnote: a bare `[^a]` with nothing defining it renders as four literal
2123    /// characters. The definition body is left empty — that parses, and the
2124    /// caller then types into it like any other block. A label that is already
2125    /// defined gets only the reference, so referring to one footnote twice does
2126    /// not append a second, dead definition.
2127    ///
2128    /// It is **one** edit, spanning the caret to the end of the document even
2129    /// though the halves are far apart: two edits would take two undos to
2130    /// reverse, and the returned `Change` would describe only the second,
2131    /// omitting the reference the caret is sitting in.
2132    ///
2133    /// [`Error::InvalidArgument`] for a label that is empty or holds a line end
2134    /// or a reference bracket; [`Error::UnsupportedFormat`] for a format with no
2135    /// footnotes.
2136    pub fn insert_footnote(&mut self, offset: usize, label: &str) -> Result<Change, Error> {
2137        self.change_op(|ed, out| unsafe {
2138            ffi::twig_editor_insert_footnote(ed, offset, label.as_ptr(), label.len(), out)
2139        })
2140    }
2141
2142    /// Shared plumbing for the change-returning ops: run `op` (which fills a
2143    /// `TwigChange` out-param) and wrap the result.
2144    fn change_op(
2145        &mut self,
2146        op: impl FnOnce(*mut ffi::TwigEditor, *mut ffi::TwigChange) -> ffi::TwigStatus,
2147    ) -> Result<Change, Error> {
2148        let mut change = ffi::TwigChange {
2149            old_span: ffi::TwigSpan { start: 0, end: 0 },
2150            new_span: ffi::TwigSpan { start: 0, end: 0 },
2151        };
2152        let status = op(self.raw.as_ptr(), &mut change);
2153        Error::from_status(status)?;
2154        Ok(Change::from_ffi(change))
2155    }
2156
2157    /// Shared plumbing for the `(locator, text)` edit ops.
2158    fn apply(
2159        &mut self,
2160        locator: &str,
2161        text: &str,
2162        op: impl FnOnce(*mut ffi::TwigEditor, *const u8, usize, *const u8, usize) -> ffi::TwigStatus,
2163    ) -> Result<(), Error> {
2164        let status = op(
2165            self.raw.as_ptr(),
2166            locator.as_ptr(),
2167            locator.len(),
2168            text.as_ptr(),
2169            text.len(),
2170        );
2171        Error::from_status(status)
2172    }
2173}
2174
2175impl Drop for Editor {
2176    fn drop(&mut self) {
2177        unsafe { ffi::twig_editor_destroy(self.raw.as_ptr()) }
2178    }
2179}
2180
2181/// Run `call` (which writes a borrowed `(ptr, len)` byte buffer) and copy the
2182/// result into an owned `Vec` — the buffer is only valid until the next
2183/// same-accessor call on the handle, so we copy before returning. Shared by
2184/// [`Document`] and [`Editor`].
2185fn collect_bytes(
2186    call: impl FnOnce(*mut *const u8, *mut usize) -> ffi::TwigStatus,
2187) -> Result<Vec<u8>, Error> {
2188    let mut ptr = std::ptr::null();
2189    let mut len = 0usize;
2190    let status = call(&mut ptr, &mut len);
2191    Error::from_status(status)?;
2192    if len == 0 {
2193        return Ok(Vec::new());
2194    }
2195    if ptr.is_null() {
2196        return Err(Error::Internal);
2197    }
2198    let bytes = unsafe { std::slice::from_raw_parts(ptr, len) };
2199    Ok(bytes.to_vec())
2200}
2201
2202/// Run `call` (which writes a borrowed `(ptr, len)` match array) and copy each
2203/// match into an owned [`QueryMatch`]. Shared by [`Document`] and [`Editor`].
2204fn collect_matches(
2205    call: impl FnOnce(*mut *const ffi::TwigQueryMatch, *mut usize) -> ffi::TwigStatus,
2206) -> Result<Vec<QueryMatch>, Error> {
2207    let mut ptr = std::ptr::null();
2208    let mut len = 0usize;
2209    let status = call(&mut ptr, &mut len);
2210    Error::from_status(status)?;
2211    if len == 0 {
2212        return Ok(Vec::new());
2213    }
2214    if ptr.is_null() {
2215        return Err(Error::Internal);
2216    }
2217    let matches = unsafe { std::slice::from_raw_parts(ptr, len) };
2218    matches.iter().map(query_match_from_ffi).collect()
2219}
2220
2221/// `collect_matches` for the flat-node reads (`nodes` / `subtree`), which hand
2222/// back a borrowed [`ffi::TwigFlatNode`] array on the same contract.
2223fn collect_flat_nodes(
2224    call: impl FnOnce(*mut *const ffi::TwigFlatNode, *mut usize) -> ffi::TwigStatus,
2225) -> Result<Vec<FlatNode>, Error> {
2226    let mut ptr = std::ptr::null();
2227    let mut len = 0usize;
2228    let status = call(&mut ptr, &mut len);
2229    Error::from_status(status)?;
2230    if len == 0 {
2231        return Ok(Vec::new());
2232    }
2233    if ptr.is_null() {
2234        return Err(Error::Internal);
2235    }
2236    let nodes = unsafe { std::slice::from_raw_parts(ptr, len) };
2237    nodes.iter().map(flat_node_from_ffi).collect()
2238}
2239
2240/// The zeroed out-parameter the `node_at` hit-tests fill.
2241fn empty_ffi_match() -> ffi::TwigQueryMatch {
2242    ffi::TwigQueryMatch {
2243        node_id: 0,
2244        span: ffi::TwigSpan { start: 0, end: 0 },
2245        content_span: ffi::TwigSpan { start: 0, end: 0 },
2246        has_content_span: 0,
2247        kind: std::ptr::null(),
2248    }
2249}
2250
2251/// Copy a borrowed C ABI [`ffi::TwigQueryMatch`] into an owned [`QueryMatch`].
2252/// Shared by `collect_matches`, [`Editor::node_at`], and [`Editor::ancestors_at`].
2253fn query_match_from_ffi(m: &ffi::TwigQueryMatch) -> Result<QueryMatch, Error> {
2254    Ok(QueryMatch {
2255        node_id: m.node_id,
2256        span: m.span.start..m.span.end,
2257        content_span: if m.has_content_span != 0 {
2258            Some(m.content_span.start..m.content_span.end)
2259        } else {
2260            None
2261        },
2262        kind: Kind::from(borrowed_cstr(m.kind)?.as_str()),
2263    })
2264}
2265
2266/// Copy a borrowed C ABI [`ffi::TwigFlatNode`] into an owned [`FlatNode`].
2267fn flat_node_from_ffi(n: &ffi::TwigFlatNode) -> Result<FlatNode, Error> {
2268    let node_id = |v: u32| {
2269        if v == ffi::TWIG_NO_NODE {
2270            None
2271        } else {
2272            Some(NodeId(v))
2273        }
2274    };
2275    Ok(FlatNode {
2276        id: NodeId(n.id),
2277        parent: node_id(n.parent),
2278        first_child: node_id(n.first_child),
2279        next_sibling: node_id(n.next_sibling),
2280        span: n.span.start..n.span.end,
2281        content_span: if n.has_content_span != 0 {
2282            Some(n.content_span.start..n.content_span.end)
2283        } else {
2284            None
2285        },
2286        level: if n.level != 0 { Some(n.level) } else { None },
2287        kind: Kind::from(borrowed_cstr(n.kind)?.as_str()),
2288        text: borrowed_bytes(n.text_ptr, n.text_len),
2289        destination: borrowed_bytes(n.destination_ptr, n.destination_len),
2290        head: match n.head {
2291            ffi::TWIG_HEAD_NONE => None,
2292            v => Some(v != 0),
2293        },
2294        alignment: Alignment::from_c(n.alignment),
2295        name: borrowed_bytes(n.name_ptr, n.name_len),
2296        directive_form: DirectiveForm::from_c(n.directive_form),
2297        origin: ContainerOrigin::from_c(n.container_origin),
2298        marker_span: if n.has_marker_span != 0 {
2299            Some(n.marker_span.start..n.marker_span.end)
2300        } else {
2301            None
2302        },
2303        checked: match n.checked {
2304            ffi::TWIG_TASK_CHECKED_NONE => None,
2305            v => Some(v != 0),
2306        },
2307        attrs: borrowed_attrs(n.attrs_ptr, n.attrs_len),
2308    })
2309}
2310
2311/// Copy a borrowed `TwigKeyVal` array into owned `(key, value)` pairs, or an
2312/// empty vec for a NULL pointer (the node has no attributes). A bare attribute
2313/// (NULL `value`) maps to a `None` value, distinct from a present-but-empty one.
2314fn borrowed_attrs(ptr: *const ffi::TwigKeyVal, len: usize) -> Vec<(String, Option<String>)> {
2315    if ptr.is_null() || len == 0 {
2316        return Vec::new();
2317    }
2318    let kvs = unsafe { std::slice::from_raw_parts(ptr, len) };
2319    kvs.iter()
2320        .map(|kv| {
2321            let key = borrowed_bytes(kv.key, kv.key_len).unwrap_or_default();
2322            (key, borrowed_bytes(kv.value, kv.value_len))
2323        })
2324        .collect()
2325}
2326
2327/// Copy a NUL-terminated, library-owned C string into an owned `String`.
2328fn borrowed_cstr(ptr: *const c_char) -> Result<String, Error> {
2329    if ptr.is_null() {
2330        return Err(Error::Internal);
2331    }
2332    Ok(unsafe { std::ffi::CStr::from_ptr(ptr) }
2333        .to_str()
2334        .map_err(|_| Error::Internal)?
2335        .to_owned())
2336}
2337
2338/// Copy a borrowed `(ptr, len)` payload slice into an owned `String`, or `None`
2339/// for a NULL pointer (the kind carries no such payload). The bytes are a slice
2340/// of a UTF-8 document, so a lossy decode never actually substitutes.
2341fn borrowed_bytes(ptr: *const u8, len: usize) -> Option<String> {
2342    if ptr.is_null() {
2343        return None;
2344    }
2345    let bytes = unsafe { std::slice::from_raw_parts(ptr, len) };
2346    Some(String::from_utf8_lossy(bytes).into_owned())
2347}
2348
2349/// The id of a node added to a [`Builder`], returned by every `add*` method and
2350/// used to wire up the tree via [`Builder::set_children`] and to root a
2351/// render/serialize/query.
2352#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
2353pub struct NodeId(pub u32);
2354
2355/// The void-payload node kinds, addable via [`Builder::add`]. Kinds with a
2356/// payload have their own dedicated `add_*` method instead.
2357#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2358pub enum VoidKind {
2359    Doc,
2360    Para,
2361    ThematicBreak,
2362    Section,
2363    Div,
2364    BlockQuote,
2365    DefinitionList,
2366    Table,
2367    ListItem,
2368    DefinitionListItem,
2369    Term,
2370    Definition,
2371    Caption,
2372    SoftBreak,
2373    HardBreak,
2374    NonBreakingSpace,
2375    Emph,
2376    Strong,
2377    Span,
2378    Mark,
2379    Superscript,
2380    Subscript,
2381    Insert,
2382    Delete,
2383    DoubleQuoted,
2384    SingleQuoted,
2385}
2386
2387impl VoidKind {
2388    fn to_c(self) -> c_int {
2389        // Discriminants match `TwigNodeKind` in the C ABI.
2390        match self {
2391            VoidKind::Doc => 0,
2392            VoidKind::Para => 1,
2393            VoidKind::ThematicBreak => 3,
2394            VoidKind::Section => 4,
2395            VoidKind::Div => 5,
2396            VoidKind::BlockQuote => 9,
2397            VoidKind::DefinitionList => 13,
2398            VoidKind::Table => 14,
2399            VoidKind::ListItem => 15,
2400            VoidKind::DefinitionListItem => 17,
2401            VoidKind::Term => 18,
2402            VoidKind::Definition => 19,
2403            VoidKind::Caption => 22,
2404            VoidKind::SoftBreak => 26,
2405            VoidKind::HardBreak => 27,
2406            VoidKind::NonBreakingSpace => 28,
2407            VoidKind::Emph => 38,
2408            VoidKind::Strong => 39,
2409            VoidKind::Span => 42,
2410            VoidKind::Mark => 43,
2411            VoidKind::Superscript => 44,
2412            VoidKind::Subscript => 45,
2413            VoidKind::Insert => 46,
2414            VoidKind::Delete => 47,
2415            VoidKind::DoubleQuoted => 48,
2416            VoidKind::SingleQuoted => 49,
2417        }
2418    }
2419}
2420
2421/// The single-string-payload node kinds, addable via [`Builder::add_text`].
2422#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2423pub enum TextKind {
2424    Str,
2425    Symb,
2426    Verbatim,
2427    InlineMath,
2428    DisplayMath,
2429    Url,
2430    Email,
2431    FootnoteReference,
2432    /// reStructuredText's `[CIT2002]_` — a use of a citation definition. The
2433    /// payload is the label as WRITTEN, not the normalized name it resolves by.
2434    CitationReference,
2435    /// reStructuredText's `|name|` — a use of a substitution definition.
2436    SubstitutionReference,
2437    Comment,
2438    Doctype,
2439    Cdata,
2440}
2441
2442impl TextKind {
2443    fn to_c(self) -> c_int {
2444        match self {
2445            TextKind::Str => 25,
2446            TextKind::Symb => 29,
2447            TextKind::Verbatim => 30,
2448            TextKind::InlineMath => 32,
2449            TextKind::DisplayMath => 33,
2450            TextKind::Url => 34,
2451            TextKind::Email => 35,
2452            TextKind::FootnoteReference => 36,
2453            TextKind::CitationReference => 58,
2454            TextKind::SubstitutionReference => 59,
2455            TextKind::Comment => 52,
2456            TextKind::Doctype => 53,
2457            TextKind::Cdata => 55,
2458        }
2459    }
2460}
2461
2462/// Bullet marker style for [`Builder::add_bullet_list`].
2463#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2464pub enum BulletStyle {
2465    Dash,
2466    Plus,
2467    Star,
2468}
2469
2470impl BulletStyle {
2471    fn to_c(self) -> c_int {
2472        match self {
2473            BulletStyle::Dash => 0,
2474            BulletStyle::Plus => 1,
2475            BulletStyle::Star => 2,
2476        }
2477    }
2478}
2479
2480/// Numbering scheme for [`Builder::add_ordered_list`].
2481#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2482pub enum OrderedNumbering {
2483    Decimal,
2484    LowerAlpha,
2485    UpperAlpha,
2486    LowerRoman,
2487    UpperRoman,
2488}
2489
2490impl OrderedNumbering {
2491    fn to_c(self) -> c_int {
2492        match self {
2493            OrderedNumbering::Decimal => 0,
2494            OrderedNumbering::LowerAlpha => 1,
2495            OrderedNumbering::UpperAlpha => 2,
2496            OrderedNumbering::LowerRoman => 3,
2497            OrderedNumbering::UpperRoman => 4,
2498        }
2499    }
2500}
2501
2502/// Delimiter around an ordered-list number (`1.`, `1)`, `(1)`).
2503#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2504pub enum OrderedDelim {
2505    Period,
2506    ParenAfter,
2507    ParenBoth,
2508}
2509
2510impl OrderedDelim {
2511    fn to_c(self) -> c_int {
2512        match self {
2513            OrderedDelim::Period => 0,
2514            OrderedDelim::ParenAfter => 1,
2515            OrderedDelim::ParenBoth => 2,
2516        }
2517    }
2518}
2519
2520/// Table-cell alignment: written via [`Builder::add_cell`], read back on
2521/// [`FlatNode::alignment`].
2522#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2523pub enum Alignment {
2524    Default,
2525    Left,
2526    Right,
2527    Center,
2528}
2529
2530impl Alignment {
2531    fn to_c(self) -> c_int {
2532        match self {
2533            Alignment::Default => ffi::TWIG_ALIGN_DEFAULT,
2534            Alignment::Left => ffi::TWIG_ALIGN_LEFT,
2535            Alignment::Right => ffi::TWIG_ALIGN_RIGHT,
2536            Alignment::Center => ffi::TWIG_ALIGN_CENTER,
2537        }
2538    }
2539
2540    /// The inverse of [`Alignment::to_c`]; `None` for [`ffi::TWIG_ALIGN_NONE`]
2541    /// (the node isn't a cell) or any code this binding doesn't know.
2542    fn from_c(v: c_int) -> Option<Self> {
2543        match v {
2544            ffi::TWIG_ALIGN_DEFAULT => Some(Alignment::Default),
2545            ffi::TWIG_ALIGN_LEFT => Some(Alignment::Left),
2546            ffi::TWIG_ALIGN_RIGHT => Some(Alignment::Right),
2547            ffi::TWIG_ALIGN_CENTER => Some(Alignment::Center),
2548            _ => None,
2549        }
2550    }
2551}
2552
2553/// The smart-punctuation kind for [`Builder::add_smart_punctuation`].
2554#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2555pub enum SmartPunctuation {
2556    LeftSingleQuote,
2557    RightSingleQuote,
2558    LeftDoubleQuote,
2559    RightDoubleQuote,
2560    Ellipses,
2561    EmDash,
2562    EnDash,
2563}
2564
2565impl SmartPunctuation {
2566    fn to_c(self) -> c_int {
2567        match self {
2568            SmartPunctuation::LeftSingleQuote => 0,
2569            SmartPunctuation::RightSingleQuote => 1,
2570            SmartPunctuation::LeftDoubleQuote => 2,
2571            SmartPunctuation::RightDoubleQuote => 3,
2572            SmartPunctuation::Ellipses => 4,
2573            SmartPunctuation::EmDash => 5,
2574            SmartPunctuation::EnDash => 6,
2575        }
2576    }
2577}
2578
2579/// Whether a generic container was written as a TAG or as a DIRECTIVE — the
2580/// axis [`DirectiveForm`] is repeatedly mistaken for and cannot answer.
2581///
2582/// `DirectiveForm` is a spelling hint: which of a directive-capable format's
2583/// three spellings fits this node. Twig's HTML parser sets one on `<div>` and
2584/// `<span>` because those are the two tags djot and Markdown have generic
2585/// spellings for — so a `<div>` and a Markdown `:::div` produce nodes that
2586/// agree on kind, name and form alike. Until this axis existed, the only way
2587/// to separate them was to re-read the source bytes under the node's span and
2588/// look at the first character.
2589///
2590/// Read-only: it records what a parser saw, and there is nothing to set on the
2591/// build path.
2592/// One thing a conversion would silently lose. See [`Document::diagnostics`].
2593#[derive(Clone, Debug, Eq, PartialEq)]
2594pub struct Warning {
2595    pub fidelity: Fidelity,
2596    /// A slash-separated child-index trail from the document root (`"1/0/2"`),
2597    /// EMPTY for the root itself.
2598    ///
2599    /// A path and not a byte span, because the output being described does not
2600    /// exist yet — there is nothing in it to point at. Resolve it against the
2601    /// tree you already have.
2602    pub path: String,
2603    /// The affected node's kind, with family members reported as themselves
2604    /// ([`Kind::Superscript`], not an `inline_mark`).
2605    pub kind: Kind,
2606}
2607
2608/// How much of a node survives a conversion.
2609#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2610#[non_exhaustive]
2611pub enum Fidelity {
2612    /// Something is emitted, but the target's parser reads it back as a
2613    /// DIFFERENT kind. The content survives; its meaning does not.
2614    Degraded,
2615    /// Nothing is emitted at all: the node and its subtree leave no trace.
2616    Dropped,
2617}
2618
2619impl Fidelity {
2620    /// Only the lossy codes have a variant — a faithful node is never reported
2621    /// as a warning, so there is nothing for it to map to. An unknown code
2622    /// reads as [`Fidelity::Degraded`], the weaker of the two claims.
2623    fn from_c(v: c_int) -> Self {
2624        match v {
2625            ffi::TWIG_FIDELITY_DROPPED => Fidelity::Dropped,
2626            _ => Fidelity::Degraded,
2627        }
2628    }
2629}
2630
2631#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2632#[non_exhaustive]
2633pub enum ContainerOrigin {
2634    /// An HTML or XML tag: `<div>`, `<video>`, `<svg:rect>`.
2635    Element,
2636    /// A lightweight-markup generic container: a djot fenced div or bracketed
2637    /// span, a Markdown `:::note` / `::name` / `:name`, an rST `.. note::`, an
2638    /// AsciiDoc delimited block.
2639    Directive,
2640}
2641
2642impl ContainerOrigin {
2643    /// `None` for [`ffi::TWIG_CONTAINER_ORIGIN_NONE`] (nothing recorded an
2644    /// origin) or any code this binding doesn't know.
2645    fn from_c(v: c_int) -> Option<Self> {
2646        match v {
2647            ffi::TWIG_CONTAINER_ORIGIN_ELEMENT => Some(ContainerOrigin::Element),
2648            ffi::TWIG_CONTAINER_ORIGIN_DIRECTIVE => Some(ContainerOrigin::Directive),
2649            _ => None,
2650        }
2651    }
2652}
2653
2654/// The surface form of a generic directive for [`Builder::add_directive`].
2655#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2656pub enum DirectiveForm {
2657    Text,
2658    Leaf,
2659    Container,
2660}
2661
2662impl DirectiveForm {
2663    fn to_c(self) -> c_int {
2664        match self {
2665            DirectiveForm::Text => ffi::TWIG_DIRECTIVE_TEXT,
2666            DirectiveForm::Leaf => ffi::TWIG_DIRECTIVE_LEAF,
2667            DirectiveForm::Container => ffi::TWIG_DIRECTIVE_CONTAINER,
2668        }
2669    }
2670
2671    /// The inverse of [`DirectiveForm::to_c`]; `None` for
2672    /// [`ffi::TWIG_DIRECTIVE_NONE`] (the node isn't a directive) or any code
2673    /// this binding doesn't know.
2674    fn from_c(v: c_int) -> Option<Self> {
2675        match v {
2676            ffi::TWIG_DIRECTIVE_TEXT => Some(DirectiveForm::Text),
2677            ffi::TWIG_DIRECTIVE_LEAF => Some(DirectiveForm::Leaf),
2678            ffi::TWIG_DIRECTIVE_CONTAINER => Some(DirectiveForm::Container),
2679            _ => None,
2680        }
2681    }
2682}
2683
2684/// Decompose an optional string into `(ptr, len, has)` for the C ABI's
2685/// `(ptr, len, has_*)` optional-string triples. The pointer borrows `s` and is
2686/// only used within the same call.
2687fn opt_str(s: Option<&str>) -> (*const u8, usize, c_int) {
2688    match s {
2689        Some(x) => (x.as_ptr(), x.len(), 1),
2690        None => (std::ptr::null(), 0, 0),
2691    }
2692}
2693
2694/// Programmatic construction of a document — the write-path mirror of
2695/// [`Document::parse`]. Build the tree bottom-up (add children, then the
2696/// container, wiring them with [`Builder::set_children`]); every `add*` method
2697/// returns the new node's [`NodeId`]. Then render, serialize, query, or dump the
2698/// subtree rooted at any id, on demand, without consuming the builder. All input
2699/// strings are copied, so caller buffers need not outlive a call.
2700#[derive(Debug)]
2701pub struct Builder {
2702    raw: NonNull<ffi::TwigBuilder>,
2703}
2704
2705impl Builder {
2706    /// Create an empty builder.
2707    pub fn new() -> Result<Self, Error> {
2708        let mut raw = std::ptr::null_mut();
2709        let status = unsafe { ffi::twig_builder_create(&mut raw) };
2710        Error::from_status(status)?;
2711        let raw = NonNull::new(raw).ok_or(Error::Internal)?;
2712        Ok(Self { raw })
2713    }
2714
2715    /// Add a void-payload node (attach children later with
2716    /// [`Builder::set_children`]).
2717    pub fn add(&mut self, kind: VoidKind) -> Result<NodeId, Error> {
2718        self.emit(|b, out| unsafe { ffi::twig_builder_add(b, kind.to_c(), out) })
2719    }
2720
2721    /// Add a single-string-payload node (a `str`, code span, url, comment, …).
2722    pub fn add_text(&mut self, kind: TextKind, text: &str) -> Result<NodeId, Error> {
2723        self.emit(|b, out| unsafe {
2724            ffi::twig_builder_add_text(b, kind.to_c(), text.as_ptr(), text.len(), out)
2725        })
2726    }
2727
2728    /// Add a heading of the given level (attach its inline children afterward).
2729    pub fn add_heading(&mut self, level: u32) -> Result<NodeId, Error> {
2730        self.emit(|b, out| unsafe { ffi::twig_builder_add_heading(b, level, out) })
2731    }
2732
2733    /// Add a code block, with an optional info-string language.
2734    pub fn add_code_block(&mut self, lang: Option<&str>, text: &str) -> Result<NodeId, Error> {
2735        let (lp, ll, has) = opt_str(lang);
2736        self.emit(|b, out| unsafe {
2737            ffi::twig_builder_add_code_block(b, lp, ll, has, text.as_ptr(), text.len(), out)
2738        })
2739    }
2740
2741    /// Add a raw block targeting `format` (e.g. `"html"`).
2742    pub fn add_raw_block(&mut self, format: &str, text: &str) -> Result<NodeId, Error> {
2743        self.emit(|b, out| unsafe {
2744            ffi::twig_builder_add_raw_block(
2745                b,
2746                format.as_ptr(),
2747                format.len(),
2748                text.as_ptr(),
2749                text.len(),
2750                out,
2751            )
2752        })
2753    }
2754
2755    /// Add a document-metadata block written in config language `lang`.
2756    pub fn add_metadata(&mut self, lang: &str, text: &str) -> Result<NodeId, Error> {
2757        self.emit(|b, out| unsafe {
2758            ffi::twig_builder_add_metadata(
2759                b,
2760                lang.as_ptr(),
2761                lang.len(),
2762                text.as_ptr(),
2763                text.len(),
2764                out,
2765            )
2766        })
2767    }
2768
2769    /// Add a raw inline targeting `format`.
2770    pub fn add_raw_inline(&mut self, format: &str, text: &str) -> Result<NodeId, Error> {
2771        self.emit(|b, out| unsafe {
2772            ffi::twig_builder_add_raw_inline(
2773                b,
2774                format.as_ptr(),
2775                format.len(),
2776                text.as_ptr(),
2777                text.len(),
2778                out,
2779            )
2780        })
2781    }
2782
2783    /// Add a smart-punctuation node of `kind`. `text` is accepted for ABI
2784    /// compatibility but ignored by the underlying builder: the node's
2785    /// spelling is always the canonical one for `kind` (e.g. `"---"` for an
2786    /// em dash), never a caller-supplied one.
2787    pub fn add_smart_punctuation(
2788        &mut self,
2789        kind: SmartPunctuation,
2790        text: &str,
2791    ) -> Result<NodeId, Error> {
2792        self.emit(|b, out| unsafe {
2793            ffi::twig_builder_add_smart_punctuation(b, kind.to_c(), text.as_ptr(), text.len(), out)
2794        })
2795    }
2796
2797    /// Add a link with an optional destination and/or reference label (attach
2798    /// the link text as children).
2799    pub fn add_link(
2800        &mut self,
2801        destination: Option<&str>,
2802        reference: Option<&str>,
2803    ) -> Result<NodeId, Error> {
2804        let (dp, dl, hd) = opt_str(destination);
2805        let (rp, rl, hr) = opt_str(reference);
2806        self.emit(|b, out| unsafe { ffi::twig_builder_add_link(b, dp, dl, hd, rp, rl, hr, out) })
2807    }
2808
2809    /// Add an image — like [`Builder::add_link`], but children are the alt text.
2810    pub fn add_image(
2811        &mut self,
2812        destination: Option<&str>,
2813        reference: Option<&str>,
2814    ) -> Result<NodeId, Error> {
2815        let (dp, dl, hd) = opt_str(destination);
2816        let (rp, rl, hr) = opt_str(reference);
2817        self.emit(|b, out| unsafe { ffi::twig_builder_add_image(b, dp, dl, hd, rp, rl, hr, out) })
2818    }
2819
2820    /// Add a generic directive of the given form and name.
2821    pub fn add_directive(&mut self, form: DirectiveForm, name: &str) -> Result<NodeId, Error> {
2822        self.emit(|b, out| unsafe {
2823            ffi::twig_builder_add_directive(b, form.to_c(), name.as_ptr(), name.len(), out)
2824        })
2825    }
2826
2827    /// Add a generic named element (the escape hatch for HTML/XML tags).
2828    pub fn add_element(&mut self, name: &str) -> Result<NodeId, Error> {
2829        self.emit(|b, out| unsafe {
2830            ffi::twig_builder_add_element(b, name.as_ptr(), name.len(), out)
2831        })
2832    }
2833
2834    /// Add an XML processing instruction (`<?target data?>`).
2835    pub fn add_processing_instruction(
2836        &mut self,
2837        target: &str,
2838        data: &str,
2839    ) -> Result<NodeId, Error> {
2840        self.emit(|b, out| unsafe {
2841            ffi::twig_builder_add_processing_instruction(
2842                b,
2843                target.as_ptr(),
2844                target.len(),
2845                data.as_ptr(),
2846                data.len(),
2847                out,
2848            )
2849        })
2850    }
2851
2852    /// Add a footnote definition with the given label.
2853    pub fn add_footnote(&mut self, label: &str) -> Result<NodeId, Error> {
2854        self.emit(|b, out| unsafe {
2855            ffi::twig_builder_add_footnote(b, label.as_ptr(), label.len(), out)
2856        })
2857    }
2858
2859    /// Add a citation definition — reStructuredText's `.. [CIT2002] ...`. Holds
2860    /// blocks, like a footnote; the two differ in which name registry resolves
2861    /// them, which is why this is its own call and not a flag on
2862    /// [`Builder::add_footnote`].
2863    pub fn add_citation(&mut self, label: &str) -> Result<NodeId, Error> {
2864        self.emit(|b, out| unsafe {
2865            ffi::twig_builder_add_citation(b, label.as_ptr(), label.len(), out)
2866        })
2867    }
2868
2869    /// Add a substitution definition — reStructuredText's
2870    /// `.. |name| image:: p.png`. Unlike a footnote or citation, its children
2871    /// are INLINE nodes.
2872    pub fn add_substitution(&mut self, label: &str) -> Result<NodeId, Error> {
2873        self.emit(|b, out| unsafe {
2874            ffi::twig_builder_add_substitution(b, label.as_ptr(), label.len(), out)
2875        })
2876    }
2877
2878    /// Add a link/image reference definition (`label` → `destination`).
2879    pub fn add_reference(&mut self, label: &str, destination: &str) -> Result<NodeId, Error> {
2880        self.emit(|b, out| unsafe {
2881            ffi::twig_builder_add_reference(
2882                b,
2883                label.as_ptr(),
2884                label.len(),
2885                destination.as_ptr(),
2886                destination.len(),
2887                out,
2888            )
2889        })
2890    }
2891
2892    /// Add a bullet list.
2893    pub fn add_bullet_list(&mut self, style: BulletStyle, tight: bool) -> Result<NodeId, Error> {
2894        self.emit(|b, out| unsafe {
2895            ffi::twig_builder_add_bullet_list(b, style.to_c(), tight as c_int, out)
2896        })
2897    }
2898
2899    /// Add an ordered list, with an optional explicit start number.
2900    pub fn add_ordered_list(
2901        &mut self,
2902        numbering: OrderedNumbering,
2903        delim: OrderedDelim,
2904        tight: bool,
2905        start: Option<u32>,
2906    ) -> Result<NodeId, Error> {
2907        let (start_val, has_start) = match start {
2908            Some(s) => (s, 1),
2909            None => (0, 0),
2910        };
2911        self.emit(|b, out| unsafe {
2912            ffi::twig_builder_add_ordered_list(
2913                b,
2914                numbering.to_c(),
2915                delim.to_c(),
2916                tight as c_int,
2917                start_val,
2918                has_start,
2919                out,
2920            )
2921        })
2922    }
2923
2924    /// Add a task list.
2925    pub fn add_task_list(&mut self, tight: bool) -> Result<NodeId, Error> {
2926        self.emit(|b, out| unsafe { ffi::twig_builder_add_task_list(b, tight as c_int, out) })
2927    }
2928
2929    /// Add a task-list item with the given checkbox state.
2930    pub fn add_task_list_item(&mut self, checked: bool) -> Result<NodeId, Error> {
2931        self.emit(|b, out| unsafe {
2932            ffi::twig_builder_add_task_list_item(b, checked as c_int, out)
2933        })
2934    }
2935
2936    /// Add a table row (`head` marks a header row).
2937    pub fn add_row(&mut self, head: bool) -> Result<NodeId, Error> {
2938        self.emit(|b, out| unsafe { ffi::twig_builder_add_row(b, head as c_int, out) })
2939    }
2940
2941    /// Add a one-square table cell (`head` marks a header cell).
2942    pub fn add_cell(&mut self, head: bool, alignment: Alignment) -> Result<NodeId, Error> {
2943        self.emit(|b, out| unsafe {
2944            ffi::twig_builder_add_cell(b, head as c_int, alignment.to_c(), out)
2945        })
2946    }
2947
2948    /// Add a table cell occupying `colspan` columns and `rowspan` rows — a grid
2949    /// table's merged cell. Both must be at least 1
2950    /// ([`Error::InvalidArgument`] otherwise); `(1, 1)` is exactly
2951    /// [`Builder::add_cell`]. Read back with [`Document::cell_extent`].
2952    pub fn add_cell_spanning(
2953        &mut self,
2954        head: bool,
2955        alignment: Alignment,
2956        colspan: u32,
2957        rowspan: u32,
2958    ) -> Result<NodeId, Error> {
2959        self.emit(|b, out| unsafe {
2960            ffi::twig_builder_add_cell_spanning(
2961                b,
2962                head as c_int,
2963                alignment.to_c(),
2964                colspan,
2965                rowspan,
2966                out,
2967            )
2968        })
2969    }
2970
2971    /// Set `parent`'s children to `children` (in order), replacing any it had.
2972    /// Each child id should appear in exactly one `set_children` call.
2973    pub fn set_children(&mut self, parent: NodeId, children: &[NodeId]) -> Result<(), Error> {
2974        let ids: Vec<u32> = children.iter().map(|n| n.0).collect();
2975        let status = unsafe {
2976            ffi::twig_builder_set_children(self.raw.as_ptr(), parent.0, ids.as_ptr(), ids.len())
2977        };
2978        Error::from_status(status)
2979    }
2980
2981    /// Attach `{...}` attributes to `id` (`(key, Some(value))`, or
2982    /// `(key, None)` for a bare attribute), replacing any it had. An empty slice
2983    /// clears them.
2984    pub fn set_attrs(&mut self, id: NodeId, attrs: &[(&str, Option<&str>)]) -> Result<(), Error> {
2985        let kvs: Vec<ffi::TwigKeyVal> = attrs
2986            .iter()
2987            .map(|(k, v)| ffi::TwigKeyVal {
2988                key: k.as_ptr(),
2989                key_len: k.len(),
2990                value: v.map_or(std::ptr::null(), |s| s.as_ptr()),
2991                value_len: v.map_or(0, |s| s.len()),
2992            })
2993            .collect();
2994        let status = unsafe {
2995            ffi::twig_builder_set_attrs(self.raw.as_ptr(), id.0, kvs.as_ptr(), kvs.len())
2996        };
2997        Error::from_status(status)
2998    }
2999
3000    /// Render the subtree rooted at `root` to HTML (generic whole-vocabulary
3001    /// printer — a built tree has no djot/Markdown side tables).
3002    pub fn render_html(&mut self, root: NodeId) -> Result<Vec<u8>, Error> {
3003        let raw = self.raw.as_ptr();
3004        collect_bytes(|ptr, len| unsafe { ffi::twig_builder_render_html(raw, root.0, ptr, len) })
3005    }
3006
3007    /// Serialize the subtree rooted at `root` to `target`'s syntax. Returns
3008    /// [`Error::UnsupportedFormat`] when the target can't represent the built
3009    /// tree (e.g. semantic kinds into XML).
3010    ///
3011    /// Prefer this over [`Builder::serialize`], for the reason
3012    /// [`Document::serialize_to`] gives.
3013    pub fn serialize_to(&mut self, root: NodeId, target: Target) -> Result<Vec<u8>, Error> {
3014        let raw = self.raw.as_ptr();
3015        let ffi_target: ffi::TwigFormat = target.into();
3016        collect_bytes(|ptr, len| unsafe {
3017            ffi::twig_builder_serialize(raw, root.0, ffi_target as i32, ptr, len)
3018        })
3019    }
3020
3021    /// Serialize the subtree rooted at `root` to `format`'s source syntax.
3022    ///
3023    /// The original spelling of [`Builder::serialize_to`], kept for
3024    /// compatibility and defined in terms of it.
3025    pub fn serialize(&mut self, root: NodeId, format: Format) -> Result<Vec<u8>, Error> {
3026        self.serialize_to(root, format.into())
3027    }
3028
3029    /// Encode the subtree rooted at `root` as pretty-printed JSON.
3030    pub fn ast_json(&mut self, root: NodeId) -> Result<Vec<u8>, Error> {
3031        let raw = self.raw.as_ptr();
3032        collect_bytes(|ptr, len| unsafe { ffi::twig_builder_ast_json(raw, root.0, ptr, len) })
3033    }
3034
3035    /// Resolve a selector against the subtree rooted at `root` (same grammar as
3036    /// [`Document::query`]).
3037    pub fn query(&mut self, root: NodeId, selector: &str) -> Result<Vec<QueryMatch>, Error> {
3038        let raw = self.raw.as_ptr();
3039        collect_matches(|ptr, len| unsafe {
3040            ffi::twig_builder_query(raw, root.0, selector.as_ptr(), selector.len(), ptr, len)
3041        })
3042    }
3043
3044    /// Shared plumbing for the `add*` constructors: run `call` (which writes the
3045    /// new node's id) and wrap the result.
3046    fn emit(
3047        &mut self,
3048        call: impl FnOnce(*mut ffi::TwigBuilder, *mut u32) -> ffi::TwigStatus,
3049    ) -> Result<NodeId, Error> {
3050        let mut id: u32 = 0;
3051        let status = call(self.raw.as_ptr(), &mut id);
3052        Error::from_status(status)?;
3053        Ok(NodeId(id))
3054    }
3055}
3056
3057impl Drop for Builder {
3058    fn drop(&mut self) {
3059        unsafe { ffi::twig_builder_destroy(self.raw.as_ptr()) }
3060    }
3061}
3062
3063#[cfg(test)]
3064mod tests {
3065    use super::*;
3066
3067    #[test]
3068    fn abi_version_matches() {
3069        // The linked library must speak the exact ABI layout this crate's
3070        // `#[repr(C)]` mirrors assume. If this fails, the Zig `TWIG_ABI_VERSION`
3071        // was bumped without updating `ffi::TWIG_ABI_VERSION` (and the mirrors).
3072        assert_eq!(abi_version(), ffi::TWIG_ABI_VERSION);
3073    }
3074
3075    #[test]
3076    fn parses_and_renders_markdown_html() {
3077        let mut doc = Document::parse_str("# hi\n", Format::Markdown).expect("parse markdown");
3078        let html = doc.render_html().expect("render html");
3079        assert_eq!(String::from_utf8_lossy(&html), "<h1>hi</h1>\n");
3080    }
3081
3082    #[test]
3083    fn parses_html_input() {
3084        let mut doc = Document::parse_str("<p>hi</p>", Format::Html).expect("parse html");
3085        let html = doc.render_html().expect("render html");
3086        assert!(String::from_utf8_lossy(&html).contains("hi"));
3087    }
3088
3089    #[test]
3090    fn parses_asciidoc_and_refuses_to_write_it() {
3091        let mut doc = Document::parse_str("= Title\n\nsome *bold* text\n", Format::Asciidoc)
3092            .expect("parse asciidoc");
3093        let html = String::from_utf8_lossy(&doc.render_html().expect("render html")).into_owned();
3094        assert!(html.contains("<h1>Title</h1>"), "got {html:?}");
3095        assert!(html.contains("<strong>bold</strong>"), "got {html:?}");
3096
3097        // Nameable as a target, and always a runtime refusal: AsciiDoc has a
3098        // parser and no serializer. This is the case `Target`'s totality
3099        // exists for — the answer is `UnsupportedFormat`, not "unnameable".
3100        assert_eq!(
3101            doc.serialize_to(Target::Asciidoc),
3102            Err(Error::UnsupportedFormat)
3103        );
3104        assert_eq!(Target::from(Format::Asciidoc), Target::Asciidoc);
3105        assert_eq!(Target::Asciidoc.as_format(), Some(Format::Asciidoc));
3106    }
3107
3108    #[test]
3109    fn serialize_round_trips_and_cross_converts() {
3110        let mut doc = Document::parse_str("# hi\n", Format::Markdown).expect("parse markdown");
3111
3112        let canonical = doc.serialize(Format::Markdown).expect("serialize markdown");
3113        assert!(String::from_utf8_lossy(&canonical).contains("# hi"));
3114
3115        // Cross-format Markdown -> XML has no serializer.
3116        assert_eq!(doc.serialize(Format::Xml), Err(Error::UnsupportedFormat));
3117    }
3118
3119    #[test]
3120    fn serialize_markdown_to_djot() {
3121        let mut doc =
3122            Document::parse_str("This is *markdown*.\n", Format::Markdown).expect("parse markdown");
3123        let djot = doc.serialize(Format::Djot).expect("serialize djot");
3124        assert!(String::from_utf8_lossy(&djot).contains("_markdown_"));
3125    }
3126
3127    #[test]
3128    fn serialize_to_takes_the_output_axis() {
3129        let mut doc =
3130            Document::parse_str("This is *markdown*.\n", Format::Markdown).expect("parse markdown");
3131
3132        let djot = doc.serialize_to(Target::Djot).expect("serialize djot");
3133        assert!(String::from_utf8_lossy(&djot).contains("_markdown_"));
3134
3135        // The capability answer is the target's, not the input's: converting
3136        // INTO XML has no serializer regardless of what parsed the document.
3137        assert_eq!(doc.serialize_to(Target::Xml), Err(Error::UnsupportedFormat));
3138    }
3139
3140    #[test]
3141    fn serialize_and_serialize_to_agree() {
3142        // `serialize` is defined in terms of `serialize_to`, so the older
3143        // spelling stays exact rather than merely similar.
3144        let mut a = Document::parse_str("# hi\n", Format::Markdown).expect("parse markdown");
3145        let mut b = Document::parse_str("# hi\n", Format::Markdown).expect("parse markdown");
3146        for format in [Format::Markdown, Format::Djot, Format::Html] {
3147            assert_eq!(a.serialize(format), b.serialize_to(Target::from(format)));
3148        }
3149    }
3150
3151    #[test]
3152    fn every_format_is_a_target_that_names_it_back() {
3153        // The subset invariant the Zig `targets` table enforces, restated at
3154        // this layer: `Target::from` is total, and `as_format` round-trips it.
3155        for format in [Format::Djot, Format::Markdown, Format::Xml, Format::Html] {
3156            assert_eq!(Target::from(format).as_format(), Some(format));
3157        }
3158    }
3159
3160    #[test]
3161    fn ast_json_dumps_the_tree() {
3162        let mut doc = Document::parse_str("hello\n", Format::Djot).expect("parse djot");
3163        let json = doc.ast_json().expect("ast json");
3164        assert!(String::from_utf8_lossy(&json).contains("\"kind\": \"doc\""));
3165    }
3166
3167    #[test]
3168    fn query_finds_nodes_by_selector() {
3169        let source = "# One\n\n## Two\n";
3170        let mut doc = Document::parse_str(source, Format::Markdown).expect("parse markdown");
3171        let matches = doc.query("heading").expect("query");
3172
3173        assert_eq!(matches.len(), 2);
3174        for m in &matches {
3175            assert_eq!(m.kind, Kind::Heading);
3176            assert!(m.span.start < m.span.end);
3177        }
3178    }
3179
3180    #[test]
3181    fn query_recovers_code_spans() {
3182        let source = "prose `code` more prose\n";
3183        let mut doc = Document::parse_str(source, Format::Markdown).expect("parse markdown");
3184        let matches = doc.query("verbatim").expect("query");
3185
3186        assert_eq!(matches.len(), 1);
3187        assert_eq!(&source[matches[0].span.clone()], "`code`");
3188    }
3189
3190    #[test]
3191    fn document_span_accessors_read_by_node_id() {
3192        let source = "# hi\n\ntext\n";
3193        let mut doc = Document::parse_str(source, Format::Markdown).expect("parse markdown");
3194        let heading = doc.query("heading").expect("query").pop().expect("heading");
3195
3196        assert_eq!(
3197            doc.span(NodeId(heading.node_id)).expect("span"),
3198            heading.span
3199        );
3200        assert_eq!(
3201            doc.content_span(NodeId(heading.node_id))
3202                .expect("content span"),
3203            heading.content_span
3204        );
3205        assert_eq!(doc.span(NodeId(u32::MAX)), Err(Error::InvalidArgument));
3206    }
3207
3208    #[test]
3209    fn document_walks_its_tree_without_an_editor() {
3210        let source = "# hi\n\ntext\n";
3211        let mut doc = Document::parse_str(source, Format::Markdown).expect("parse markdown");
3212
3213        let nodes = doc.nodes().expect("nodes");
3214        assert!(nodes.len() >= 3);
3215        for (i, n) in nodes.iter().enumerate() {
3216            assert_eq!(n.id, NodeId(i as u32));
3217        }
3218
3219        let kids = doc.children(None).expect("children");
3220        assert_eq!(kids.len(), 2);
3221        assert_eq!(kids[0].kind, Kind::Heading);
3222
3223        let sub = doc.subtree(NodeId(kids[0].node_id)).expect("subtree");
3224        assert_eq!(sub[0].id, NodeId(0));
3225        assert_eq!(sub[0].parent, None);
3226        assert_eq!(sub[0].span, kids[0].span);
3227
3228        let hit = doc.node_at(2).expect("node_at").expect("a node at 2");
3229        let chain = doc.ancestors_at(2).expect("ancestors");
3230        assert_eq!(chain.last().expect("deepest").node_id, hit.node_id);
3231        assert_eq!(chain[0].kind, Kind::Doc);
3232
3233        assert_eq!(doc.subtree(NodeId(u32::MAX)), Err(Error::InvalidArgument));
3234    }
3235
3236    #[test]
3237    fn editor_document_view_reads_the_live_tree() {
3238        let mut ed = Editor::new_str("# one\n\ntwo\n", Format::Markdown).expect("editor");
3239
3240        {
3241            let mut view = ed.document().expect("view");
3242            let kids = view.children(None).expect("children");
3243            assert_eq!(kids.len(), 2);
3244            assert_eq!(kids[0].kind, Kind::Heading);
3245            assert_eq!(view.span(NodeId(kids[0].node_id)).expect("span"), 0..5);
3246            // The two the view can't serve.
3247            assert_eq!(view.render_html(), Err(Error::UnsupportedFormat));
3248            assert_eq!(
3249                view.serialize(Format::Markdown),
3250                Err(Error::UnsupportedFormat)
3251            );
3252        }
3253
3254        ed.replace("0", "# one and a half").expect("replace");
3255        let mut view = ed.document().expect("view");
3256        let kids = view.children(None).expect("children");
3257        assert_eq!(view.span(NodeId(kids[0].node_id)).expect("span"), 0..16);
3258    }
3259
3260    #[test]
3261    fn query_rejects_a_malformed_selector() {
3262        let mut doc = Document::parse_str("hi\n", Format::Markdown).expect("parse markdown");
3263        assert_eq!(doc.query("list >"), Err(Error::InvalidArgument));
3264    }
3265
3266    #[test]
3267    fn editor_edits_by_index_path() {
3268        let mut ed = Editor::new_str("<a><b>hi</b></a>", Format::Xml).expect("editor");
3269        ed.replace_content("0.0", "bye").expect("replace_content");
3270        assert_eq!(ed.source_str().expect("source"), "<a><b>bye</b></a>");
3271    }
3272
3273    #[test]
3274    fn flat_nodes_expose_element_name_and_attrs() {
3275        // A `<picture>` with a theme-switching `<source>`: the dark alternative
3276        // lives only in the `<source>`'s attributes, which the snapshot now
3277        // surfaces (both `<picture>` and `<source>` report `kind == "container"`).
3278        let src = "<picture><source media=\"(prefers-color-scheme: dark)\" srcset=\"d.svg\"><img src=\"l.svg\" alt=\"x\"></picture>\n";
3279        let mut ed = Editor::new_ext(
3280            src.as_bytes(),
3281            Format::Markdown,
3282            MarkdownExtensions {
3283                html_elements: true,
3284                ..Default::default()
3285            },
3286        )
3287        .expect("editor");
3288        let nodes = ed.nodes().expect("nodes");
3289
3290        let source = nodes
3291            .iter()
3292            .find(|n| n.name.as_deref() == Some("source"))
3293            .expect("a <source> element node");
3294        assert_eq!(
3295            source.attrs,
3296            vec![
3297                (
3298                    "media".to_string(),
3299                    Some("(prefers-color-scheme: dark)".to_string())
3300                ),
3301                ("srcset".to_string(), Some("d.svg".to_string())),
3302            ]
3303        );
3304
3305        // The `<img>` fallback stays an `image` node (no element name), and its
3306        // `src` is the ordinary `destination`.
3307        let img = nodes
3308            .iter()
3309            .find(|n| n.kind == Kind::Image)
3310            .expect("an image node");
3311        assert!(img.name.is_none());
3312        assert_eq!(img.destination.as_deref(), Some("l.svg"));
3313
3314        // A semantic node carries neither an element name nor attributes.
3315        let picture_kids_str = nodes.iter().find(|n| n.kind == Kind::Str);
3316        if let Some(s) = picture_kids_str {
3317            assert!(s.name.is_none() && s.attrs.is_empty());
3318        }
3319    }
3320
3321    #[test]
3322    fn definitions_finds_what_a_walk_from_the_root_cannot() {
3323        // Both definitions are resolved by label, so neither is anybody's
3324        // child: the document root's subtree contains the paragraph and
3325        // nothing else.
3326        let mut doc = Document::parse_str(
3327            "text[^1] [x][a]\n\n[^1]: note\n\n[a]: /u\n",
3328            Format::Markdown,
3329        )
3330        .expect("parse markdown");
3331
3332        let defs = doc.definitions().expect("definitions");
3333        let mut kinds: Vec<Kind> = defs.iter().map(|m| m.kind.clone()).collect();
3334        kinds.sort_by(|a, b| a.as_str().cmp(b.as_str()));
3335        assert_eq!(kinds, vec![Kind::Footnote, Kind::Reference]);
3336
3337        // None of them is reachable from the root — the property that made a
3338        // whole-arena rescan the only way to find them.
3339        let all = doc.nodes().expect("nodes");
3340        let root = all
3341            .iter()
3342            .find(|n| n.kind == Kind::Doc)
3343            .expect("a doc root");
3344        let mut reachable = vec![root.id];
3345        let mut i = 0;
3346        while i < reachable.len() {
3347            let n = &all[reachable[i].0 as usize];
3348            let mut c = n.first_child;
3349            while let Some(cid) = c {
3350                reachable.push(cid);
3351                c = all[cid.0 as usize].next_sibling;
3352            }
3353            i += 1;
3354        }
3355        for d in &defs {
3356            assert!(
3357                !reachable.contains(&NodeId(d.node_id)),
3358                "{} should be unreachable from the root",
3359                d.kind
3360            );
3361        }
3362
3363        // A document that defines nothing gets an empty vec, not an error.
3364        let mut plain = Document::parse_str("just text\n", Format::Markdown).expect("parse");
3365        assert_eq!(plain.definitions().expect("definitions"), Vec::new());
3366    }
3367
3368    #[test]
3369    fn kind_round_trips_through_its_published_name() {
3370        // `as_str` is the wire vocabulary and `from` is its inverse, so any
3371        // variant whose spelling drifts from the C ABI's fails here rather
3372        // than quietly becoming `Other`.
3373        for k in [
3374            Kind::Doc,
3375            Kind::Para,
3376            Kind::Heading,
3377            Kind::Container,
3378            Kind::TaskListItem,
3379            Kind::Superscript,
3380            Kind::FootnoteReference,
3381            Kind::ProcessingInstruction,
3382            Kind::Cdata,
3383        ] {
3384            assert_eq!(Kind::from(k.as_str()), k, "{k} did not round-trip");
3385            assert!(!k.is_unknown());
3386        }
3387    }
3388
3389    #[test]
3390    fn an_unknown_kind_name_is_carried_rather_than_lost() {
3391        // A newer library against an older binding. The node is still a node,
3392        // and a renderer that passes it through unchanged should be able to.
3393        let k = Kind::from("some_future_kind");
3394        assert!(k.is_unknown());
3395        assert_eq!(k.as_str(), "some_future_kind");
3396        assert_eq!(k, Kind::Other("some_future_kind".to_string()));
3397    }
3398
3399    #[test]
3400    fn every_kind_the_library_publishes_has_a_variant() {
3401        // Walks documents covering every corner of the vocabulary this crate
3402        // can reach from Rust and asserts nothing arrives as `Other`. If twig
3403        // adds a kind, or renames one, this fails — which is the whole reason
3404        // the enum is here instead of a `String`.
3405        let cases: &[(&str, Format, MarkdownExtensions)] = &[
3406            (
3407                "# h\n\npara *emph* **strong** `code`\n\n- a\n- b\n\n1. c\n\n> q\n\n---\n\n```zig\nx\n```\n",
3408                Format::Markdown,
3409                MarkdownExtensions {
3410                    directives: false,
3411                    math: false,
3412                    html_elements: false,
3413                },
3414            ),
3415            (
3416                "| a | b |\n| --- | --- |\n| 1 | 2 |\n\n- [ ] task\n- [x] done\n\nfoot[^1]\n\n[^1]: note\n\n[l]: /u\n\n[x][l]\n",
3417                Format::Markdown,
3418                MarkdownExtensions::default(),
3419            ),
3420            (
3421                ":::note\nbody\n:::\n\n:role[x]\n\n$a+b$\n",
3422                Format::Markdown,
3423                MarkdownExtensions {
3424                    directives: true,
3425                    math: true,
3426                    html_elements: false,
3427                },
3428            ),
3429            (
3430                "a^b^ c~d~ {=e=} {+f+} {-g-} 'q' \"dq\"\n\n![i](/p)\n\n<https://e.com>\n",
3431                Format::Djot,
3432                MarkdownExtensions::default(),
3433            ),
3434            (
3435                "<!-- c --><!DOCTYPE html><video controls><p>x</p></video>",
3436                Format::Html,
3437                MarkdownExtensions::default(),
3438            ),
3439        ];
3440
3441        let mut unknown: Vec<String> = Vec::new();
3442        let mut seen: Vec<String> = Vec::new();
3443        for (src, format, ext) in cases {
3444            let mut ed = Editor::new_ext(src.as_bytes(), *format, *ext).expect("editor");
3445            for n in ed.nodes().expect("nodes") {
3446                if n.kind.is_unknown() {
3447                    unknown.push(n.kind.as_str().to_string());
3448                }
3449                seen.push(n.kind.as_str().to_string());
3450            }
3451        }
3452        unknown.sort();
3453        unknown.dedup();
3454        assert!(unknown.is_empty(), "kinds with no variant: {unknown:?}");
3455
3456        // And the sweep really swept: without this the assertion above passes
3457        // just as happily on an empty walk.
3458        seen.sort();
3459        seen.dedup();
3460        assert!(
3461            seen.len() >= 30,
3462            "only {} distinct kinds reached: {seen:?}",
3463            seen.len()
3464        );
3465    }
3466
3467    #[test]
3468    fn diagnostics_report_what_a_conversion_would_lose() {
3469        // A djot superscript has no Markdown spelling. The two answers below
3470        // are for the SAME document — fidelity is a property of the
3471        // (document, target) pair, which is why it is asked per target.
3472        let mut doc = Document::parse_str("a^b^ c\n", Format::Djot).expect("parse djot");
3473
3474        let to_md = doc
3475            .diagnostics(Target::Markdown)
3476            .expect("markdown diagnostics");
3477        assert_eq!(
3478            to_md,
3479            vec![Warning {
3480                fidelity: Fidelity::Degraded,
3481                path: "0/1".to_string(),
3482                kind: Kind::Superscript,
3483            }]
3484        );
3485
3486        // Lossless to djot: an empty vec is a real answer, not a failure.
3487        assert_eq!(
3488            doc.diagnostics(Target::Djot).expect("djot diagnostics"),
3489            Vec::new()
3490        );
3491    }
3492
3493    #[test]
3494    fn diagnostics_separate_a_droppable_node_from_a_degradable_one() {
3495        // An HTML comment converted to djot leaves NOTHING behind — a
3496        // different and worse answer than "comes back as something else", and
3497        // the distinction a consumer needs to decide whether to warn or refuse.
3498        let mut doc =
3499            Document::parse_str("<p>hi</p><!-- secret -->", Format::Html).expect("parse html");
3500        let warnings = doc.diagnostics(Target::Djot).expect("djot diagnostics");
3501        let comment = warnings
3502            .iter()
3503            .find(|w| w.kind == Kind::Comment)
3504            .expect("a warning about the comment");
3505        assert_eq!(comment.fidelity, Fidelity::Dropped);
3506    }
3507
3508    #[test]
3509    fn diagnostics_refuse_a_target_with_no_serializer() {
3510        // "This target cannot be written" is a capability answer, not a
3511        // per-node diagnosis of every node in the document.
3512        let mut doc = Document::parse_str("# hi\n", Format::Markdown).expect("parse markdown");
3513        assert_eq!(doc.diagnostics(Target::Xml), Err(Error::UnsupportedFormat));
3514        assert_eq!(
3515            doc.diagnostics(Target::Asciidoc),
3516            Err(Error::UnsupportedFormat)
3517        );
3518    }
3519
3520    #[test]
3521    fn diagnostics_flag_a_header_less_table_and_leave_a_headed_one_alone() {
3522        // The instance-level answer, and the one a consumer cannot reach by
3523        // looking at kinds: both documents contain a `table`, and only one of
3524        // them costs anything to convert. GFM's delimiter row is mandatory, so
3525        // the header-less table gets an empty header synthesized above it.
3526        let mut headed = Document::parse_str(
3527            "<table><tr><th>H</th></tr><tr><td>a</td></tr></table>",
3528            Format::Html,
3529        )
3530        .expect("parse headed table");
3531        assert!(
3532            headed
3533                .diagnostics(Target::Markdown)
3534                .expect("diagnostics")
3535                .iter()
3536                .all(|w| w.kind != Kind::Table)
3537        );
3538
3539        let mut headless = Document::parse_str("<table><tr><td>a</td></tr></table>", Format::Html)
3540            .expect("parse header-less table");
3541        let table_warning = headless
3542            .diagnostics(Target::Markdown)
3543            .expect("diagnostics")
3544            .into_iter()
3545            .find(|w| w.kind == Kind::Table)
3546            .expect("a warning about the table");
3547        assert_eq!(table_warning.fidelity, Fidelity::Degraded);
3548    }
3549
3550    #[test]
3551    fn container_origin_separates_a_div_from_a_div() {
3552        // The collision this field exists for. These two documents produce
3553        // container nodes that agree on `kind`, on `name` AND on
3554        // `directive_form` — so a consumer holding one of them could not say
3555        // which syntax the author wrote without re-reading the source bytes.
3556        let mut html =
3557            Editor::new("<div>hi</div>\n".as_bytes(), Format::Html).expect("html editor");
3558        let mut md = Editor::new_ext(
3559            ":::div\nhi\n:::\n".as_bytes(),
3560            Format::Markdown,
3561            MarkdownExtensions {
3562                directives: true,
3563                ..Default::default()
3564            },
3565        )
3566        .expect("markdown editor");
3567
3568        let html_nodes = html.nodes().expect("html nodes");
3569        let md_nodes = md.nodes().expect("markdown nodes");
3570        let tag = html_nodes
3571            .iter()
3572            .find(|n| n.name.as_deref() == Some("div"))
3573            .expect("a <div> container");
3574        let directive = md_nodes
3575            .iter()
3576            .find(|n| n.name.as_deref() == Some("div"))
3577            .expect("a :::div container");
3578
3579        // Indistinguishable on every field that predates `origin`.
3580        assert_eq!(tag.kind, directive.kind);
3581        assert_eq!(tag.name, directive.name);
3582        assert_eq!(tag.directive_form, directive.directive_form);
3583        assert_eq!(tag.directive_form, Some(DirectiveForm::Container));
3584
3585        // And decidable now.
3586        assert_eq!(tag.origin, Some(ContainerOrigin::Element));
3587        assert_eq!(directive.origin, Some(ContainerOrigin::Directive));
3588    }
3589
3590    /// Parse `src` as both authorable formats and run `check` over each — the
3591    /// shape every test below wants, because the point of these two APIs is
3592    /// that a consumer cannot tell which parser produced the tree.
3593    fn for_both_formats(src: &str, check: impl Fn(&mut Document, Format)) {
3594        for format in [Format::Markdown, Format::Djot] {
3595            let mut doc = Document::parse(src.as_bytes(), format).expect("parse");
3596            check(&mut doc, format);
3597        }
3598    }
3599
3600    #[test]
3601    fn marker_span_is_what_a_rich_view_hides() {
3602        for_both_formats("> - [x] done\n", |doc, format| {
3603            let nodes = doc.nodes().expect("nodes");
3604            let quote = nodes
3605                .iter()
3606                .find(|n| n.kind == Kind::BlockQuote)
3607                .expect("a block quote");
3608            let item = nodes
3609                .iter()
3610                .find(|n| n.kind == Kind::TaskListItem)
3611                .expect("a task item");
3612
3613            // The quote's `> ` and the item's `- [x] ` — the item's marker
3614            // takes its checkbox with it, because the rendered view draws a
3615            // checkbox in PLACE of those bytes rather than beside them.
3616            assert_eq!(quote.marker_span, Some(0..2), "{format:?}");
3617            assert_eq!(item.marker_span, Some(2..8), "{format:?}");
3618
3619            // Not derivable from the other two spans: a marker-prefixed
3620            // container reports its whole extent as its interior, so the
3621            // subtraction a caller might reach for yields nothing.
3622            assert_eq!(quote.content_span, Some(quote.span.clone()), "{format:?}");
3623
3624            // A paragraph has no marker of its own; only its ancestors do.
3625            let para = nodes
3626                .iter()
3627                .find(|n| n.kind == Kind::Para)
3628                .expect("a paragraph");
3629            assert_eq!(para.marker_span, None, "{format:?}");
3630        });
3631    }
3632
3633    #[test]
3634    fn line_prefix_assembles_every_marker_on_the_line() {
3635        for_both_formats("> - [x] done\n", |doc, format| {
3636            // Four nodes' worth of hidden width as one range, which is what a
3637            // caret stepping over it needs — not a chain to stitch together.
3638            assert_eq!(doc.line_prefix(9).expect("prefix"), Some(0..8), "{format:?}");
3639        });
3640    }
3641
3642    #[test]
3643    fn line_prefix_is_none_on_a_continuation_line() {
3644        // Line two continues the quote but OPENS nothing. `None` is the honest
3645        // answer: what a continuation line repeats is a different question with
3646        // a different answer, and guessing it from marker spans is how an
3647        // editor ends up restructuring a document that never had the shape it
3648        // inferred.
3649        for_both_formats("> c\n> d\n", |doc, format| {
3650            assert_eq!(doc.line_prefix(2).expect("prefix"), Some(0..2), "{format:?}");
3651            assert_eq!(doc.line_prefix(6).expect("prefix"), None, "{format:?}");
3652        });
3653    }
3654
3655    #[test]
3656    fn a_caret_at_a_blocks_end_is_in_that_block_in_both_formats() {
3657        // The divergence this API exists for. Djot ends a paragraph's span
3658        // AFTER its newline and Markdown BEFORE it, so under half-open
3659        // containment offset 1 — the caret you get by pressing End on line one,
3660        // the commonest caret position there is — resolved to the paragraph
3661        // through Djot and to the root through Markdown.
3662        for_both_formats("a\n\nb\n", |doc, format| {
3663            for offset in [0usize, 1, 3, 4] {
3664                let hit = doc
3665                    .node_at_caret(offset)
3666                    .expect("caret hit")
3667                    .expect("some node");
3668                assert_eq!(hit.kind, Kind::Str, "{format:?} at {offset}");
3669            }
3670            // The blank line between the two blocks belongs to neither, and
3671            // neither does the empty line after the final newline.
3672            for offset in [2usize, 5] {
3673                let hit = doc
3674                    .node_at_caret(offset)
3675                    .expect("caret hit")
3676                    .expect("some node");
3677                assert_eq!(hit.kind, Kind::Doc, "{format:?} at {offset}");
3678            }
3679        });
3680    }
3681
3682    #[test]
3683    fn the_caret_chain_ends_at_the_node_the_scalar_call_returns() {
3684        for_both_formats("- a\n", |doc, format| {
3685            let hit = doc.node_at_caret(3).expect("hit").expect("some node");
3686            let chain = doc.ancestors_at_caret(3).expect("chain");
3687            assert_eq!(chain.last().map(|m| m.node_id), Some(hit.node_id), "{format:?}");
3688            // And the chain passes through the item, which is what a gesture
3689            // scoped to "the block I'm in" needs at a caret sitting at its end.
3690            assert!(
3691                chain.iter().any(|m| m.kind == Kind::ListItem),
3692                "{format:?}: chain should reach the list item"
3693            );
3694        });
3695    }
3696
3697    #[test]
3698    fn continuation_prefix_repeats_a_quote_and_indents_past_an_item() {
3699        for_both_formats("> - a\n", |doc, format| {
3700            // The bytes already on the line, and the bytes a continuation would
3701            // need. They differ exactly where an editor gets it wrong by hand:
3702            // the item's `- ` is PRESENT and must not be repeated, or the
3703            // continuation opens a second item instead of continuing the first.
3704            assert_eq!(doc.line_prefix(4).expect("prefix"), Some(0..4), "{format:?}");
3705            let cont = doc.continuation_prefix(4).expect("continuation");
3706            assert_eq!(cont.text, ">   ", "{format:?}");
3707            assert_eq!(cont.columns, 4, "{format:?}");
3708        });
3709    }
3710
3711    #[test]
3712    fn continuation_prefix_answers_on_a_line_that_opens_nothing() {
3713        // The case `line_prefix` declines. Each ancestor answers from its own
3714        // opening line, so the quote's marker is still found on line one.
3715        for_both_formats("> c\n> d\n", |doc, format| {
3716            assert_eq!(doc.line_prefix(6).expect("prefix"), None, "{format:?}");
3717            assert_eq!(
3718                doc.continuation_prefix(6).expect("continuation").text,
3719                "> ",
3720                "{format:?}"
3721            );
3722        });
3723    }
3724
3725    #[test]
3726    fn continuation_prefix_takes_an_ordered_markers_own_width() {
3727        // `10. ` is four columns where `1. ` is three. A fixed indent is the
3728        // assumption that makes Tab wrong on the tenth item.
3729        for_both_formats("10. x\n", |doc, format| {
3730            assert_eq!(
3731                doc.continuation_prefix(4).expect("continuation").columns,
3732                4,
3733                "{format:?}"
3734            );
3735        });
3736        for_both_formats("1. x\n", |doc, format| {
3737            assert_eq!(
3738                doc.continuation_prefix(3).expect("continuation").columns,
3739                3,
3740                "{format:?}"
3741            );
3742        });
3743    }
3744
3745    #[test]
3746    fn a_blank_line_keeps_a_quote_alive_and_drops_an_items_indent() {
3747        for_both_formats("> - a\n", |doc, format| {
3748            let blank = doc.blank_line_prefix(4).expect("blank");
3749            // `>` and not `> `: the space after the marker is content indent,
3750            // and a blank line has no content.
3751            assert_eq!(blank.text, ">", "{format:?}");
3752            assert_eq!(blank.columns, 1, "{format:?}");
3753        });
3754        // Inside a list alone there is nothing to keep alive, so a blank line
3755        // carries nothing at all.
3756        for_both_formats("- a\n", |doc, format| {
3757            assert_eq!(doc.blank_line_prefix(3).expect("blank").text, "", "{format:?}");
3758        });
3759    }
3760
3761    #[test]
3762    fn a_prefix_column_count_is_not_its_byte_length() {
3763        // A tab in a marker advances to a tab stop, so the two diverge — which
3764        // is why `columns` is carried rather than left to the caller to infer.
3765        let mut doc = Document::parse("-	x
3766".as_bytes(), Format::Markdown).expect("parse");
3767        let cont = doc.continuation_prefix(2).expect("continuation");
3768        assert_eq!(cont.columns, 4);
3769    }
3770
3771    #[test]
3772    fn set_block_opens_a_heading_on_a_blank_line() {
3773        for format in [Format::Markdown, Format::Djot] {
3774            let mut ed = Editor::new("a\n\n".as_bytes(), format).expect("editor");
3775            ed.set_block(3, BlockKind::Heading(2)).expect("set_block");
3776            assert_eq!(ed.source().expect("source"), b"a\n\n## ", "{format:?}");
3777            // The reparse is the assertion that matters, not the bytes: Djot
3778            // does not let a heading interrupt a paragraph, so a marker written
3779            // without the separating blank would come back as literal text.
3780            let nodes = ed.nodes().expect("nodes");
3781            assert!(
3782                nodes.iter().any(|n| n.kind == Kind::Heading),
3783                "{format:?}: should have parsed a heading"
3784            );
3785        }
3786    }
3787
3788    #[test]
3789    fn set_block_refuses_a_blank_line_inside_a_code_block() {
3790        // `innermostBlock` reports nothing here exactly as it does between
3791        // blocks; only the line's owner tells them apart. Writing `# ` in would
3792        // add no heading and corrupt the listing.
3793        for format in [Format::Markdown, Format::Djot] {
3794            let src = "```\nx\n\ny\n```\n";
3795            let mut ed = Editor::new(src.as_bytes(), format).expect("editor");
3796            let blank = src.find("\n\n").expect("a blank line") + 1;
3797            assert!(
3798                matches!(
3799                    ed.set_block(blank, BlockKind::Heading(1)),
3800                    Err(Error::NotEditable)
3801                ),
3802                "{format:?}"
3803            );
3804            assert_eq!(ed.source().expect("source"), src.as_bytes(), "{format:?}");
3805        }
3806    }
3807
3808    #[test]
3809    fn task_items_report_their_checkbox_state() {
3810        // Twig would WRITE a checkbox and not read one back, so a consumer
3811        // rendering a clickable box re-derived the state by scanning for `[x]`.
3812        // A capital `[X]` is checked too, which that scan misses.
3813        for_both_formats("- [ ] a\n- [x] b\n- [X] c\n- d\n", |doc, format| {
3814            let nodes = doc.nodes().expect("nodes");
3815            let states: Vec<Option<bool>> = nodes
3816                .iter()
3817                .filter(|n| matches!(n.kind, Kind::TaskListItem | Kind::ListItem))
3818                .map(|n| n.checked)
3819                .collect();
3820            assert_eq!(
3821                states,
3822                vec![Some(false), Some(true), Some(true), None],
3823                "{format:?}"
3824            );
3825
3826            // `None` is not `Some(false)`: a consumer treating "not a task
3827            // item" as unchecked draws an empty box beside every paragraph.
3828            for n in nodes.iter().filter(|n| n.kind == Kind::Para) {
3829                assert_eq!(n.checked, None, "{format:?}");
3830            }
3831        });
3832    }
3833
3834    #[test]
3835    fn an_editor_reaches_the_caret_reads_through_its_document_view() {
3836        // The path an editing host actually takes. These reads are questions
3837        // about a TREE, not about an editing session, so they live on the
3838        // document surface and an editor borrows it — no `twig_editor_*` alias
3839        // to keep in step. See DESIGN.md, "The reads are not editor-specific."
3840        let mut ed = Editor::new("- a\n".as_bytes(), Format::Markdown).expect("editor");
3841        let mut view = ed.document().expect("document view");
3842
3843        assert_eq!(view.line_prefix(3).expect("prefix"), Some(0..2));
3844        let hit = view.node_at_caret(3).expect("hit").expect("some node");
3845        assert_eq!(hit.kind, Kind::Str);
3846    }
3847
3848    #[test]
3849    fn container_origin_is_none_for_non_containers() {
3850        // The field is a container's, so everything else reports `None` rather
3851        // than a default that would read as a real answer.
3852        let mut ed = Editor::new("# hi\n\npara\n".as_bytes(), Format::Markdown).expect("editor");
3853        for n in ed.nodes().expect("nodes") {
3854            assert_eq!(n.origin, None, "{} should carry no origin", n.kind);
3855        }
3856    }
3857
3858    #[test]
3859    fn flat_nodes_expose_directive_name_and_form() {
3860        // All three surface forms report `kind == "container"`, so the snapshot
3861        // has to carry both halves of a directive's identity: which type it is
3862        // (`name`) and how it was written (`directive_form`). Without them a
3863        // renderer can't tell an `::embed` from a `::toc`, nor an inline span
3864        // from a standalone block.
3865        let src = ":::note{.warning}\nBody\n:::\n\n::embed{src=\"demo.html\"}\n\nSee :abbr[HTML]{title=\"HyperText\"} inline.\n";
3866        let mut ed = Editor::new_ext(
3867            src.as_bytes(),
3868            Format::Markdown,
3869            MarkdownExtensions {
3870                directives: true,
3871                ..Default::default()
3872            },
3873        )
3874        .expect("editor");
3875        let nodes = ed.nodes().expect("nodes");
3876
3877        let forms: Vec<(Option<&str>, Option<DirectiveForm>)> = nodes
3878            .iter()
3879            .filter(|n| n.kind == Kind::Container)
3880            .map(|n| (n.name.as_deref(), n.directive_form))
3881            .collect();
3882        assert_eq!(
3883            forms,
3884            vec![
3885                (Some("note"), Some(DirectiveForm::Container)),
3886                (Some("embed"), Some(DirectiveForm::Leaf)),
3887                (Some("abbr"), Some(DirectiveForm::Text)),
3888            ]
3889        );
3890
3891        // The attributes still ride the ordinary side-table, and a non-directive
3892        // reports no form at all.
3893        let embed = nodes
3894            .iter()
3895            .find(|n| n.name.as_deref() == Some("embed"))
3896            .expect("embed");
3897        assert_eq!(
3898            embed.attrs,
3899            vec![("src".to_string(), Some("demo.html".to_string()))]
3900        );
3901        let para = nodes.iter().find(|n| n.kind == Kind::Para).expect("a para");
3902        assert!(para.directive_form.is_none() && para.name.is_none());
3903    }
3904
3905    #[test]
3906    fn editor_insert_child_and_delete() {
3907        let mut ed = Editor::new_str("<r><a/><c/></r>", Format::Xml).expect("editor");
3908        ed.insert_child("0", 1, "<b/>").expect("insert_child");
3909        assert_eq!(ed.source_str().expect("source"), "<r><a/><b/><c/></r>");
3910        ed.delete("0.1").expect("delete");
3911        assert_eq!(ed.source_str().expect("source"), "<r><a/><c/></r>");
3912    }
3913
3914    #[test]
3915    fn editor_edits_by_selector() {
3916        let mut ed = Editor::new_str("# One\n\n## Two\n", Format::Markdown).expect("editor");
3917        ed.replace("heading(\"Two\")", "## Renamed")
3918            .expect("replace");
3919        assert_eq!(ed.source_str().expect("source"), "# One\n\n## Renamed\n");
3920    }
3921
3922    #[test]
3923    fn editor_locator_errors_are_distinct() {
3924        let mut ed = Editor::new_str("<r><a/><a/></r>", Format::Xml).expect("editor");
3925        assert_eq!(ed.replace("0.9", "x"), Err(Error::NotFound));
3926        assert_eq!(ed.replace("element", "x"), Err(Error::Ambiguous));
3927        assert_eq!(ed.replace("element(", "x"), Err(Error::InvalidArgument));
3928        // Untouched by the failed edits.
3929        assert_eq!(ed.source_str().expect("source"), "<r><a/><a/></r>");
3930    }
3931
3932    #[test]
3933    fn editor_reparse_break_rolls_back() {
3934        let mut ed = Editor::new_str("<a>ok</a>", Format::Xml).expect("editor");
3935        assert_eq!(ed.replace_content("0", "<b>"), Err(Error::EditConflict));
3936        assert_eq!(ed.source_str().expect("source"), "<a>ok</a>");
3937    }
3938
3939    #[test]
3940    fn editor_leaf_content_is_not_editable() {
3941        let mut ed = Editor::new_str("<a>hi</a>", Format::Xml).expect("editor");
3942        assert_eq!(ed.replace_content("0.0", "x"), Err(Error::NotEditable));
3943    }
3944
3945    #[test]
3946    fn editor_query_reflects_current_tree() {
3947        let mut ed = Editor::new_str("<r><a/></r>", Format::Xml).expect("editor");
3948        ed.insert_child("0", 1, "<b/>").expect("insert_child");
3949        // Root <r> plus <a/> and <b/>.
3950        assert_eq!(ed.query("element").expect("query").len(), 3);
3951        let json = ed.ast_json().expect("ast_json");
3952        assert!(String::from_utf8_lossy(&json).contains("\"kind\": \"doc\""));
3953    }
3954
3955    // ── offset-addressed editing & read-back (P0–P3) ────────────────────────
3956
3957    #[test]
3958    fn editor_edit_range_types_backspaces_and_reports_change() {
3959        let mut ed = Editor::new_str("ab\n", Format::Markdown).expect("editor");
3960
3961        // Type "X" at offset 1 (a zero-width splice = an insertion).
3962        let c = ed.edit_range(1, 1, "X").expect("edit_range insert");
3963        assert_eq!(ed.source_str().unwrap(), "aXb\n");
3964        assert_eq!(c.old, 1..1);
3965        assert_eq!(c.new, 1..2);
3966        assert_eq!(c.delta(), 1);
3967
3968        // Backspace it (delete the "X").
3969        let c2 = ed.edit_range(1, 2, "").expect("edit_range delete");
3970        assert_eq!(ed.source_str().unwrap(), "ab\n");
3971        assert_eq!(c2.old, 1..2);
3972        assert_eq!(c2.new, 1..1);
3973        assert_eq!(c2.delta(), -1);
3974    }
3975
3976    #[test]
3977    fn editor_edit_range_rejects_bad_ranges() {
3978        let mut ed = Editor::new_str("hi\n", Format::Markdown).expect("editor");
3979        assert_eq!(ed.edit_range(0, 99, "x"), Err(Error::InvalidArgument)); // end past len
3980        assert_eq!(ed.edit_range(2, 1, "x"), Err(Error::InvalidArgument)); // start > end
3981        assert_eq!(ed.source_str().unwrap(), "hi\n"); // untouched
3982    }
3983
3984    #[test]
3985    fn editor_last_change_reports_locator_ops_too() {
3986        let mut ed = Editor::new_str("# One\n\n## Two\n", Format::Markdown).expect("editor");
3987        assert_eq!(ed.last_change(), None); // nothing edited yet
3988
3989        ed.replace("heading(\"Two\")", "## Renamed")
3990            .expect("replace");
3991        assert_eq!(ed.source_str().unwrap(), "# One\n\n## Renamed\n");
3992        let c = ed.last_change().expect("a change was recorded");
3993        // "## Two" occupied [7,13); "## Renamed" (10 bytes) now occupies [7,17).
3994        assert_eq!(c.old, 7..13);
3995        assert_eq!(c.new, 7..17);
3996    }
3997
3998    #[test]
3999    fn editor_nodes_is_a_walkable_flat_tree() {
4000        let mut ed = Editor::new_str("# Hi\n\ntext\n", Format::Markdown).expect("editor");
4001        let nodes = ed.nodes().expect("nodes");
4002        assert!(!nodes.is_empty());
4003
4004        // Dense, index-aligned ids.
4005        for (i, n) in nodes.iter().enumerate() {
4006            assert_eq!(n.id, NodeId(i as u32));
4007        }
4008        // Exactly one root (no parent), and it's the doc.
4009        let roots: Vec<_> = nodes.iter().filter(|n| n.parent.is_none()).collect();
4010        assert_eq!(roots.len(), 1);
4011        assert_eq!(roots[0].kind, Kind::Doc);
4012
4013        // The heading carries its level; the "Hi" text is reachable as a payload.
4014        let heading = nodes
4015            .iter()
4016            .find(|n| n.kind == Kind::Heading)
4017            .expect("a heading");
4018        assert_eq!(heading.level, Some(1));
4019        assert!(nodes.iter().any(|n| n.text.as_deref() == Some("Hi")));
4020
4021        // A kind with no row/cell payload reports neither.
4022        assert_eq!(heading.head, None);
4023        assert_eq!(heading.alignment, None);
4024
4025        // Every non-root node's parent links back to a node that lists it as a
4026        // child (via first_child/next_sibling).
4027        for n in nodes.iter().filter(|n| n.parent.is_some()) {
4028            let p = &nodes[n.parent.unwrap().0 as usize];
4029            let mut kid = p.first_child;
4030            let mut seen = false;
4031            while let Some(NodeId(k)) = kid {
4032                if k == n.id.0 {
4033                    seen = true;
4034                    break;
4035                }
4036                kid = nodes[k as usize].next_sibling;
4037            }
4038            assert!(
4039                seen,
4040                "node {:?} not found among its parent's children",
4041                n.id
4042            );
4043        }
4044    }
4045
4046    #[test]
4047    fn editor_child_spans_and_subtree_agree_with_nodes() {
4048        let src = "# Title\n\nHello **world** and more.\n\n- one\n- two\n";
4049        let mut ed = Editor::new_str(src, Format::Markdown).expect("editor");
4050        let all = ed.nodes().expect("nodes");
4051        let doc = all.iter().find(|n| n.kind == Kind::Doc).expect("doc");
4052
4053        // child_spans(None) == the doc root's children, same ids/kinds/spans and
4054        // in the same order.
4055        let top = ed.child_spans(None).expect("child_spans");
4056        let mut want = Vec::new();
4057        let mut c = doc.first_child;
4058        while let Some(id) = c {
4059            want.push(id);
4060            c = all[id.0 as usize].next_sibling;
4061        }
4062        assert_eq!(top.len(), want.len(), "top-level count");
4063        for (m, id) in top.iter().zip(&want) {
4064            assert_eq!(m.node_id, id.0, "child id");
4065            assert_eq!(m.kind, all[id.0 as usize].kind, "child kind");
4066            assert_eq!(m.span, all[id.0 as usize].span, "child span");
4067        }
4068        // The span addresses the block as written (absolute offsets).
4069        assert!(
4070            src[top[0].span.clone()].starts_with('#'),
4071            "first block is the heading"
4072        );
4073
4074        // child_spans works below the top level too.
4075        let list = top
4076            .iter()
4077            .find(|m| {
4078                matches!(
4079                    m.kind,
4080                    Kind::BulletList | Kind::OrderedList | Kind::TaskList
4081                )
4082            })
4083            .expect("a list");
4084        let items = ed.child_spans(Some(NodeId(list.node_id))).expect("items");
4085        assert_eq!(items.len(), 2);
4086        assert!(
4087            items.iter().all(|m| m.kind == Kind::ListItem),
4088            "items: {items:?}"
4089        );
4090
4091        // subtree(para) is self-contained, local-indexed, and spans stay absolute.
4092        let para = top
4093            .iter()
4094            .find(|m| m.kind == Kind::Para)
4095            .expect("a para")
4096            .node_id;
4097        let sub = ed.subtree(NodeId(para)).expect("subtree");
4098        assert_eq!(sub[0].id, NodeId(0), "root is local id 0");
4099        assert_eq!(sub[0].parent, None, "root has no parent inside the subtree");
4100        assert_eq!(sub[0].next_sibling, None, "root's sibling is severed");
4101        assert_eq!(sub[0].kind, Kind::Para);
4102        for (i, n) in sub.iter().enumerate() {
4103            assert_eq!(n.id, NodeId(i as u32), "dense local ids");
4104            for link in [n.parent, n.first_child, n.next_sibling]
4105                .into_iter()
4106                .flatten()
4107            {
4108                assert!(
4109                    (link.0 as usize) < sub.len(),
4110                    "link {link:?} escapes the subtree"
4111                );
4112            }
4113        }
4114        assert!(
4115            src[sub[0].span.clone()].starts_with("Hello"),
4116            "absolute span: {:?}",
4117            &src[sub[0].span.clone()]
4118        );
4119
4120        // Same multiset of node kinds as the paragraph's arena subtree.
4121        fn arena_kinds(all: &[FlatNode], root: NodeId) -> Vec<Kind> {
4122            let mut out = Vec::new();
4123            let mut stack = vec![root];
4124            while let Some(id) = stack.pop() {
4125                let n = &all[id.0 as usize];
4126                out.push(n.kind.clone());
4127                let mut c = n.first_child;
4128                while let Some(cid) = c {
4129                    stack.push(cid);
4130                    c = all[cid.0 as usize].next_sibling;
4131                }
4132            }
4133            out
4134        }
4135        let mut want_kinds = arena_kinds(&all, NodeId(para));
4136        let mut got_kinds: Vec<Kind> = sub.iter().map(|n| n.kind.clone()).collect();
4137        // Sorted by NAME: `Kind` is deliberately not `Ord` (there is no
4138        // meaningful order over a vocabulary), and this only needs a canonical
4139        // one to compare two multisets.
4140        want_kinds.sort_by(|a, b| a.as_str().cmp(b.as_str()));
4141        got_kinds.sort_by(|a, b| a.as_str().cmp(b.as_str()));
4142        assert_eq!(got_kinds, want_kinds, "subtree kinds match the arena");
4143
4144        // Out-of-range id is rejected.
4145        assert!(matches!(
4146            ed.subtree(NodeId(9999)),
4147            Err(Error::InvalidArgument)
4148        ));
4149    }
4150
4151    #[test]
4152    fn flat_nodes_carry_table_head_and_alignment() {
4153        // The delimiter row (`|:-----|----:|`) is consumed by the parser and has
4154        // no node of its own, so `alignment` on the cells is the only way a
4155        // consumer can recover the column alignment from a snapshot.
4156        let src = "| Name | Qty |\n|:-----|----:|\n| Pear | 3 |\n";
4157        let mut ed = Editor::new_str(src, Format::Markdown).expect("editor");
4158        let nodes = ed.nodes().expect("nodes");
4159
4160        let rows: Vec<_> = nodes.iter().filter(|n| n.kind == Kind::Row).collect();
4161        assert_eq!(rows.len(), 2, "a header row and one body row");
4162        assert_eq!(rows[0].head, Some(true), "first row is the header");
4163        assert_eq!(rows[1].head, Some(false), "second row is a body row");
4164
4165        let cells: Vec<_> = nodes.iter().filter(|n| n.kind == Kind::Cell).collect();
4166        assert_eq!(cells.len(), 4);
4167        // Alignment comes from the delimiter row and applies down the column.
4168        assert_eq!(cells[0].alignment, Some(Alignment::Left));
4169        assert_eq!(cells[1].alignment, Some(Alignment::Right));
4170        assert_eq!(cells[2].alignment, Some(Alignment::Left));
4171        assert_eq!(cells[3].alignment, Some(Alignment::Right));
4172        // Header cells are flagged too, not just their row.
4173        assert_eq!(cells[0].head, Some(true));
4174        assert_eq!(cells[2].head, Some(false));
4175
4176        // A table with no alignment spelled out reports Default — a real value,
4177        // distinct from the None a non-cell reports.
4178        let mut plain =
4179            Editor::new_str("| A |\n| --- |\n| b |\n", Format::Markdown).expect("editor");
4180        let pnodes = plain.nodes().expect("nodes");
4181        let pcell = pnodes
4182            .iter()
4183            .find(|n| n.kind == Kind::Cell)
4184            .expect("a cell");
4185        assert_eq!(pcell.alignment, Some(Alignment::Default));
4186    }
4187
4188    #[test]
4189    fn cell_extent_reports_merged_cells_and_nothing_else() {
4190        let src = "<table><tr><td colspan=\"2\" rowspan=\"3\">a</td><td>b</td></tr></table>";
4191        let mut doc = Document::parse_str(src, Format::Html).expect("parse");
4192        let cells: Vec<NodeId> = doc
4193            .nodes()
4194            .expect("nodes")
4195            .iter()
4196            .filter(|n| n.kind == Kind::Cell)
4197            .map(|n| n.id)
4198            .collect();
4199        assert_eq!(cells.len(), 2);
4200        assert_eq!(doc.cell_extent(cells[0]).expect("extent"), Some((2, 3)));
4201        // A plain cell is one square — 1, never 0.
4202        assert_eq!(doc.cell_extent(cells[1]).expect("extent"), Some((1, 1)));
4203
4204        // A pipe table cannot express a span at all, so every cell is (1, 1).
4205        let mut pipe =
4206            Document::parse_str("| a |\n| --- |\n| b |\n", Format::Markdown).expect("parse");
4207        let pipe_cell = pipe
4208            .nodes()
4209            .expect("nodes")
4210            .iter()
4211            .find(|n| n.kind == Kind::Cell)
4212            .expect("a cell")
4213            .id;
4214        assert_eq!(pipe.cell_extent(pipe_cell).expect("extent"), Some((1, 1)));
4215
4216        // Not a cell at all: None, distinct from any extent.
4217        let root = NodeId(0);
4218        assert_eq!(pipe.cell_extent(root).expect("extent"), None);
4219    }
4220
4221    #[test]
4222    fn builder_add_cell_spanning_renders_colspan_and_rowspan() {
4223        let mut b = Builder::new().expect("builder");
4224        let wide_text = b.add_text(TextKind::Str, "wide").expect("str");
4225        let wide = b
4226            .add_cell_spanning(false, Alignment::Default, 2, 3)
4227            .expect("cell");
4228        b.set_children(wide, &[wide_text]).expect("children");
4229        let plain_text = b.add_text(TextKind::Str, "one").expect("str");
4230        let plain = b.add_cell(false, Alignment::Default).expect("cell");
4231        b.set_children(plain, &[plain_text]).expect("children");
4232        let row = b.add_row(false).expect("row");
4233        b.set_children(row, &[wide, plain]).expect("children");
4234        let table = b.add(VoidKind::Table).expect("table");
4235        b.set_children(table, &[row]).expect("children");
4236
4237        let html = String::from_utf8(b.render_html(table).expect("html")).expect("utf-8");
4238        assert!(
4239            html.contains("<td colspan=\"2\" rowspan=\"3\">wide</td>"),
4240            "{html}"
4241        );
4242        // `add_cell` is the one-square case: the default extent writes nothing.
4243        assert!(html.contains("<td>one</td>"), "{html}");
4244
4245        // A zero extent is no cell anyone can lay out.
4246        assert!(matches!(
4247            b.add_cell_spanning(false, Alignment::Default, 0, 1),
4248            Err(Error::InvalidArgument)
4249        ));
4250    }
4251
4252    #[test]
4253    fn editor_node_at_and_ancestors_hit_test_offsets() {
4254        let mut ed = Editor::new_str("# Hi\n\ntext\n", Format::Markdown).expect("editor");
4255
4256        // Offset 2 is the "H" of the heading "# Hi" [0,4).
4257        let m = ed
4258            .node_at(2)
4259            .expect("node_at")
4260            .expect("a node covers offset 2");
4261        assert!(m.span.contains(&2));
4262
4263        // The ancestor chain is root-first and ends at the deepest (== node_at).
4264        let chain = ed.ancestors_at(2).expect("ancestors_at");
4265        assert!(!chain.is_empty());
4266        assert_eq!(chain[0].kind, Kind::Doc);
4267        assert_eq!(chain.last().unwrap().node_id, m.node_id);
4268
4269        // An out-of-range offset is an error; a gap covers nothing deeper than doc.
4270        assert_eq!(ed.node_at(999), Err(Error::InvalidArgument));
4271    }
4272
4273    // ── range-oriented rich-text ops (P5) ───────────────────────────────────
4274
4275    #[test]
4276    fn editor_wrap_and_toggle_inline_round_trip() {
4277        let mut ed = Editor::new_str("a word b\n", Format::Markdown).expect("editor");
4278
4279        // Bold "word" [2,6); the Change reports the new "**word**" region.
4280        let c = ed.wrap_range(2, 6, InlineKind::Strong).expect("wrap");
4281        assert_eq!(ed.source_str().unwrap(), "a **word** b\n");
4282        assert_eq!(&ed.source_str().unwrap()[c.new.clone()], "**word**");
4283
4284        // Toggle it off by selecting the strong node's interior [4,8).
4285        ed.toggle_inline(4, 8, InlineKind::Strong)
4286            .expect("toggle off");
4287        assert_eq!(ed.source_str().unwrap(), "a word b\n");
4288
4289        // Toggle emphasis on when the range isn't already marked.
4290        ed.toggle_inline(2, 6, InlineKind::Emph).expect("toggle on");
4291        assert_eq!(ed.source_str().unwrap(), "a *word* b\n");
4292    }
4293
4294    #[test]
4295    fn editor_inline_kind_support_is_format_specific() {
4296        // Markdown has no highlight/mark spelling.
4297        let mut md = Editor::new_str("a word b\n", Format::Markdown).expect("editor");
4298        assert_eq!(
4299            md.wrap_range(2, 6, InlineKind::Mark),
4300            Err(Error::UnsupportedFormat)
4301        );
4302
4303        // Djot spells it {=…=}.
4304        let mut dj = Editor::new_str("a word b\n", Format::Djot).expect("editor");
4305        dj.wrap_range(2, 6, InlineKind::Mark).expect("djot mark");
4306        assert_eq!(dj.source_str().unwrap(), "a {=word=} b\n");
4307    }
4308
4309    #[test]
4310    fn editor_toggle_strips_verbatim_via_content_span() {
4311        let mut ed = Editor::new_str("a `code` b\n", Format::Markdown).expect("editor");
4312        // The verbatim node [2,8) reports content_span [3,7); toggle peels it.
4313        ed.toggle_inline(2, 8, InlineKind::Verbatim)
4314            .expect("toggle code off");
4315        assert_eq!(ed.source_str().unwrap(), "a code b\n");
4316
4317        // A multi-backtick span peels BOTH runs via content_span, not by
4318        // stripping a single delimiter (which would corrupt it to "`x`").
4319        let mut ed2 = Editor::new_str("a ``x`` b\n", Format::Markdown).expect("editor");
4320        ed2.toggle_inline(2, 7, InlineKind::Verbatim)
4321            .expect("toggle multi off");
4322        assert_eq!(ed2.source_str().unwrap(), "a x b\n");
4323    }
4324
4325    #[test]
4326    fn editor_set_block_switches_para_and_heading_levels() {
4327        let mut ed = Editor::new_str("Title\n\nbody text\n", Format::Markdown).expect("editor");
4328
4329        // Paragraph -> H2 (offset 0 is inside "Title").
4330        ed.set_block(0, BlockKind::Heading(2)).expect("to h2");
4331        assert_eq!(ed.source_str().unwrap(), "## Title\n\nbody text\n");
4332
4333        // H2 -> H1 (offset now inside "## Title").
4334        ed.set_block(3, BlockKind::Heading(1)).expect("to h1");
4335        assert_eq!(ed.source_str().unwrap(), "# Title\n\nbody text\n");
4336
4337        // Heading -> paragraph, dropping the marker.
4338        ed.set_block(2, BlockKind::Paragraph).expect("to para");
4339        assert_eq!(ed.source_str().unwrap(), "Title\n\nbody text\n");
4340    }
4341
4342    #[test]
4343    fn editor_set_block_rejects_bad_level_and_format() {
4344        let mut md = Editor::new_str("hi\n", Format::Markdown).expect("editor");
4345        assert_eq!(
4346            md.set_block(0, BlockKind::Heading(9)),
4347            Err(Error::InvalidArgument)
4348        );
4349
4350        let mut xml = Editor::new_str("<a>hi</a>", Format::Xml).expect("editor");
4351        assert_eq!(
4352            xml.set_block(1, BlockKind::Heading(1)),
4353            Err(Error::UnsupportedFormat)
4354        );
4355    }
4356
4357    #[test]
4358    fn editor_toggle_block_container_round_trips() {
4359        let mut ed = Editor::new_str("a\n", Format::Djot).expect("editor");
4360
4361        let c = ed
4362            .toggle_block_container(0, 1, BlockContainerKind::BlockQuote)
4363            .expect("quote on");
4364        assert_eq!(ed.source_str().unwrap(), "> a\n");
4365        assert_eq!(&ed.source_str().unwrap()[c.new.clone()], "> a\n");
4366
4367        ed.toggle_block_container(2, 3, BlockContainerKind::BlockQuote)
4368            .expect("quote off");
4369        assert_eq!(ed.source_str().unwrap(), "a\n");
4370    }
4371
4372    #[test]
4373    fn editor_toggle_block_container_nests_a_partial_selection() {
4374        let mut ed = Editor::new_str("> a\n>\n> b\n", Format::Djot).expect("editor");
4375
4376        // Only the first paragraph is covered, so the quote is not fully
4377        // selected: nest rather than drag `b` out of the quote too.
4378        ed.toggle_block_container(2, 3, BlockContainerKind::BlockQuote)
4379            .expect("nest");
4380        assert_eq!(ed.source_str().unwrap(), "> > a\n>\n> b\n");
4381
4382        // Peel the inner level back off, leaving the outer quote intact.
4383        ed.toggle_block_container(4, 5, BlockContainerKind::BlockQuote)
4384            .expect("peel");
4385        assert_eq!(ed.source_str().unwrap(), "> a\n>\n> b\n");
4386    }
4387
4388    #[test]
4389    fn editor_toggle_block_container_numbers_and_converts_lists() {
4390        let mut ed = Editor::new_str("a\n\nb\n", Format::Djot).expect("editor");
4391
4392        // Each covered block becomes its own numbered item.
4393        ed.toggle_block_container(0, 4, BlockContainerKind::OrderedList)
4394            .expect("ordered on");
4395        assert_eq!(ed.source_str().unwrap(), "1. a\n\n2. b\n");
4396
4397        // The other list kind converts in place instead of nesting.
4398        ed.toggle_block_container(3, 9, BlockContainerKind::BulletList)
4399            .expect("convert");
4400        assert_eq!(ed.source_str().unwrap(), "- a\n\n- b\n");
4401    }
4402
4403    #[test]
4404    fn editor_toggle_block_container_rejects_unspellable_format() {
4405        let mut xml = Editor::new_str("<a>hi</a>", Format::Xml).expect("editor");
4406        assert_eq!(
4407            xml.toggle_block_container(3, 5, BlockContainerKind::BlockQuote),
4408            Err(Error::UnsupportedFormat)
4409        );
4410    }
4411
4412    #[test]
4413    fn editor_insert_link_wraps_and_repoints() {
4414        let mut ed = Editor::new_str("a word b\n", Format::Djot).expect("editor");
4415
4416        ed.insert_link(2, 6, "http://x.dev").expect("link");
4417        assert_eq!(ed.source_str().unwrap(), "a [word](http://x.dev) b\n");
4418
4419        // A caret inside the existing link re-points it rather than nesting.
4420        ed.insert_link(3, 7, "http://y.dev").expect("re-point");
4421        assert_eq!(ed.source_str().unwrap(), "a [word](http://y.dev) b\n");
4422    }
4423
4424    #[test]
4425    fn editor_insert_link_repoints_an_autolink() {
4426        // The regression: an autolink is a `url`/`email` node whose text IS its
4427        // destination. Read as ordinary text, a caret inside it spliced a whole
4428        // new link into the middle of the old URL —
4429        // `see <https<https://y.dev>://x.dev> ok`.
4430        for format in [Format::Markdown, Format::Djot] {
4431            let mut ed = Editor::new_str("see <https://x.dev> ok\n", format).expect("editor");
4432            ed.insert_link(10, 10, "https://y.dev").expect("re-point");
4433            assert_eq!(ed.source_str().unwrap(), "see <https://y.dev> ok\n");
4434
4435            // Source that looks right can still parse wrong: assert the reparse.
4436            let nodes = ed.nodes().expect("nodes");
4437            let url = nodes
4438                .iter()
4439                .find(|n| n.kind == Kind::Url)
4440                .expect("still an autolink");
4441            assert_eq!(url.text.as_deref(), Some("https://y.dev"));
4442            assert!(!nodes.iter().any(|n| n.kind == Kind::Link));
4443        }
4444    }
4445
4446    #[test]
4447    fn editor_insert_link_escapes_the_destination() {
4448        // Unescaped, the `)` would close the link early and spill `b` into the
4449        // paragraph as literal text.
4450        let mut dj = Editor::new_str("w\n", Format::Djot).expect("editor");
4451        dj.insert_link(0, 1, "a)b").expect("link");
4452        assert_eq!(dj.source_str().unwrap(), "[w](a\\)b)\n");
4453
4454        // Whitespace is where the formats part ways: Markdown needs the angle
4455        // form (a bare space ends the destination and kills the link outright),
4456        // Djot must NOT use it (it would link to the literal text `<a b>`).
4457        let mut md = Editor::new_str("w\n", Format::Markdown).expect("editor");
4458        md.insert_link(0, 1, "a b").expect("link");
4459        assert_eq!(md.source_str().unwrap(), "[w](<a b>)\n");
4460
4461        let mut dj2 = Editor::new_str("w\n", Format::Djot).expect("editor");
4462        dj2.insert_link(0, 1, "a b").expect("link");
4463        assert_eq!(dj2.source_str().unwrap(), "[w](a b)\n");
4464    }
4465
4466    #[test]
4467    fn editor_insert_image_escapes_the_destination_per_format() {
4468        // The whole point of the op: a caller's `![](my cat.png)` is not an image
4469        // in Markdown, and the correct repair differs by format.
4470        let mut md = Editor::new_str("w\n", Format::Markdown).expect("editor");
4471        md.insert_image(0, 1, "my cat.png").expect("image");
4472        assert_eq!(md.source_str().unwrap(), "![w](<my cat.png>)\n");
4473
4474        let mut dj = Editor::new_str("w\n", Format::Djot).expect("editor");
4475        dj.insert_image(0, 1, "my cat.png").expect("image");
4476        assert_eq!(dj.source_str().unwrap(), "![w](my cat.png)\n");
4477
4478        // A `)` would close the image early and spill the rest as literal text.
4479        let mut paren = Editor::new_str("w\n", Format::Djot).expect("editor");
4480        paren.insert_image(0, 1, "a)b.png").expect("image");
4481        assert_eq!(paren.source_str().unwrap(), "![w](a\\)b.png)\n");
4482    }
4483
4484    #[test]
4485    fn editor_insert_image_keeps_an_empty_alt_empty() {
4486        // Unlike a link, where an empty range spells an autolink or doubles the
4487        // destination as text — an image with no alt is ordinary.
4488        let mut ed = Editor::new_str("ab\n", Format::Markdown).expect("editor");
4489        ed.insert_image(1, 1, "cat.png").expect("image");
4490        assert_eq!(ed.source_str().unwrap(), "a![](cat.png)b\n");
4491    }
4492
4493    #[test]
4494    fn editor_insert_image_rejects_a_newline_destination() {
4495        let mut ed = Editor::new_str("w\n", Format::Djot).expect("editor");
4496        assert_eq!(
4497            ed.insert_image(0, 1, "a\nb.png"),
4498            Err(Error::InvalidArgument)
4499        );
4500
4501        let mut xml = Editor::new_str("<a>hi</a>", Format::Xml).expect("editor");
4502        assert_eq!(
4503            xml.insert_image(3, 5, "x.png"),
4504            Err(Error::UnsupportedFormat)
4505        );
4506    }
4507
4508    #[test]
4509    fn editor_insert_link_rejects_a_newline_destination() {
4510        let mut ed = Editor::new_str("w\n", Format::Djot).expect("editor");
4511        assert_eq!(ed.insert_link(0, 1, "a\nb"), Err(Error::InvalidArgument));
4512
4513        let mut xml = Editor::new_str("<a>hi</a>", Format::Xml).expect("editor");
4514        assert_eq!(xml.insert_link(3, 5, "u"), Err(Error::UnsupportedFormat));
4515    }
4516
4517    #[test]
4518    fn editor_insert_literal_keeps_typed_specials_literal() {
4519        for format in [Format::Markdown, Format::Djot] {
4520            let mut ed = Editor::new_str("z\n", format).expect("editor");
4521            // A `*` at a line start would open emphasis unescaped.
4522            ed.insert_literal(0, "*hi*").expect("literal");
4523
4524            // Source that looks right can still parse wrong: assert the reparse.
4525            let nodes = ed.nodes().expect("nodes");
4526            assert!(
4527                !nodes
4528                    .iter()
4529                    .any(|n| n.kind == Kind::Emph || n.kind == Kind::Strong)
4530            );
4531            let text: String = nodes
4532                .iter()
4533                .filter(|n| n.kind == Kind::Str)
4534                .filter_map(|n| n.text.clone())
4535                .collect();
4536            assert_eq!(text, "*hi*z");
4537        }
4538    }
4539
4540    #[test]
4541    fn editor_insert_literal_escapes_block_markers_only_at_line_start() {
4542        // Mid-line, a `#` opens nothing and is left as typed.
4543        let mut ed = Editor::new_str("az\n", Format::Markdown).expect("editor");
4544        ed.insert_literal(1, "# ").expect("literal");
4545        assert_eq!(ed.source_str().unwrap(), "a# z\n");
4546
4547        // At a line start it would open a heading, so it is escaped.
4548        let mut ed2 = Editor::new_str("z\n", Format::Markdown).expect("editor");
4549        ed2.insert_literal(0, "# ").expect("literal");
4550        assert_eq!(ed2.source_str().unwrap(), "\\# z\n");
4551        assert!(
4552            !ed2.nodes()
4553                .expect("nodes")
4554                .iter()
4555                .any(|n| n.kind == Kind::Heading)
4556        );
4557    }
4558
4559    #[test]
4560    fn editor_insert_literal_rejects_bad_offset_and_parse_only_format() {
4561        let mut ed = Editor::new_str("ab\n", Format::Markdown).expect("editor");
4562        assert_eq!(ed.insert_literal(99, "x"), Err(Error::InvalidArgument));
4563
4564        let mut xml = Editor::new_str("<a>hi</a>", Format::Xml).expect("editor");
4565        assert_eq!(xml.insert_literal(3, "x"), Err(Error::UnsupportedFormat));
4566    }
4567
4568    #[test]
4569    fn editor_insert_line_break_splices_in_cell_br() {
4570        let mut ed =
4571            Editor::new_str("| a | b |\n| --- | --- |\n", Format::Markdown).expect("editor");
4572        // Caret just after `a` in the header cell.
4573        ed.insert_line_break(3).expect("line break");
4574        assert_eq!(ed.source_str().unwrap(), "| a<br> | b |\n| --- | --- |\n");
4575        // The break reads back as a semantic node, not raw HTML.
4576        let nodes = ed.nodes().expect("nodes");
4577        assert!(nodes.iter().any(|n| n.kind == Kind::HardBreak));
4578        assert!(!nodes.iter().any(|n| n.kind == Kind::RawInline));
4579    }
4580
4581    #[test]
4582    fn editor_insert_line_break_rejects_off_cell_off_format_and_bad_offset() {
4583        // Not inside a cell → NotFound.
4584        let mut para = Editor::new_str("just text\n", Format::Markdown).expect("editor");
4585        assert_eq!(para.insert_line_break(3), Err(Error::NotFound));
4586
4587        // Djot has no in-cell break spelling → UnsupportedFormat.
4588        let mut dj = Editor::new_str("| a | b |\n| --- | --- |\n", Format::Djot).expect("editor");
4589        assert_eq!(dj.insert_line_break(3), Err(Error::UnsupportedFormat));
4590
4591        // Out-of-range offset → InvalidArgument.
4592        let mut ed =
4593            Editor::new_str("| a | b |\n| --- | --- |\n", Format::Markdown).expect("editor");
4594        assert_eq!(ed.insert_line_break(9999), Err(Error::InvalidArgument));
4595    }
4596
4597    #[test]
4598    fn editor_insert_thematic_break_is_blank_separated_per_format() {
4599        // The blank line above is load-bearing, not cosmetic: flush against the
4600        // paragraph, Markdown's `---` is a setext underline and the paragraph
4601        // becomes an <h2>. So assert the reparsed KIND, not just the bytes.
4602        let mut md = Editor::new_str("a\n", Format::Markdown).expect("editor");
4603        md.insert_thematic_break(0).expect("rule");
4604        assert_eq!(md.source_str().unwrap(), "a\n\n---\n");
4605        let nodes = md.nodes().expect("nodes");
4606        assert!(nodes.iter().any(|n| n.kind == Kind::ThematicBreak));
4607        assert!(!nodes.iter().any(|n| n.kind == Kind::Heading));
4608
4609        // Djot spells the same construct differently — the reason the spelling
4610        // is the library's and not the caller's.
4611        let mut dj = Editor::new_str("a\n", Format::Djot).expect("editor");
4612        dj.insert_thematic_break(0).expect("rule");
4613        assert_eq!(dj.source_str().unwrap(), "a\n\n* * *\n");
4614
4615        let mut xml = Editor::new_str("<a>hi</a>", Format::Xml).expect("editor");
4616        assert_eq!(xml.insert_thematic_break(3), Err(Error::UnsupportedFormat));
4617    }
4618
4619    #[test]
4620    fn editor_split_block_keeps_both_halves_the_same_kind() {
4621        // A list item's halves are both items — the marker is repeated, so the
4622        // second half doesn't fall out of the list as a paragraph.
4623        let mut item = Editor::new_str("- this is a list item\n", Format::Markdown).expect("editor");
4624        item.split_block(10).expect("split");
4625        assert_eq!(item.source_str().unwrap(), "- this is \n- a list item\n");
4626        let nodes = item.nodes().expect("nodes");
4627        assert_eq!(nodes.iter().filter(|n| n.kind == Kind::ListItem).count(), 2);
4628
4629        // At the item's end the empty sibling IS the point — that is Enter.
4630        let mut tail = Editor::new_str("- a\n", Format::Markdown).expect("editor");
4631        tail.split_block(3).expect("split");
4632        assert_eq!(tail.source_str().unwrap(), "- a\n- \n");
4633
4634        // A paragraph divides on a blank line instead.
4635        let mut para = Editor::new_str("ab\n", Format::Markdown).expect("editor");
4636        para.split_block(1).expect("split");
4637        assert_eq!(para.source_str().unwrap(), "a\n\nb\n");
4638
4639        // A table has no honest caret-split: a newline mid-cell destroys it.
4640        let mut table =
4641            Editor::new_str("| a | b |\n|---|---|\n| c | d |\n", Format::Markdown).expect("editor");
4642        assert_eq!(table.split_block(3), Err(Error::NotEditable));
4643
4644        let mut empty = Editor::new_str("", Format::Markdown).expect("editor");
4645        assert_eq!(empty.split_block(0), Err(Error::NotFound));
4646    }
4647
4648    #[test]
4649    fn editor_toggle_code_block_round_trips_and_measures_the_fence() {
4650        let mut ed = Editor::new_str("a\n", Format::Markdown).expect("editor");
4651        ed.toggle_code_block(0, 1, Some("zig")).expect("fence");
4652        assert_eq!(ed.source_str().unwrap(), "```zig\na\n```\n");
4653        let nodes = ed.nodes().expect("nodes");
4654        assert!(nodes.iter().any(|n| n.kind == Kind::CodeBlock));
4655
4656        ed.toggle_code_block(0, 0, None).expect("unfence");
4657        assert_eq!(ed.source_str().unwrap(), "a\n");
4658
4659        // Three backticks in the body would close a three-backtick fence, so the
4660        // fence is measured against the body rather than fixed.
4661        let mut runs = Editor::new_str("a ``` b\n", Format::Markdown).expect("editor");
4662        runs.toggle_code_block(0, 7, None).expect("fence");
4663        assert_eq!(runs.source_str().unwrap(), "````\na ``` b\n````\n");
4664    }
4665
4666    #[test]
4667    fn editor_toggle_code_block_refuses_inside_a_list_item() {
4668        // A fence at column zero here would pull the item's `- ` into the code
4669        // body and the item would stop being an item.
4670        let mut ed = Editor::new_str("- a\n- b\n", Format::Markdown).expect("editor");
4671        assert_eq!(ed.toggle_code_block(2, 3, None), Err(Error::NotEditable));
4672        assert_eq!(ed.source_str().unwrap(), "- a\n- b\n");
4673    }
4674
4675    #[test]
4676    fn editor_set_code_language_retags_clears_and_refuses() {
4677        let mut ed = Editor::new_str("```zig\na\n```\n", Format::Markdown).expect("editor");
4678        ed.set_code_language(0, Some("rust")).expect("retag");
4679        assert_eq!(ed.source_str().unwrap(), "```rust\na\n```\n");
4680
4681        // `None` clears the info string; `Some("")` writes the same bytes but is
4682        // a different request.
4683        ed.set_code_language(0, None).expect("clear");
4684        assert_eq!(ed.source_str().unwrap(), "```\na\n```\n");
4685        ed.set_code_language(0, Some("")).expect("empty");
4686        assert_eq!(ed.source_str().unwrap(), "```\na\n```\n");
4687
4688        // Markdown's info string ends at whitespace, so a space would come back
4689        // truncated — refused rather than silently clipped.
4690        assert_eq!(
4691            ed.set_code_language(0, Some("a b")),
4692            Err(Error::InvalidArgument)
4693        );
4694        // Djot's runs to the end of the line, so the same string is fine there.
4695        let mut dj = Editor::new_str("```\na\n```\n", Format::Djot).expect("editor");
4696        dj.set_code_language(0, Some("a b"))
4697            .expect("djot info string");
4698        assert_eq!(dj.source_str().unwrap(), "```a b\na\n```\n");
4699
4700        let mut para = Editor::new_str("x\n", Format::Markdown).expect("editor");
4701        assert_eq!(para.set_code_language(0, Some("zig")), Err(Error::NotFound));
4702    }
4703
4704    #[test]
4705    fn editor_task_checkbox_gestures() {
4706        let mut ed = Editor::new_str("- a\n", Format::Markdown).expect("editor");
4707
4708        // The box is added by one gesture and ticked by another — adding
4709        // converts the item's kind, ticking only changes what the box holds.
4710        ed.toggle_task_item(2).expect("add box");
4711        assert_eq!(ed.source_str().unwrap(), "- [ ] a\n");
4712        assert!(
4713            ed.nodes()
4714                .unwrap()
4715                .iter()
4716                .any(|n| n.kind == Kind::TaskListItem)
4717        );
4718
4719        ed.set_task_checked(6, true).expect("tick");
4720        assert_eq!(ed.source_str().unwrap(), "- [x] a\n");
4721        // Already checked: a no-op that still succeeds and moves nothing.
4722        ed.set_task_checked(6, true).expect("no-op");
4723        assert_eq!(ed.source_str().unwrap(), "- [x] a\n");
4724
4725        ed.toggle_task_checked(6).expect("flip");
4726        assert_eq!(ed.source_str().unwrap(), "- [ ] a\n");
4727
4728        ed.toggle_task_item(6).expect("remove box");
4729        assert_eq!(ed.source_str().unwrap(), "- a\n");
4730
4731        // A plain bullet has no box to tick; `toggle_task_item` is how a caller
4732        // asks for one.
4733        assert_eq!(ed.set_task_checked(2, true), Err(Error::NotEditable));
4734        // And a caret in no list item has no item at all.
4735        let mut para = Editor::new_str("a\n", Format::Markdown).expect("editor");
4736        assert_eq!(para.toggle_task_item(0), Err(Error::NotFound));
4737    }
4738
4739    #[test]
4740    fn editor_insert_footnote_writes_both_halves_as_one_edit() {
4741        for format in [Format::Markdown, Format::Djot] {
4742            let mut ed = Editor::new_str("see\n", format).expect("editor");
4743            ed.insert_footnote(3, "a").expect("footnote");
4744            assert_eq!(ed.source_str().unwrap(), "see[^a]\n\n[^a]: \n");
4745
4746            // Half a footnote is not a footnote, so assert both nodes exist.
4747            let nodes = ed.nodes().expect("nodes");
4748            assert!(nodes.iter().any(|n| n.kind == Kind::FootnoteReference));
4749            assert!(nodes.iter().any(|n| n.kind == Kind::Footnote));
4750
4751            // One edit, so one undo takes both halves back.
4752            ed.undo().expect("undo");
4753            assert_eq!(ed.source_str().unwrap(), "see\n");
4754        }
4755    }
4756
4757    #[test]
4758    fn editor_insert_footnote_reuses_an_existing_definition() {
4759        let mut ed = Editor::new_str("see\n", Format::Markdown).expect("editor");
4760        ed.insert_footnote(3, "a").expect("first");
4761        ed.insert_footnote(7, "a").expect("second reference");
4762        assert_eq!(ed.source_str().unwrap(), "see[^a][^a]\n\n[^a]: \n");
4763        let defs = ed
4764            .nodes()
4765            .unwrap()
4766            .iter()
4767            .filter(|n| n.kind == Kind::Footnote)
4768            .count();
4769        assert_eq!(defs, 1);
4770
4771        assert_eq!(ed.insert_footnote(3, ""), Err(Error::InvalidArgument));
4772        assert_eq!(ed.insert_footnote(3, "a]b"), Err(Error::InvalidArgument));
4773    }
4774
4775    #[test]
4776    fn editor_undo_redo_round_trip() {
4777        let mut ed = Editor::new_str("hello\n", Format::Markdown).expect("editor");
4778        ed.edit_range(5, 5, "!").expect("edit");
4779        assert_eq!(ed.source_str().unwrap(), "hello!\n");
4780
4781        let change = ed.undo().expect("undo ok").expect("something to undo");
4782        assert_eq!(ed.source_str().unwrap(), "hello\n");
4783        assert_eq!(change.new.end, 5);
4784        assert!(ed.undo().expect("undo ok").is_none(), "history exhausted");
4785
4786        ed.redo().expect("redo ok").expect("something to redo");
4787        assert_eq!(ed.source_str().unwrap(), "hello!\n");
4788    }
4789
4790    #[test]
4791    fn editor_coalesce_folds_a_run() {
4792        let mut ed = Editor::new_str("\n", Format::Markdown).expect("editor");
4793        ed.edit_range(0, 0, "a").expect("edit");
4794        ed.edit_range(1, 1, "b").expect("edit");
4795        ed.coalesce_last_undo().expect("coalesce");
4796        assert_eq!(ed.source_str().unwrap(), "ab\n");
4797        // One undo removes the whole coalesced run.
4798        ed.undo().expect("undo ok").expect("something to undo");
4799        assert_eq!(ed.source_str().unwrap(), "\n");
4800        assert!(ed.undo().expect("undo ok").is_none());
4801    }
4802
4803    #[test]
4804    fn editor_revision_bumps_per_successful_mutation() {
4805        let mut ed = Editor::new_str("x\n", Format::Markdown).expect("editor");
4806        assert_eq!(ed.revision(), 0);
4807        ed.edit_range(1, 1, "y").expect("edit");
4808        assert_eq!(ed.revision(), 1);
4809
4810        // A reparse-breaking edit is rolled back and must not bump the revision.
4811        let mut xml = Editor::new_str("<a>ok</a>", Format::Xml).expect("editor");
4812        assert_eq!(xml.revision(), 0);
4813        assert!(xml.replace_content("0", "<b>").is_err());
4814        assert_eq!(xml.revision(), 0);
4815
4816        // undo and redo are mutations too.
4817        ed.undo().expect("undo ok").expect("something to undo");
4818        assert_eq!(ed.revision(), 2);
4819        ed.redo().expect("redo ok").expect("something to redo");
4820        assert_eq!(ed.revision(), 3);
4821    }
4822
4823    #[test]
4824    fn editor_dirty_range_tracks_and_clears() {
4825        let mut ed = Editor::new_str("abcdefgh\n", Format::Markdown).expect("editor");
4826        // Clean to start.
4827        assert_eq!(ed.dirty_range(), None);
4828
4829        // One insertion of two bytes at offset 2 dirties exactly [2, 4).
4830        ed.edit_range(2, 2, "XY").expect("edit");
4831        assert_eq!(ed.dirty_range(), Some(2..4));
4832
4833        // A second, disjoint edit near the end accumulates conservatively: the
4834        // reported range is a superset covering both edits.
4835        ed.edit_range(9, 9, "Z").expect("edit"); // source is now "abXYcdefgZh\n"
4836        let d = ed.dirty_range().expect("dirty");
4837        assert!(
4838            d.start <= 2 && d.end >= 10,
4839            "range {d:?} must cover both edits"
4840        );
4841
4842        // clear_dirty acknowledges without moving the revision.
4843        let rev = ed.revision();
4844        ed.clear_dirty();
4845        assert_eq!(ed.dirty_range(), None);
4846        assert_eq!(ed.revision(), rev);
4847
4848        // Post-clear, only new mutations show up — and undo counts as one.
4849        ed.undo().expect("undo ok").expect("something to undo");
4850        assert!(ed.dirty_range().is_some());
4851    }
4852
4853    #[test]
4854    fn editor_caret_blob_follows_undo_and_redo() {
4855        let mut ed = Editor::new_str("hello\n", Format::Markdown).expect("editor");
4856        assert!(ed.caret_blob().unwrap().is_empty());
4857
4858        // Set the pre-edit caret, then edit: the retired undo step captures it.
4859        ed.set_caret_blob(b"before").expect("set caret");
4860        ed.edit_range(5, 5, "!").expect("edit");
4861        // A fresh state starts caret-less until the host sets one.
4862        assert!(ed.caret_blob().unwrap().is_empty());
4863        ed.set_caret_blob(b"after").expect("set caret");
4864
4865        // Undo restores the pre-edit source AND the pre-edit caret.
4866        ed.undo().expect("undo ok").expect("something to undo");
4867        assert_eq!(ed.source_str().unwrap(), "hello\n");
4868        assert_eq!(ed.caret_blob().unwrap(), b"before");
4869
4870        // Redo restores the post-edit source AND the post-edit caret.
4871        ed.redo().expect("redo ok").expect("something to redo");
4872        assert_eq!(ed.source_str().unwrap(), "hello!\n");
4873        assert_eq!(ed.caret_blob().unwrap(), b"after");
4874    }
4875
4876    #[test]
4877    fn editor_coalesced_run_keeps_the_pre_run_caret() {
4878        let mut ed = Editor::new_str("\n", Format::Markdown).expect("editor");
4879        ed.set_caret_blob(b"c0").expect("set caret");
4880        ed.edit_range(0, 0, "a").expect("edit");
4881        ed.set_caret_blob(b"c1").expect("set caret");
4882        ed.edit_range(1, 1, "b").expect("edit");
4883        ed.coalesce_last_undo().expect("coalesce");
4884        ed.set_caret_blob(b"c2").expect("set caret");
4885
4886        // One undo folds the run and restores the caret from before it began.
4887        ed.undo().expect("undo ok").expect("something to undo");
4888        assert_eq!(ed.source_str().unwrap(), "\n");
4889        assert_eq!(ed.caret_blob().unwrap(), b"c0");
4890    }
4891
4892    #[test]
4893    fn editor_renumber_ordered_lists_fixes_a_stale_sequence() {
4894        let mut ed = Editor::new_str("1. a\n2. x\n2. b\n3. c\n", Format::Markdown).expect("editor");
4895        ed.renumber_ordered_lists(0).expect("renumber ok");
4896        assert_eq!(ed.source_str().unwrap(), "1. a\n2. x\n3. b\n4. c\n");
4897    }
4898
4899    #[test]
4900    fn editor_renumber_ordered_lists_leaves_djot_prose_alone() {
4901        // Djot reads `   2. b` as text inside item `a`; Markdown reads the same
4902        // bytes as a nested item. The author's digit survives in the one case.
4903        let src = "1. a\n   2. b\n2. c\n";
4904        let mut dj = Editor::new_str(src, Format::Djot).expect("editor");
4905        dj.renumber_ordered_lists(0).expect("renumber ok");
4906        assert_eq!(dj.source_str().unwrap(), src);
4907
4908        let mut md = Editor::new_str(src, Format::Markdown).expect("editor");
4909        md.renumber_ordered_lists(0).expect("renumber ok");
4910        assert_eq!(md.source_str().unwrap(), "1. a\n   1. b\n2. c\n");
4911    }
4912
4913    #[test]
4914    fn editor_renumber_ordered_lists_off_a_list_is_not_found() {
4915        let mut ed = Editor::new_str("a paragraph\n", Format::Markdown).expect("editor");
4916        assert!(matches!(ed.renumber_ordered_lists(2), Err(Error::NotFound)));
4917    }
4918
4919    #[test]
4920    fn editor_table_insert_row_and_set_alignment() {
4921        let src = "| a | b |\n| --- | --- |\n| 1 | 2 |\n";
4922        let mut ed = Editor::new_str(src, Format::Markdown).expect("editor");
4923        ed.table_insert_row(24, true).expect("insert row"); // caret in body `1`
4924        assert_eq!(
4925            ed.source_str().unwrap(),
4926            "| a | b |\n| --- | --- |\n| 1 | 2 |\n|  |  |\n"
4927        );
4928        ed.table_set_alignment(6, Alignment::Center).expect("align"); // column `b`
4929        assert!(ed.source_str().unwrap().contains("| --- | :---: |"));
4930    }
4931
4932    #[test]
4933    fn editor_table_edit_off_a_table_is_not_found() {
4934        let mut ed = Editor::new_str("nope\n", Format::Markdown).expect("editor");
4935        assert!(matches!(ed.table_delete_row(2), Err(Error::NotFound)));
4936    }
4937
4938    #[test]
4939    fn editor_set_block_converts_setext_heading() {
4940        // A setext heading rebuilt from its content_span collapses the underline.
4941        let mut ed = Editor::new_str("Title\n=====\n\nbody\n", Format::Markdown).expect("editor");
4942        ed.set_block(0, BlockKind::Heading(1))
4943            .expect("setext to atx");
4944        assert_eq!(ed.source_str().unwrap(), "# Title\n\nbody\n");
4945    }
4946
4947    #[test]
4948    fn editor_unwrap_and_smart_delete() {
4949        let mut ed = Editor::new_str("<r><box><b/><c/></box></r>", Format::Xml).expect("editor");
4950        ed.unwrap_node("0.0").expect("unwrap"); // <box>
4951        assert_eq!(ed.source_str().expect("source"), "<r><b/><c/></r>");
4952
4953        let mut md = Editor::new_str("A\n\nB\n\nC\n", Format::Markdown).expect("editor");
4954        md.delete_smart("1").expect("delete_smart"); // the "B" paragraph
4955        assert_eq!(md.source_str().expect("source"), "A\n\nC\n");
4956    }
4957
4958    #[test]
4959    fn editor_directives_require_the_extension_flag() {
4960        let src = ":::vis{.public}\nhi\n:::\n";
4961        // Without the flag, the colon-fence lines are plain paragraph text —
4962        // no directive node.
4963        let mut plain = Editor::new_str(src, Format::Markdown).expect("editor");
4964        assert_eq!(plain.query("directive").expect("query").len(), 0);
4965        // With it enabled, the container directive is recognized.
4966        let mut ext = Editor::new_ext(
4967            src.as_bytes(),
4968            Format::Markdown,
4969            MarkdownExtensions {
4970                directives: true,
4971                ..Default::default()
4972            },
4973        )
4974        .expect("editor");
4975        assert_eq!(ext.query("directive").expect("query").len(), 1);
4976    }
4977
4978    #[test]
4979    fn document_html_elements_make_embedded_img_queryable() {
4980        let src = "text <img src=\"a.png\" alt=\"x\"> more\n";
4981        // Without the flag, the `<img>` is opaque raw HTML — no `image` node.
4982        let mut plain = Document::parse_str(src, Format::Markdown).expect("parse");
4983        assert_eq!(plain.query("image").expect("query").len(), 0);
4984        // With it enabled on the read path, the promoted image is queryable.
4985        let mut ext = Document::parse_str_with(
4986            src,
4987            Format::Markdown,
4988            MarkdownExtensions {
4989                html_elements: true,
4990                ..Default::default()
4991            },
4992        )
4993        .expect("parse");
4994        let images = ext.query("image").expect("query");
4995        assert_eq!(images.len(), 1);
4996        assert_eq!(images[0].kind, Kind::Image);
4997    }
4998
4999    #[test]
5000    fn editor_filter_public_audience_view() {
5001        let src = "# Archive\n\n:::vis{.public}\nPublic.\n:::\n\n:::vis{.family}\nPrivate.\n:::\n";
5002        let mut ed = Editor::new_ext(
5003            src.as_bytes(),
5004            Format::Markdown,
5005            MarkdownExtensions {
5006                directives: true,
5007                ..Default::default()
5008            },
5009        )
5010        .expect("editor");
5011        // Drop every vis block except the public one, then unwrap it.
5012        ed.filter(
5013            "directive[name=vis]",
5014            Some("directive[class~=public]"),
5015            true,
5016        )
5017        .expect("filter");
5018        assert_eq!(ed.source_str().expect("source"), "# Archive\n\nPublic.\n");
5019    }
5020
5021    #[test]
5022    fn editor_filter_rejects_a_malformed_selector() {
5023        let mut ed = Editor::new_str("hi\n", Format::Markdown).expect("editor");
5024        assert_eq!(
5025            ed.filter("list >", None, false),
5026            Err(Error::InvalidArgument)
5027        );
5028    }
5029
5030    #[test]
5031    fn builder_builds_and_renders_a_document() {
5032        let mut b = Builder::new().expect("builder");
5033
5034        // # Title\n\nhello *world*
5035        let title = b.add_text(TextKind::Str, "Title").unwrap();
5036        let heading = b.add_heading(1).unwrap();
5037        b.set_children(heading, &[title]).unwrap();
5038
5039        let hello = b.add_text(TextKind::Str, "hello ").unwrap();
5040        let world = b.add_text(TextKind::Str, "world").unwrap();
5041        let emph = b.add(VoidKind::Emph).unwrap();
5042        b.set_children(emph, &[world]).unwrap();
5043        let para = b.add(VoidKind::Para).unwrap();
5044        b.set_children(para, &[hello, emph]).unwrap();
5045
5046        let doc = b.add(VoidKind::Doc).unwrap();
5047        b.set_children(doc, &[heading, para]).unwrap();
5048
5049        let html = String::from_utf8(b.render_html(doc).unwrap()).unwrap();
5050        assert!(html.contains("<h1>Title</h1>"), "{html}");
5051        assert!(html.contains("<em>world</em>"), "{html}");
5052
5053        let md = String::from_utf8(b.serialize(doc, Format::Markdown).unwrap()).unwrap();
5054        assert!(md.contains("# Title"), "{md}");
5055        assert!(md.contains("*world*"), "{md}");
5056
5057        let matches = b.query(doc, "heading").unwrap();
5058        assert_eq!(matches.len(), 1);
5059        assert_eq!(matches[0].kind, Kind::Heading);
5060
5061        let json = String::from_utf8(b.ast_json(doc).unwrap()).unwrap();
5062        assert!(json.contains("\"kind\": \"doc\""), "{json}");
5063    }
5064
5065    #[test]
5066    fn builder_element_with_attributes() {
5067        let mut b = Builder::new().expect("builder");
5068        let inner = b.add_text(TextKind::Str, "hi").unwrap();
5069        let el = b.add_element("section").unwrap();
5070        b.set_children(el, &[inner]).unwrap();
5071        b.set_attrs(el, &[("class", Some("note")), ("hidden", None)])
5072            .unwrap();
5073
5074        let html = String::from_utf8(b.render_html(el).unwrap()).unwrap();
5075        assert!(html.contains("<section"), "{html}");
5076        assert!(html.contains("class=\"note\""), "{html}");
5077        assert!(html.contains("hidden"), "{html}");
5078    }
5079
5080    #[test]
5081    fn builder_lists_round_trip_to_markdown() {
5082        let mut b = Builder::new().expect("builder");
5083
5084        // An ordered list: 1. one / 2. two
5085        let one_txt = b.add_text(TextKind::Str, "one").unwrap();
5086        let one_para = b.add(VoidKind::Para).unwrap();
5087        b.set_children(one_para, &[one_txt]).unwrap();
5088        let one = b.add(VoidKind::ListItem).unwrap();
5089        b.set_children(one, &[one_para]).unwrap();
5090
5091        let two_txt = b.add_text(TextKind::Str, "two").unwrap();
5092        let two_para = b.add(VoidKind::Para).unwrap();
5093        b.set_children(two_para, &[two_txt]).unwrap();
5094        let two = b.add(VoidKind::ListItem).unwrap();
5095        b.set_children(two, &[two_para]).unwrap();
5096
5097        let list = b
5098            .add_ordered_list(
5099                OrderedNumbering::Decimal,
5100                OrderedDelim::Period,
5101                true,
5102                Some(1),
5103            )
5104            .unwrap();
5105        b.set_children(list, &[one, two]).unwrap();
5106        let doc = b.add(VoidKind::Doc).unwrap();
5107        b.set_children(doc, &[list]).unwrap();
5108
5109        let md = String::from_utf8(b.serialize(doc, Format::Markdown).unwrap()).unwrap();
5110        assert!(md.contains("1. one"), "{md}");
5111        assert!(md.contains("2. two"), "{md}");
5112    }
5113
5114    #[test]
5115    fn builder_rejects_invalid_kind_and_id() {
5116        let b = Builder::new().expect("builder");
5117        // `heading` (code 2) carries a payload, so the void-kind `add` rejects it
5118        // — the safe `VoidKind` enum has no such variant, so we go through the raw
5119        // ABI to prove the guard.
5120        let mut id = 0u32;
5121        let status = unsafe { ffi::twig_builder_add(b.raw.as_ptr(), 2, &mut id) };
5122        assert_eq!(Error::from_status(status), Err(Error::InvalidArgument));
5123
5124        // A root id past the end can't be rendered.
5125        let mut ptr = std::ptr::null();
5126        let mut len = 0usize;
5127        let status =
5128            unsafe { ffi::twig_builder_render_html(b.raw.as_ptr(), 4242, &mut ptr, &mut len) };
5129        assert_eq!(Error::from_status(status), Err(Error::InvalidArgument));
5130    }
5131}