Skip to main content

twig/
lib.rs

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