Skip to main content

twig/
lib.rs

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