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