Skip to main content

twig/
lib.rs

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