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