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