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        // Each one knows where it stands. A link reference definition used
3725        // to answer `0..0`, which left an editor unable to tell its lines
3726        // from blank ones.
3727        for d in &defs {
3728            let want = match d.kind {
3729                Kind::Footnote => 17..27,
3730                Kind::Reference => 29..36,
3731                _ => unreachable!(),
3732            };
3733            assert_eq!(d.span, want, "{} stands on its own bytes", d.kind);
3734        }
3735
3736        // None of them is reachable from the root — the property that made a
3737        // whole-arena rescan the only way to find them.
3738        let all = doc.nodes().expect("nodes");
3739        let root = all
3740            .iter()
3741            .find(|n| n.kind == Kind::Doc)
3742            .expect("a doc root");
3743        let mut reachable = vec![root.id];
3744        let mut i = 0;
3745        while i < reachable.len() {
3746            let n = &all[reachable[i].0 as usize];
3747            let mut c = n.first_child;
3748            while let Some(cid) = c {
3749                reachable.push(cid);
3750                c = all[cid.0 as usize].next_sibling;
3751            }
3752            i += 1;
3753        }
3754        for d in &defs {
3755            assert!(
3756                !reachable.contains(&NodeId(d.node_id)),
3757                "{} should be unreachable from the root",
3758                d.kind
3759            );
3760        }
3761
3762        // A document that defines nothing gets an empty vec, not an error.
3763        let mut plain = Document::parse_str("just text\n", Format::Markdown).expect("parse");
3764        assert_eq!(plain.definitions().expect("definitions"), Vec::new());
3765    }
3766
3767    #[test]
3768    fn kind_round_trips_through_its_published_name() {
3769        // `as_str` is the wire vocabulary and `from` is its inverse, so any
3770        // variant whose spelling drifts from the C ABI's fails here rather
3771        // than quietly becoming `Other`.
3772        for k in [
3773            Kind::Doc,
3774            Kind::Para,
3775            Kind::Heading,
3776            Kind::Container,
3777            Kind::TaskListItem,
3778            Kind::Superscript,
3779            Kind::FootnoteReference,
3780            Kind::ProcessingInstruction,
3781            Kind::Cdata,
3782        ] {
3783            assert_eq!(Kind::from(k.as_str()), k, "{k} did not round-trip");
3784            assert!(!k.is_unknown());
3785        }
3786    }
3787
3788    #[test]
3789    fn an_unknown_kind_name_is_carried_rather_than_lost() {
3790        // A newer library against an older binding. The node is still a node,
3791        // and a renderer that passes it through unchanged should be able to.
3792        let k = Kind::from("some_future_kind");
3793        assert!(k.is_unknown());
3794        assert_eq!(k.as_str(), "some_future_kind");
3795        assert_eq!(k, Kind::Other("some_future_kind".to_string()));
3796    }
3797
3798    #[test]
3799    fn every_kind_the_library_publishes_has_a_variant() {
3800        // Walks documents covering every corner of the vocabulary this crate
3801        // can reach from Rust and asserts nothing arrives as `Other`. If twig
3802        // adds a kind, or renames one, this fails — which is the whole reason
3803        // the enum is here instead of a `String`.
3804        let cases: &[(&str, Format, MarkdownExtensions)] = &[
3805            (
3806                "# 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",
3807                Format::Markdown,
3808                MarkdownExtensions {
3809                    directives: false,
3810                    math: false,
3811                    html_elements: false,
3812                    highlight: false,
3813                    highlight_colors: false,
3814                },
3815            ),
3816            (
3817                "| 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",
3818                Format::Markdown,
3819                MarkdownExtensions::default(),
3820            ),
3821            (
3822                ":::note\nbody\n:::\n\n:role[x]\n\n$a+b$ ==h== ==🔴 r==\n",
3823                Format::Markdown,
3824                MarkdownExtensions {
3825                    directives: true,
3826                    math: true,
3827                    html_elements: false,
3828                    highlight: true,
3829                    highlight_colors: true,
3830                },
3831            ),
3832            (
3833                "a^b^ c~d~ {=e=} {+f+} {-g-} 'q' \"dq\"\n\n![i](/p)\n\n<https://e.com>\n",
3834                Format::Djot,
3835                MarkdownExtensions::default(),
3836            ),
3837            (
3838                "<!-- c --><!DOCTYPE html><video controls><p>x</p></video>",
3839                Format::Html,
3840                MarkdownExtensions::default(),
3841            ),
3842        ];
3843
3844        let mut unknown: Vec<String> = Vec::new();
3845        let mut seen: Vec<String> = Vec::new();
3846        for (src, format, ext) in cases {
3847            let mut ed = Editor::new_ext(src.as_bytes(), *format, *ext).expect("editor");
3848            for n in ed.nodes().expect("nodes") {
3849                if n.kind.is_unknown() {
3850                    unknown.push(n.kind.as_str().to_string());
3851                }
3852                seen.push(n.kind.as_str().to_string());
3853            }
3854        }
3855        unknown.sort();
3856        unknown.dedup();
3857        assert!(unknown.is_empty(), "kinds with no variant: {unknown:?}");
3858
3859        // And the sweep really swept: without this the assertion above passes
3860        // just as happily on an empty walk.
3861        seen.sort();
3862        seen.dedup();
3863        assert!(
3864            seen.len() >= 30,
3865            "only {} distinct kinds reached: {seen:?}",
3866            seen.len()
3867        );
3868    }
3869
3870    #[test]
3871    fn diagnostics_report_what_a_conversion_would_lose() {
3872        // A djot superscript has no Markdown spelling. The two answers below
3873        // are for the SAME document — fidelity is a property of the
3874        // (document, target) pair, which is why it is asked per target.
3875        let mut doc = Document::parse_str("a^b^ c\n", Format::Djot).expect("parse djot");
3876
3877        let to_md = doc
3878            .diagnostics(Target::Markdown)
3879            .expect("markdown diagnostics");
3880        assert_eq!(
3881            to_md,
3882            vec![Warning {
3883                fidelity: Fidelity::Degraded,
3884                path: "0/1".to_string(),
3885                kind: Kind::Superscript,
3886            }]
3887        );
3888
3889        // Lossless to djot: an empty vec is a real answer, not a failure.
3890        assert_eq!(
3891            doc.diagnostics(Target::Djot).expect("djot diagnostics"),
3892            Vec::new()
3893        );
3894    }
3895
3896    #[test]
3897    fn diagnostics_separate_a_droppable_node_from_a_degradable_one() {
3898        // An HTML comment converted to djot leaves NOTHING behind — a
3899        // different and worse answer than "comes back as something else", and
3900        // the distinction a consumer needs to decide whether to warn or refuse.
3901        let mut doc =
3902            Document::parse_str("<p>hi</p><!-- secret -->", Format::Html).expect("parse html");
3903        let warnings = doc.diagnostics(Target::Djot).expect("djot diagnostics");
3904        let comment = warnings
3905            .iter()
3906            .find(|w| w.kind == Kind::Comment)
3907            .expect("a warning about the comment");
3908        assert_eq!(comment.fidelity, Fidelity::Dropped);
3909    }
3910
3911    #[test]
3912    fn diagnostics_refuse_a_target_with_no_serializer() {
3913        // "This target cannot be written" is a capability answer, not a
3914        // per-node diagnosis of every node in the document.
3915        let mut doc = Document::parse_str("# hi\n", Format::Markdown).expect("parse markdown");
3916        assert_eq!(doc.diagnostics(Target::Xml), Err(Error::UnsupportedFormat));
3917        // AsciiDoc has a serializer now, so it gets a per-node answer instead.
3918        assert!(doc.diagnostics(Target::Asciidoc).is_ok());
3919    }
3920
3921    #[test]
3922    fn diagnostics_flag_a_header_less_table_and_leave_a_headed_one_alone() {
3923        // The instance-level answer, and the one a consumer cannot reach by
3924        // looking at kinds: both documents contain a `table`, and only one of
3925        // them costs anything to convert. GFM's delimiter row is mandatory, so
3926        // the header-less table gets an empty header synthesized above it.
3927        let mut headed = Document::parse_str(
3928            "<table><tr><th>H</th></tr><tr><td>a</td></tr></table>",
3929            Format::Html,
3930        )
3931        .expect("parse headed table");
3932        assert!(
3933            headed
3934                .diagnostics(Target::Markdown)
3935                .expect("diagnostics")
3936                .iter()
3937                .all(|w| w.kind != Kind::Table)
3938        );
3939
3940        let mut headless = Document::parse_str("<table><tr><td>a</td></tr></table>", Format::Html)
3941            .expect("parse header-less table");
3942        let table_warning = headless
3943            .diagnostics(Target::Markdown)
3944            .expect("diagnostics")
3945            .into_iter()
3946            .find(|w| w.kind == Kind::Table)
3947            .expect("a warning about the table");
3948        assert_eq!(table_warning.fidelity, Fidelity::Degraded);
3949    }
3950
3951    #[test]
3952    fn container_origin_separates_a_div_from_a_div() {
3953        // The collision this field exists for. These two documents produce
3954        // container nodes that agree on `kind`, on `name` AND on
3955        // `directive_form` — so a consumer holding one of them could not say
3956        // which syntax the author wrote without re-reading the source bytes.
3957        let mut html =
3958            Editor::new("<div>hi</div>\n".as_bytes(), Format::Html).expect("html editor");
3959        let mut md = Editor::new_ext(
3960            ":::div\nhi\n:::\n".as_bytes(),
3961            Format::Markdown,
3962            MarkdownExtensions {
3963                directives: true,
3964                ..Default::default()
3965            },
3966        )
3967        .expect("markdown editor");
3968
3969        let html_nodes = html.nodes().expect("html nodes");
3970        let md_nodes = md.nodes().expect("markdown nodes");
3971        let tag = html_nodes
3972            .iter()
3973            .find(|n| n.name.as_deref() == Some("div"))
3974            .expect("a <div> container");
3975        let directive = md_nodes
3976            .iter()
3977            .find(|n| n.name.as_deref() == Some("div"))
3978            .expect("a :::div container");
3979
3980        // Indistinguishable on every field that predates `origin`.
3981        assert_eq!(tag.kind, directive.kind);
3982        assert_eq!(tag.name, directive.name);
3983        assert_eq!(tag.directive_form, directive.directive_form);
3984        assert_eq!(tag.directive_form, Some(DirectiveForm::Container));
3985
3986        // And decidable now.
3987        assert_eq!(tag.origin, Some(ContainerOrigin::Element));
3988        assert_eq!(directive.origin, Some(ContainerOrigin::Directive));
3989    }
3990
3991    /// Parse `src` as both authorable formats and run `check` over each — the
3992    /// shape every test below wants, because the point of these two APIs is
3993    /// that a consumer cannot tell which parser produced the tree.
3994    fn for_both_formats(src: &str, check: impl Fn(&mut Document, Format)) {
3995        for format in [Format::Markdown, Format::Djot] {
3996            let mut doc = Document::parse(src.as_bytes(), format).expect("parse");
3997            check(&mut doc, format);
3998        }
3999    }
4000
4001    #[test]
4002    fn marker_span_is_what_a_rich_view_hides() {
4003        for_both_formats("> - [x] done\n", |doc, format| {
4004            let nodes = doc.nodes().expect("nodes");
4005            let quote = nodes
4006                .iter()
4007                .find(|n| n.kind == Kind::BlockQuote)
4008                .expect("a block quote");
4009            let item = nodes
4010                .iter()
4011                .find(|n| n.kind == Kind::TaskListItem)
4012                .expect("a task item");
4013
4014            // The quote's `> ` and the item's `- [x] ` — the item's marker
4015            // takes its checkbox with it, because the rendered view draws a
4016            // checkbox in PLACE of those bytes rather than beside them.
4017            assert_eq!(quote.marker_span, Some(0..2), "{format:?}");
4018            assert_eq!(item.marker_span, Some(2..8), "{format:?}");
4019
4020            // Not derivable from the other two spans: a marker-prefixed
4021            // container reports its whole extent as its interior, so the
4022            // subtraction a caller might reach for yields nothing.
4023            assert_eq!(quote.content_span, Some(quote.span.clone()), "{format:?}");
4024
4025            // A paragraph has no marker of its own; only its ancestors do.
4026            let para = nodes
4027                .iter()
4028                .find(|n| n.kind == Kind::Para)
4029                .expect("a paragraph");
4030            assert_eq!(para.marker_span, None, "{format:?}");
4031        });
4032    }
4033
4034    #[test]
4035    fn attrs_span_locates_the_attribute_block_a_heuristic_had_to_guess_at() {
4036        // A Djot attribute line sits on its OWN line above the block it
4037        // attaches to, so a consumer dropping the block has to drop that line
4038        // too. Without this the extent was guessed at by scanning for `{`,
4039        // which strands the line as a published paragraph when the guess
4040        // misses — and the line names the audience.
4041        let src = "{.vis .family}\nheld back\n\nplain\n";
4042        let mut doc = Document::parse(src.as_bytes(), Format::Djot).expect("parse");
4043        let nodes = doc.nodes().expect("nodes");
4044        let paras: Vec<&FlatNode> = nodes.iter().filter(|n| n.kind == Kind::Para).collect();
4045        assert_eq!(paras.len(), 2);
4046
4047        let span = doc
4048            .attrs_span(paras[0].id)
4049            .expect("attrs span")
4050            .expect("the attributed paragraph has one");
4051        assert_eq!(&src[span.clone()], "{.vis .family}");
4052        // The block's own span starts AFTER the attribute line, which is why
4053        // dropping the block alone leaves the line behind.
4054        assert!(span.end <= paras[0].span.start);
4055
4056        // `None` is a real answer, not a failure: the second paragraph is
4057        // unattributed.
4058        assert_eq!(doc.attrs_span(paras[1].id).expect("attrs span"), None);
4059    }
4060
4061    #[test]
4062    fn line_prefix_assembles_every_marker_on_the_line() {
4063        for_both_formats("> - [x] done\n", |doc, format| {
4064            // Four nodes' worth of hidden width as one range, which is what a
4065            // caret stepping over it needs — not a chain to stitch together.
4066            assert_eq!(doc.line_prefix(9).expect("prefix"), Some(0..8), "{format:?}");
4067        });
4068    }
4069
4070    #[test]
4071    fn line_prefix_is_none_on_a_continuation_line() {
4072        // Line two continues the quote but OPENS nothing. `None` is the honest
4073        // answer: what a continuation line repeats is a different question with
4074        // a different answer, and guessing it from marker spans is how an
4075        // editor ends up restructuring a document that never had the shape it
4076        // inferred.
4077        for_both_formats("> c\n> d\n", |doc, format| {
4078            assert_eq!(doc.line_prefix(2).expect("prefix"), Some(0..2), "{format:?}");
4079            assert_eq!(doc.line_prefix(6).expect("prefix"), None, "{format:?}");
4080        });
4081    }
4082
4083    #[test]
4084    fn a_caret_at_a_blocks_end_is_in_that_block_in_both_formats() {
4085        // The divergence this API exists for. Djot ends a paragraph's span
4086        // AFTER its newline and Markdown BEFORE it, so under half-open
4087        // containment offset 1 — the caret you get by pressing End on line one,
4088        // the commonest caret position there is — resolved to the paragraph
4089        // through Djot and to the root through Markdown.
4090        for_both_formats("a\n\nb\n", |doc, format| {
4091            for offset in [0usize, 1, 3, 4] {
4092                let hit = doc
4093                    .node_at_caret(offset)
4094                    .expect("caret hit")
4095                    .expect("some node");
4096                assert_eq!(hit.kind, Kind::Str, "{format:?} at {offset}");
4097            }
4098            // The blank line between the two blocks belongs to neither, and
4099            // neither does the empty line after the final newline.
4100            for offset in [2usize, 5] {
4101                let hit = doc
4102                    .node_at_caret(offset)
4103                    .expect("caret hit")
4104                    .expect("some node");
4105                assert_eq!(hit.kind, Kind::Doc, "{format:?} at {offset}");
4106            }
4107        });
4108    }
4109
4110    #[test]
4111    fn the_caret_chain_ends_at_the_node_the_scalar_call_returns() {
4112        for_both_formats("- a\n", |doc, format| {
4113            let hit = doc.node_at_caret(3).expect("hit").expect("some node");
4114            let chain = doc.ancestors_at_caret(3).expect("chain");
4115            assert_eq!(chain.last().map(|m| m.node_id), Some(hit.node_id), "{format:?}");
4116            // And the chain passes through the item, which is what a gesture
4117            // scoped to "the block I'm in" needs at a caret sitting at its end.
4118            assert!(
4119                chain.iter().any(|m| m.kind == Kind::ListItem),
4120                "{format:?}: chain should reach the list item"
4121            );
4122        });
4123    }
4124
4125    #[test]
4126    fn continuation_prefix_repeats_a_quote_and_indents_past_an_item() {
4127        for_both_formats("> - a\n", |doc, format| {
4128            // The bytes already on the line, and the bytes a continuation would
4129            // need. They differ exactly where an editor gets it wrong by hand:
4130            // the item's `- ` is PRESENT and must not be repeated, or the
4131            // continuation opens a second item instead of continuing the first.
4132            assert_eq!(doc.line_prefix(4).expect("prefix"), Some(0..4), "{format:?}");
4133            let cont = doc.continuation_prefix(4).expect("continuation");
4134            assert_eq!(cont.text, ">   ", "{format:?}");
4135            assert_eq!(cont.columns, 4, "{format:?}");
4136        });
4137    }
4138
4139    #[test]
4140    fn continuation_prefix_answers_on_a_line_that_opens_nothing() {
4141        // The case `line_prefix` declines. Each ancestor answers from its own
4142        // opening line, so the quote's marker is still found on line one.
4143        for_both_formats("> c\n> d\n", |doc, format| {
4144            assert_eq!(doc.line_prefix(6).expect("prefix"), None, "{format:?}");
4145            assert_eq!(
4146                doc.continuation_prefix(6).expect("continuation").text,
4147                "> ",
4148                "{format:?}"
4149            );
4150        });
4151    }
4152
4153    #[test]
4154    fn continuation_prefix_takes_an_ordered_markers_own_width() {
4155        // `10. ` is four columns where `1. ` is three. A fixed indent is the
4156        // assumption that makes Tab wrong on the tenth item.
4157        for_both_formats("10. x\n", |doc, format| {
4158            assert_eq!(
4159                doc.continuation_prefix(4).expect("continuation").columns,
4160                4,
4161                "{format:?}"
4162            );
4163        });
4164        for_both_formats("1. x\n", |doc, format| {
4165            assert_eq!(
4166                doc.continuation_prefix(3).expect("continuation").columns,
4167                3,
4168                "{format:?}"
4169            );
4170        });
4171    }
4172
4173    #[test]
4174    fn a_blank_line_keeps_a_quote_alive_and_drops_an_items_indent() {
4175        for_both_formats("> - a\n", |doc, format| {
4176            let blank = doc.blank_line_prefix(4).expect("blank");
4177            // `>` and not `> `: the space after the marker is content indent,
4178            // and a blank line has no content.
4179            assert_eq!(blank.text, ">", "{format:?}");
4180            assert_eq!(blank.columns, 1, "{format:?}");
4181        });
4182        // Inside a list alone there is nothing to keep alive, so a blank line
4183        // carries nothing at all.
4184        for_both_formats("- a\n", |doc, format| {
4185            assert_eq!(doc.blank_line_prefix(3).expect("blank").text, "", "{format:?}");
4186        });
4187    }
4188
4189    #[test]
4190    fn a_prefix_column_count_is_not_its_byte_length() {
4191        // A tab in a marker advances to a tab stop, so the two diverge — which
4192        // is why `columns` is carried rather than left to the caller to infer.
4193        let mut doc = Document::parse("-	x
4194".as_bytes(), Format::Markdown).expect("parse");
4195        let cont = doc.continuation_prefix(2).expect("continuation");
4196        assert_eq!(cont.columns, 4);
4197    }
4198
4199    #[test]
4200    fn set_block_opens_a_heading_on_a_blank_line() {
4201        for format in [Format::Markdown, Format::Djot] {
4202            let mut ed = Editor::new("a\n\n".as_bytes(), format).expect("editor");
4203            ed.set_block(3, BlockKind::Heading(2)).expect("set_block");
4204            assert_eq!(ed.source().expect("source"), b"a\n\n## ", "{format:?}");
4205            // The reparse is the assertion that matters, not the bytes: Djot
4206            // does not let a heading interrupt a paragraph, so a marker written
4207            // without the separating blank would come back as literal text.
4208            let nodes = ed.nodes().expect("nodes");
4209            assert!(
4210                nodes.iter().any(|n| n.kind == Kind::Heading),
4211                "{format:?}: should have parsed a heading"
4212            );
4213        }
4214    }
4215
4216    #[test]
4217    fn set_block_refuses_a_blank_line_inside_a_code_block() {
4218        // `innermostBlock` reports nothing here exactly as it does between
4219        // blocks; only the line's owner tells them apart. Writing `# ` in would
4220        // add no heading and corrupt the listing.
4221        for format in [Format::Markdown, Format::Djot] {
4222            let src = "```\nx\n\ny\n```\n";
4223            let mut ed = Editor::new(src.as_bytes(), format).expect("editor");
4224            let blank = src.find("\n\n").expect("a blank line") + 1;
4225            assert!(
4226                matches!(
4227                    ed.set_block(blank, BlockKind::Heading(1)),
4228                    Err(Error::NotEditable)
4229                ),
4230                "{format:?}"
4231            );
4232            assert_eq!(ed.source().expect("source"), src.as_bytes(), "{format:?}");
4233        }
4234    }
4235
4236    #[test]
4237    fn task_items_report_their_checkbox_state() {
4238        // Twig would WRITE a checkbox and not read one back, so a consumer
4239        // rendering a clickable box re-derived the state by scanning for `[x]`.
4240        // A capital `[X]` is checked too, which that scan misses.
4241        for_both_formats("- [ ] a\n- [x] b\n- [X] c\n- d\n", |doc, format| {
4242            let nodes = doc.nodes().expect("nodes");
4243            let states: Vec<Option<bool>> = nodes
4244                .iter()
4245                .filter(|n| matches!(n.kind, Kind::TaskListItem | Kind::ListItem))
4246                .map(|n| n.checked)
4247                .collect();
4248            assert_eq!(
4249                states,
4250                vec![Some(false), Some(true), Some(true), None],
4251                "{format:?}"
4252            );
4253
4254            // `None` is not `Some(false)`: a consumer treating "not a task
4255            // item" as unchecked draws an empty box beside every paragraph.
4256            for n in nodes.iter().filter(|n| n.kind == Kind::Para) {
4257                assert_eq!(n.checked, None, "{format:?}");
4258            }
4259        });
4260    }
4261
4262    #[test]
4263    fn an_editor_reaches_the_caret_reads_through_its_document_view() {
4264        // The path an editing host actually takes. These reads are questions
4265        // about a TREE, not about an editing session, so they live on the
4266        // document surface and an editor borrows it — no `twig_editor_*` alias
4267        // to keep in step. See DESIGN.md, "The reads are not editor-specific."
4268        let mut ed = Editor::new("- a\n".as_bytes(), Format::Markdown).expect("editor");
4269        let mut view = ed.document().expect("document view");
4270
4271        assert_eq!(view.line_prefix(3).expect("prefix"), Some(0..2));
4272        let hit = view.node_at_caret(3).expect("hit").expect("some node");
4273        assert_eq!(hit.kind, Kind::Str);
4274    }
4275
4276    #[test]
4277    fn container_origin_is_none_for_non_containers() {
4278        // The field is a container's, so everything else reports `None` rather
4279        // than a default that would read as a real answer.
4280        let mut ed = Editor::new("# hi\n\npara\n".as_bytes(), Format::Markdown).expect("editor");
4281        for n in ed.nodes().expect("nodes") {
4282            assert_eq!(n.origin, None, "{} should carry no origin", n.kind);
4283        }
4284    }
4285
4286    #[test]
4287    fn flat_nodes_expose_directive_name_and_form() {
4288        // All three surface forms report `kind == "container"`, so the snapshot
4289        // has to carry both halves of a directive's identity: which type it is
4290        // (`name`) and how it was written (`directive_form`). Without them a
4291        // renderer can't tell an `::embed` from a `::toc`, nor an inline span
4292        // from a standalone block.
4293        let src = ":::note{.warning}\nBody\n:::\n\n::embed{src=\"demo.html\"}\n\nSee :abbr[HTML]{title=\"HyperText\"} inline.\n";
4294        let mut ed = Editor::new_ext(
4295            src.as_bytes(),
4296            Format::Markdown,
4297            MarkdownExtensions {
4298                directives: true,
4299                ..Default::default()
4300            },
4301        )
4302        .expect("editor");
4303        let nodes = ed.nodes().expect("nodes");
4304
4305        let forms: Vec<(Option<&str>, Option<DirectiveForm>)> = nodes
4306            .iter()
4307            .filter(|n| n.kind == Kind::Container)
4308            .map(|n| (n.name.as_deref(), n.directive_form))
4309            .collect();
4310        assert_eq!(
4311            forms,
4312            vec![
4313                (Some("note"), Some(DirectiveForm::Container)),
4314                (Some("embed"), Some(DirectiveForm::Leaf)),
4315                (Some("abbr"), Some(DirectiveForm::Text)),
4316            ]
4317        );
4318
4319        // The attributes still ride the ordinary side-table, and a non-directive
4320        // reports no form at all.
4321        let embed = nodes
4322            .iter()
4323            .find(|n| n.name.as_deref() == Some("embed"))
4324            .expect("embed");
4325        assert_eq!(
4326            embed.attrs,
4327            vec![("src".to_string(), Some("demo.html".to_string()))]
4328        );
4329        let para = nodes.iter().find(|n| n.kind == Kind::Para).expect("a para");
4330        assert!(para.directive_form.is_none() && para.name.is_none());
4331    }
4332
4333    #[test]
4334    fn editor_insert_child_and_delete() {
4335        let mut ed = Editor::new_str("<r><a/><c/></r>", Format::Xml).expect("editor");
4336        ed.insert_child("0", 1, "<b/>").expect("insert_child");
4337        assert_eq!(ed.source_str().expect("source"), "<r><a/><b/><c/></r>");
4338        ed.delete("0.1").expect("delete");
4339        assert_eq!(ed.source_str().expect("source"), "<r><a/><c/></r>");
4340    }
4341
4342    #[test]
4343    fn editor_edits_by_selector() {
4344        let mut ed = Editor::new_str("# One\n\n## Two\n", Format::Markdown).expect("editor");
4345        ed.replace("heading(\"Two\")", "## Renamed")
4346            .expect("replace");
4347        assert_eq!(ed.source_str().expect("source"), "# One\n\n## Renamed\n");
4348    }
4349
4350    #[test]
4351    fn editor_locator_errors_are_distinct() {
4352        let mut ed = Editor::new_str("<r><a/><a/></r>", Format::Xml).expect("editor");
4353        assert_eq!(ed.replace("0.9", "x"), Err(Error::NotFound));
4354        assert_eq!(ed.replace("element", "x"), Err(Error::Ambiguous));
4355        assert_eq!(ed.replace("element(", "x"), Err(Error::InvalidArgument));
4356        // Untouched by the failed edits.
4357        assert_eq!(ed.source_str().expect("source"), "<r><a/><a/></r>");
4358    }
4359
4360    #[test]
4361    fn editor_reparse_break_rolls_back() {
4362        let mut ed = Editor::new_str("<a>ok</a>", Format::Xml).expect("editor");
4363        assert_eq!(ed.replace_content("0", "<b>"), Err(Error::EditConflict));
4364        assert_eq!(ed.source_str().expect("source"), "<a>ok</a>");
4365    }
4366
4367    #[test]
4368    fn editor_leaf_content_is_not_editable() {
4369        let mut ed = Editor::new_str("<a>hi</a>", Format::Xml).expect("editor");
4370        assert_eq!(ed.replace_content("0.0", "x"), Err(Error::NotEditable));
4371    }
4372
4373    #[test]
4374    fn editor_query_reflects_current_tree() {
4375        let mut ed = Editor::new_str("<r><a/></r>", Format::Xml).expect("editor");
4376        ed.insert_child("0", 1, "<b/>").expect("insert_child");
4377        // Root <r> plus <a/> and <b/>.
4378        assert_eq!(ed.query("element").expect("query").len(), 3);
4379        let json = ed.ast_json().expect("ast_json");
4380        assert!(String::from_utf8_lossy(&json).contains("\"kind\": \"doc\""));
4381    }
4382
4383    // ── offset-addressed editing & read-back (P0–P3) ────────────────────────
4384
4385    #[test]
4386    fn editor_edit_range_types_backspaces_and_reports_change() {
4387        let mut ed = Editor::new_str("ab\n", Format::Markdown).expect("editor");
4388
4389        // Type "X" at offset 1 (a zero-width splice = an insertion).
4390        let c = ed.edit_range(1, 1, "X").expect("edit_range insert");
4391        assert_eq!(ed.source_str().unwrap(), "aXb\n");
4392        assert_eq!(c.old, 1..1);
4393        assert_eq!(c.new, 1..2);
4394        assert_eq!(c.delta(), 1);
4395
4396        // Backspace it (delete the "X").
4397        let c2 = ed.edit_range(1, 2, "").expect("edit_range delete");
4398        assert_eq!(ed.source_str().unwrap(), "ab\n");
4399        assert_eq!(c2.old, 1..2);
4400        assert_eq!(c2.new, 1..1);
4401        assert_eq!(c2.delta(), -1);
4402    }
4403
4404    #[test]
4405    fn editor_edit_range_rejects_bad_ranges() {
4406        let mut ed = Editor::new_str("hi\n", Format::Markdown).expect("editor");
4407        assert_eq!(ed.edit_range(0, 99, "x"), Err(Error::InvalidArgument)); // end past len
4408        assert_eq!(ed.edit_range(2, 1, "x"), Err(Error::InvalidArgument)); // start > end
4409        assert_eq!(ed.source_str().unwrap(), "hi\n"); // untouched
4410    }
4411
4412    #[test]
4413    fn editor_last_change_reports_locator_ops_too() {
4414        let mut ed = Editor::new_str("# One\n\n## Two\n", Format::Markdown).expect("editor");
4415        assert_eq!(ed.last_change(), None); // nothing edited yet
4416
4417        ed.replace("heading(\"Two\")", "## Renamed")
4418            .expect("replace");
4419        assert_eq!(ed.source_str().unwrap(), "# One\n\n## Renamed\n");
4420        let c = ed.last_change().expect("a change was recorded");
4421        // "## Two" occupied [7,13); "## Renamed" (10 bytes) now occupies [7,17).
4422        assert_eq!(c.old, 7..13);
4423        assert_eq!(c.new, 7..17);
4424    }
4425
4426    #[test]
4427    fn editor_nodes_is_a_walkable_flat_tree() {
4428        let mut ed = Editor::new_str("# Hi\n\ntext\n", Format::Markdown).expect("editor");
4429        let nodes = ed.nodes().expect("nodes");
4430        assert!(!nodes.is_empty());
4431
4432        // Dense, index-aligned ids.
4433        for (i, n) in nodes.iter().enumerate() {
4434            assert_eq!(n.id, NodeId(i as u32));
4435        }
4436        // Exactly one root (no parent), and it's the doc.
4437        let roots: Vec<_> = nodes.iter().filter(|n| n.parent.is_none()).collect();
4438        assert_eq!(roots.len(), 1);
4439        assert_eq!(roots[0].kind, Kind::Doc);
4440
4441        // The heading carries its level; the "Hi" text is reachable as a payload.
4442        let heading = nodes
4443            .iter()
4444            .find(|n| n.kind == Kind::Heading)
4445            .expect("a heading");
4446        assert_eq!(heading.level, Some(1));
4447        assert!(nodes.iter().any(|n| n.text.as_deref() == Some("Hi")));
4448
4449        // A kind with no row/cell payload reports neither.
4450        assert_eq!(heading.head, None);
4451        assert_eq!(heading.alignment, None);
4452
4453        // Every non-root node's parent links back to a node that lists it as a
4454        // child (via first_child/next_sibling).
4455        for n in nodes.iter().filter(|n| n.parent.is_some()) {
4456            let p = &nodes[n.parent.unwrap().0 as usize];
4457            let mut kid = p.first_child;
4458            let mut seen = false;
4459            while let Some(NodeId(k)) = kid {
4460                if k == n.id.0 {
4461                    seen = true;
4462                    break;
4463                }
4464                kid = nodes[k as usize].next_sibling;
4465            }
4466            assert!(
4467                seen,
4468                "node {:?} not found among its parent's children",
4469                n.id
4470            );
4471        }
4472    }
4473
4474    #[test]
4475    fn editor_child_spans_and_subtree_agree_with_nodes() {
4476        let src = "# Title\n\nHello **world** and more.\n\n- one\n- two\n";
4477        let mut ed = Editor::new_str(src, Format::Markdown).expect("editor");
4478        let all = ed.nodes().expect("nodes");
4479        let doc = all.iter().find(|n| n.kind == Kind::Doc).expect("doc");
4480
4481        // child_spans(None) == the doc root's children, same ids/kinds/spans and
4482        // in the same order.
4483        let top = ed.child_spans(None).expect("child_spans");
4484        let mut want = Vec::new();
4485        let mut c = doc.first_child;
4486        while let Some(id) = c {
4487            want.push(id);
4488            c = all[id.0 as usize].next_sibling;
4489        }
4490        assert_eq!(top.len(), want.len(), "top-level count");
4491        for (m, id) in top.iter().zip(&want) {
4492            assert_eq!(m.node_id, id.0, "child id");
4493            assert_eq!(m.kind, all[id.0 as usize].kind, "child kind");
4494            assert_eq!(m.span, all[id.0 as usize].span, "child span");
4495        }
4496        // The span addresses the block as written (absolute offsets).
4497        assert!(
4498            src[top[0].span.clone()].starts_with('#'),
4499            "first block is the heading"
4500        );
4501
4502        // child_spans works below the top level too.
4503        let list = top
4504            .iter()
4505            .find(|m| {
4506                matches!(
4507                    m.kind,
4508                    Kind::BulletList | Kind::OrderedList | Kind::TaskList
4509                )
4510            })
4511            .expect("a list");
4512        let items = ed.child_spans(Some(NodeId(list.node_id))).expect("items");
4513        assert_eq!(items.len(), 2);
4514        assert!(
4515            items.iter().all(|m| m.kind == Kind::ListItem),
4516            "items: {items:?}"
4517        );
4518
4519        // subtree(para) is self-contained, local-indexed, and spans stay absolute.
4520        let para = top
4521            .iter()
4522            .find(|m| m.kind == Kind::Para)
4523            .expect("a para")
4524            .node_id;
4525        let sub = ed.subtree(NodeId(para)).expect("subtree");
4526        assert_eq!(sub[0].id, NodeId(0), "root is local id 0");
4527        assert_eq!(sub[0].parent, None, "root has no parent inside the subtree");
4528        assert_eq!(sub[0].next_sibling, None, "root's sibling is severed");
4529        assert_eq!(sub[0].kind, Kind::Para);
4530        for (i, n) in sub.iter().enumerate() {
4531            assert_eq!(n.id, NodeId(i as u32), "dense local ids");
4532            for link in [n.parent, n.first_child, n.next_sibling]
4533                .into_iter()
4534                .flatten()
4535            {
4536                assert!(
4537                    (link.0 as usize) < sub.len(),
4538                    "link {link:?} escapes the subtree"
4539                );
4540            }
4541        }
4542        assert!(
4543            src[sub[0].span.clone()].starts_with("Hello"),
4544            "absolute span: {:?}",
4545            &src[sub[0].span.clone()]
4546        );
4547
4548        // Same multiset of node kinds as the paragraph's arena subtree.
4549        fn arena_kinds(all: &[FlatNode], root: NodeId) -> Vec<Kind> {
4550            let mut out = Vec::new();
4551            let mut stack = vec![root];
4552            while let Some(id) = stack.pop() {
4553                let n = &all[id.0 as usize];
4554                out.push(n.kind.clone());
4555                let mut c = n.first_child;
4556                while let Some(cid) = c {
4557                    stack.push(cid);
4558                    c = all[cid.0 as usize].next_sibling;
4559                }
4560            }
4561            out
4562        }
4563        let mut want_kinds = arena_kinds(&all, NodeId(para));
4564        let mut got_kinds: Vec<Kind> = sub.iter().map(|n| n.kind.clone()).collect();
4565        // Sorted by NAME: `Kind` is deliberately not `Ord` (there is no
4566        // meaningful order over a vocabulary), and this only needs a canonical
4567        // one to compare two multisets.
4568        want_kinds.sort_by(|a, b| a.as_str().cmp(b.as_str()));
4569        got_kinds.sort_by(|a, b| a.as_str().cmp(b.as_str()));
4570        assert_eq!(got_kinds, want_kinds, "subtree kinds match the arena");
4571
4572        // Out-of-range id is rejected.
4573        assert!(matches!(
4574            ed.subtree(NodeId(9999)),
4575            Err(Error::InvalidArgument)
4576        ));
4577    }
4578
4579    #[test]
4580    fn flat_nodes_carry_table_head_and_alignment() {
4581        // The delimiter row (`|:-----|----:|`) is consumed by the parser and has
4582        // no node of its own, so `alignment` on the cells is the only way a
4583        // consumer can recover the column alignment from a snapshot.
4584        let src = "| Name | Qty |\n|:-----|----:|\n| Pear | 3 |\n";
4585        let mut ed = Editor::new_str(src, Format::Markdown).expect("editor");
4586        let nodes = ed.nodes().expect("nodes");
4587
4588        let rows: Vec<_> = nodes.iter().filter(|n| n.kind == Kind::Row).collect();
4589        assert_eq!(rows.len(), 2, "a header row and one body row");
4590        assert_eq!(rows[0].head, Some(true), "first row is the header");
4591        assert_eq!(rows[1].head, Some(false), "second row is a body row");
4592
4593        let cells: Vec<_> = nodes.iter().filter(|n| n.kind == Kind::Cell).collect();
4594        assert_eq!(cells.len(), 4);
4595        // Alignment comes from the delimiter row and applies down the column.
4596        assert_eq!(cells[0].alignment, Some(Alignment::Left));
4597        assert_eq!(cells[1].alignment, Some(Alignment::Right));
4598        assert_eq!(cells[2].alignment, Some(Alignment::Left));
4599        assert_eq!(cells[3].alignment, Some(Alignment::Right));
4600        // Header cells are flagged too, not just their row.
4601        assert_eq!(cells[0].head, Some(true));
4602        assert_eq!(cells[2].head, Some(false));
4603
4604        // A table with no alignment spelled out reports Default — a real value,
4605        // distinct from the None a non-cell reports.
4606        let mut plain =
4607            Editor::new_str("| A |\n| --- |\n| b |\n", Format::Markdown).expect("editor");
4608        let pnodes = plain.nodes().expect("nodes");
4609        let pcell = pnodes
4610            .iter()
4611            .find(|n| n.kind == Kind::Cell)
4612            .expect("a cell");
4613        assert_eq!(pcell.alignment, Some(Alignment::Default));
4614    }
4615
4616    #[test]
4617    fn cell_extent_reports_merged_cells_and_nothing_else() {
4618        let src = "<table><tr><td colspan=\"2\" rowspan=\"3\">a</td><td>b</td></tr></table>";
4619        let mut doc = Document::parse_str(src, Format::Html).expect("parse");
4620        let cells: Vec<NodeId> = doc
4621            .nodes()
4622            .expect("nodes")
4623            .iter()
4624            .filter(|n| n.kind == Kind::Cell)
4625            .map(|n| n.id)
4626            .collect();
4627        assert_eq!(cells.len(), 2);
4628        assert_eq!(doc.cell_extent(cells[0]).expect("extent"), Some((2, 3)));
4629        // A plain cell is one square — 1, never 0.
4630        assert_eq!(doc.cell_extent(cells[1]).expect("extent"), Some((1, 1)));
4631
4632        // A pipe table cannot express a span at all, so every cell is (1, 1).
4633        let mut pipe =
4634            Document::parse_str("| a |\n| --- |\n| b |\n", Format::Markdown).expect("parse");
4635        let pipe_cell = pipe
4636            .nodes()
4637            .expect("nodes")
4638            .iter()
4639            .find(|n| n.kind == Kind::Cell)
4640            .expect("a cell")
4641            .id;
4642        assert_eq!(pipe.cell_extent(pipe_cell).expect("extent"), Some((1, 1)));
4643
4644        // Not a cell at all: None, distinct from any extent.
4645        let root = NodeId(0);
4646        assert_eq!(pipe.cell_extent(root).expect("extent"), None);
4647    }
4648
4649    #[test]
4650    fn builder_add_cell_spanning_renders_colspan_and_rowspan() {
4651        let mut b = Builder::new().expect("builder");
4652        let wide_text = b.add_text(TextKind::Str, "wide").expect("str");
4653        let wide = b
4654            .add_cell_spanning(false, Alignment::Default, 2, 3)
4655            .expect("cell");
4656        b.set_children(wide, &[wide_text]).expect("children");
4657        let plain_text = b.add_text(TextKind::Str, "one").expect("str");
4658        let plain = b.add_cell(false, Alignment::Default).expect("cell");
4659        b.set_children(plain, &[plain_text]).expect("children");
4660        let row = b.add_row(false).expect("row");
4661        b.set_children(row, &[wide, plain]).expect("children");
4662        let table = b.add(VoidKind::Table).expect("table");
4663        b.set_children(table, &[row]).expect("children");
4664
4665        let html = String::from_utf8(b.render_html(table).expect("html")).expect("utf-8");
4666        assert!(
4667            html.contains("<td colspan=\"2\" rowspan=\"3\">wide</td>"),
4668            "{html}"
4669        );
4670        // `add_cell` is the one-square case: the default extent writes nothing.
4671        assert!(html.contains("<td>one</td>"), "{html}");
4672
4673        // A zero extent is no cell anyone can lay out.
4674        assert!(matches!(
4675            b.add_cell_spanning(false, Alignment::Default, 0, 1),
4676            Err(Error::InvalidArgument)
4677        ));
4678    }
4679
4680    #[test]
4681    fn editor_node_at_and_ancestors_hit_test_offsets() {
4682        let mut ed = Editor::new_str("# Hi\n\ntext\n", Format::Markdown).expect("editor");
4683
4684        // Offset 2 is the "H" of the heading "# Hi" [0,4).
4685        let m = ed
4686            .node_at(2)
4687            .expect("node_at")
4688            .expect("a node covers offset 2");
4689        assert!(m.span.contains(&2));
4690
4691        // The ancestor chain is root-first and ends at the deepest (== node_at).
4692        let chain = ed.ancestors_at(2).expect("ancestors_at");
4693        assert!(!chain.is_empty());
4694        assert_eq!(chain[0].kind, Kind::Doc);
4695        assert_eq!(chain.last().unwrap().node_id, m.node_id);
4696
4697        // An out-of-range offset is an error; a gap covers nothing deeper than doc.
4698        assert_eq!(ed.node_at(999), Err(Error::InvalidArgument));
4699    }
4700
4701    // ── range-oriented rich-text ops (P5) ───────────────────────────────────
4702
4703    #[test]
4704    fn editor_wrap_and_toggle_inline_round_trip() {
4705        let mut ed = Editor::new_str("a word b\n", Format::Markdown).expect("editor");
4706
4707        // Bold "word" [2,6); the Change reports the new "**word**" region.
4708        let c = ed.wrap_range(2, 6, InlineKind::Strong).expect("wrap");
4709        assert_eq!(ed.source_str().unwrap(), "a **word** b\n");
4710        assert_eq!(&ed.source_str().unwrap()[c.new.clone()], "**word**");
4711
4712        // Toggle it off by selecting the strong node's interior [4,8).
4713        ed.toggle_inline(4, 8, InlineKind::Strong)
4714            .expect("toggle off");
4715        assert_eq!(ed.source_str().unwrap(), "a word b\n");
4716
4717        // Toggle emphasis on when the range isn't already marked.
4718        ed.toggle_inline(2, 6, InlineKind::Emph).expect("toggle on");
4719        assert_eq!(ed.source_str().unwrap(), "a *word* b\n");
4720    }
4721
4722    #[test]
4723    fn editor_inline_marks_cut_at_block_boundaries() {
4724        // One pair per block, not one pair straddling the blank line — which
4725        // would reparse as four literal asterisks and no mark.
4726        let mut ed = Editor::new_str("one two\n\nthree four\n", Format::Markdown)
4727            .expect("editor");
4728        let c = ed.toggle_inline(0, 19, InlineKind::Strong).expect("toggle on");
4729        assert_eq!(
4730            ed.source_str().unwrap(),
4731            "**one two**\n\n**three four**\n"
4732        );
4733
4734        // One splice, so one Change spanning the lot and one undo step — the
4735        // whole reason the pieces are assembled before anything is written.
4736        assert_eq!(&ed.source_str().unwrap()[c.new.clone()], "**one two**\n\n**three four**");
4737        ed.undo().expect("undo");
4738        assert_eq!(ed.source_str().unwrap(), "one two\n\nthree four\n");
4739
4740        // And the second press removes both, rather than nesting a second pair
4741        // around each.
4742        ed.toggle_inline(0, 19, InlineKind::Strong).expect("toggle on");
4743        ed.toggle_inline(0, 27, InlineKind::Strong).expect("toggle off");
4744        assert_eq!(ed.source_str().unwrap(), "one two\n\nthree four\n");
4745
4746        // A range with nowhere in it to put a mark says so.
4747        let mut fenced = Editor::new_str("```\nx y\n```\n", Format::Markdown).expect("editor");
4748        assert_eq!(
4749            fenced.toggle_inline(4, 7, InlineKind::Strong),
4750            Err(Error::NotEditable)
4751        );
4752    }
4753
4754    #[test]
4755    fn editor_inline_kind_support_is_format_specific() {
4756        // Markdown has no highlight/mark spelling.
4757        let mut md = Editor::new_str("a word b\n", Format::Markdown).expect("editor");
4758        assert_eq!(
4759            md.wrap_range(2, 6, InlineKind::Mark),
4760            Err(Error::UnsupportedFormat)
4761        );
4762
4763        // Djot spells it {=…=}.
4764        let mut dj = Editor::new_str("a word b\n", Format::Djot).expect("editor");
4765        dj.wrap_range(2, 6, InlineKind::Mark).expect("djot mark");
4766        assert_eq!(dj.source_str().unwrap(), "a {=word=} b\n");
4767    }
4768
4769    #[test]
4770    fn editor_authors_gfm_strikethrough_out_of_the_box() {
4771        // The extension that defaults ON, so the default editor is the one
4772        // that can write it — the opposite direction from `highlight` below,
4773        // and no flag on this side turns it off.
4774        assert!(Format::Markdown.supports(Gesture::ToggleInline(InlineKind::Delete)));
4775        let mut ed = Editor::new_str("a word b\n", Format::Markdown).expect("editor");
4776        ed.toggle_inline(2, 6, InlineKind::Delete).expect("strike");
4777        assert_eq!(ed.source_str().unwrap(), "a ~~word~~ b\n");
4778        ed.toggle_inline(4, 8, InlineKind::Delete).expect("unstrike");
4779        assert_eq!(ed.source_str().unwrap(), "a word b\n");
4780    }
4781
4782    #[test]
4783    fn editor_highlight_is_authorable_with_the_extension_on() {
4784        let exts = MarkdownExtensions {
4785            highlight: true,
4786            ..Default::default()
4787        };
4788        // The same format and the same gesture, answered two ways: `==x==` is
4789        // text under default options and a mark under `highlight`, so the
4790        // toggle refuses in one and reverses in the other.
4791        assert!(!Format::Markdown.supports(Gesture::ToggleInline(InlineKind::Mark)));
4792        assert!(Format::Markdown.supports_with(exts, Gesture::ToggleInline(InlineKind::Mark)));
4793
4794        let mut ed =
4795            Editor::new_ext(b"a word b\n", Format::Markdown, exts).expect("editor");
4796        ed.toggle_inline(2, 6, InlineKind::Mark).expect("highlight");
4797        assert_eq!(ed.source_str().unwrap(), "a ==word== b\n");
4798        ed.toggle_inline(4, 8, InlineKind::Mark).expect("unhighlight");
4799        assert_eq!(ed.source_str().unwrap(), "a word b\n");
4800    }
4801
4802    #[test]
4803    fn editor_set_mark_color_writes_reads_and_clears_the_colour() {
4804        let exts = MarkdownExtensions {
4805            highlight: true,
4806            highlight_colors: true,
4807            ..Default::default()
4808        };
4809        assert!(Format::Markdown.supports_with(exts, Gesture::SetMarkColor));
4810        // The narrower gate: highlights alone do not buy a palette.
4811        let hi_only = MarkdownExtensions {
4812            highlight: true,
4813            ..Default::default()
4814        };
4815        assert!(!Format::Markdown.supports_with(hi_only, Gesture::SetMarkColor));
4816        assert!(!Format::Markdown.supports(Gesture::SetMarkColor));
4817        assert!(!Format::Djot.supports_with(exts, Gesture::SetMarkColor));
4818
4819        let mut ed =
4820            Editor::new_ext("a ==word== b\n".as_bytes(), Format::Markdown, exts).expect("editor");
4821        ed.set_mark_color(6, Some(MarkColor::Red)).expect("colour");
4822        assert_eq!(ed.source_str().unwrap(), "a ==\u{1F534} word== b\n");
4823
4824        // And it is queryable as the attribute it is, not as text.
4825        let mut doc =
4826            Document::parse_with(ed.source_str().unwrap().as_bytes(), Format::Markdown, exts)
4827                .expect("parse");
4828        assert_eq!(doc.query("mark[data-color=red]").expect("query").len(), 1);
4829
4830        ed.set_mark_color(9, Some(MarkColor::Blue)).expect("recolour");
4831        assert_eq!(ed.source_str().unwrap(), "a ==\u{1F535} word== b\n");
4832        ed.set_mark_color(9, None).expect("clear");
4833        assert_eq!(ed.source_str().unwrap(), "a ==word== b\n");
4834
4835        // A caret outside any highlight edits nothing.
4836        assert_eq!(
4837            ed.set_mark_color(0, Some(MarkColor::Red)),
4838            Err(Error::NotEditable)
4839        );
4840        assert_eq!(ed.source_str().unwrap(), "a ==word== b\n");
4841
4842        // The names are the attribute values, both ways.
4843        for c in [
4844            MarkColor::Red,
4845            MarkColor::Orange,
4846            MarkColor::Yellow,
4847            MarkColor::Green,
4848            MarkColor::Blue,
4849            MarkColor::Purple,
4850            MarkColor::Brown,
4851        ] {
4852            assert_eq!(MarkColor::from_str(c.as_str()), Some(c));
4853        }
4854        assert_eq!(MarkColor::from_str("pink"), None);
4855    }
4856
4857    #[test]
4858    fn editor_toggle_strips_verbatim_via_content_span() {
4859        let mut ed = Editor::new_str("a `code` b\n", Format::Markdown).expect("editor");
4860        // The verbatim node [2,8) reports content_span [3,7); toggle peels it.
4861        ed.toggle_inline(2, 8, InlineKind::Verbatim)
4862            .expect("toggle code off");
4863        assert_eq!(ed.source_str().unwrap(), "a code b\n");
4864
4865        // A multi-backtick span peels BOTH runs via content_span, not by
4866        // stripping a single delimiter (which would corrupt it to "`x`").
4867        let mut ed2 = Editor::new_str("a ``x`` b\n", Format::Markdown).expect("editor");
4868        ed2.toggle_inline(2, 7, InlineKind::Verbatim)
4869            .expect("toggle multi off");
4870        assert_eq!(ed2.source_str().unwrap(), "a x b\n");
4871    }
4872
4873    #[test]
4874    fn editor_set_block_switches_para_and_heading_levels() {
4875        let mut ed = Editor::new_str("Title\n\nbody text\n", Format::Markdown).expect("editor");
4876
4877        // Paragraph -> H2 (offset 0 is inside "Title").
4878        ed.set_block(0, BlockKind::Heading(2)).expect("to h2");
4879        assert_eq!(ed.source_str().unwrap(), "## Title\n\nbody text\n");
4880
4881        // H2 -> H1 (offset now inside "## Title").
4882        ed.set_block(3, BlockKind::Heading(1)).expect("to h1");
4883        assert_eq!(ed.source_str().unwrap(), "# Title\n\nbody text\n");
4884
4885        // Heading -> paragraph, dropping the marker.
4886        ed.set_block(2, BlockKind::Paragraph).expect("to para");
4887        assert_eq!(ed.source_str().unwrap(), "Title\n\nbody text\n");
4888    }
4889
4890    #[test]
4891    fn editor_set_block_rejects_bad_level_and_format() {
4892        let mut md = Editor::new_str("hi\n", Format::Markdown).expect("editor");
4893        assert_eq!(
4894            md.set_block(0, BlockKind::Heading(9)),
4895            Err(Error::InvalidArgument)
4896        );
4897
4898        let mut xml = Editor::new_str("<a>hi</a>", Format::Xml).expect("editor");
4899        assert_eq!(
4900            xml.set_block(1, BlockKind::Heading(1)),
4901            Err(Error::UnsupportedFormat)
4902        );
4903    }
4904
4905    #[test]
4906    fn editor_toggle_block_container_round_trips() {
4907        let mut ed = Editor::new_str("a\n", Format::Djot).expect("editor");
4908
4909        let c = ed
4910            .toggle_block_container(0, 1, BlockContainerKind::BlockQuote)
4911            .expect("quote on");
4912        assert_eq!(ed.source_str().unwrap(), "> a\n");
4913        assert_eq!(&ed.source_str().unwrap()[c.new.clone()], "> a\n");
4914
4915        ed.toggle_block_container(2, 3, BlockContainerKind::BlockQuote)
4916            .expect("quote off");
4917        assert_eq!(ed.source_str().unwrap(), "a\n");
4918    }
4919
4920    #[test]
4921    fn editor_toggle_block_container_nests_a_partial_selection() {
4922        let mut ed = Editor::new_str("> a\n>\n> b\n", Format::Djot).expect("editor");
4923
4924        // Only the first paragraph is covered, so the quote is not fully
4925        // selected: nest rather than drag `b` out of the quote too.
4926        ed.toggle_block_container(2, 3, BlockContainerKind::BlockQuote)
4927            .expect("nest");
4928        assert_eq!(ed.source_str().unwrap(), "> > a\n>\n> b\n");
4929
4930        // Peel the inner level back off, leaving the outer quote intact.
4931        ed.toggle_block_container(4, 5, BlockContainerKind::BlockQuote)
4932            .expect("peel");
4933        assert_eq!(ed.source_str().unwrap(), "> a\n>\n> b\n");
4934    }
4935
4936    #[test]
4937    fn editor_toggle_block_container_numbers_and_converts_lists() {
4938        let mut ed = Editor::new_str("a\n\nb\n", Format::Djot).expect("editor");
4939
4940        // Each covered block becomes its own numbered item.
4941        ed.toggle_block_container(0, 4, BlockContainerKind::OrderedList)
4942            .expect("ordered on");
4943        assert_eq!(ed.source_str().unwrap(), "1. a\n\n2. b\n");
4944
4945        // The other list kind converts in place instead of nesting.
4946        ed.toggle_block_container(3, 9, BlockContainerKind::BulletList)
4947            .expect("convert");
4948        assert_eq!(ed.source_str().unwrap(), "- a\n\n- b\n");
4949    }
4950
4951    #[test]
4952    fn editor_toggle_block_container_rejects_unspellable_format() {
4953        let mut xml = Editor::new_str("<a>hi</a>", Format::Xml).expect("editor");
4954        assert_eq!(
4955            xml.toggle_block_container(3, 5, BlockContainerKind::BlockQuote),
4956            Err(Error::UnsupportedFormat)
4957        );
4958    }
4959
4960    #[test]
4961    fn editor_insert_link_wraps_and_repoints() {
4962        let mut ed = Editor::new_str("a word b\n", Format::Djot).expect("editor");
4963
4964        ed.insert_link(2, 6, "http://x.dev").expect("link");
4965        assert_eq!(ed.source_str().unwrap(), "a [word](http://x.dev) b\n");
4966
4967        // A caret inside the existing link re-points it rather than nesting.
4968        ed.insert_link(3, 7, "http://y.dev").expect("re-point");
4969        assert_eq!(ed.source_str().unwrap(), "a [word](http://y.dev) b\n");
4970    }
4971
4972    #[test]
4973    fn editor_insert_link_repoints_an_autolink() {
4974        // The regression: an autolink is a `url`/`email` node whose text IS its
4975        // destination. Read as ordinary text, a caret inside it spliced a whole
4976        // new link into the middle of the old URL —
4977        // `see <https<https://y.dev>://x.dev> ok`.
4978        for format in [Format::Markdown, Format::Djot] {
4979            let mut ed = Editor::new_str("see <https://x.dev> ok\n", format).expect("editor");
4980            ed.insert_link(10, 10, "https://y.dev").expect("re-point");
4981            assert_eq!(ed.source_str().unwrap(), "see <https://y.dev> ok\n");
4982
4983            // Source that looks right can still parse wrong: assert the reparse.
4984            let nodes = ed.nodes().expect("nodes");
4985            let url = nodes
4986                .iter()
4987                .find(|n| n.kind == Kind::Url)
4988                .expect("still an autolink");
4989            assert_eq!(url.text.as_deref(), Some("https://y.dev"));
4990            assert!(!nodes.iter().any(|n| n.kind == Kind::Link));
4991        }
4992    }
4993
4994    #[test]
4995    fn editor_insert_link_escapes_the_destination() {
4996        // Unescaped, the `)` would close the link early and spill `b` into the
4997        // paragraph as literal text.
4998        let mut dj = Editor::new_str("w\n", Format::Djot).expect("editor");
4999        dj.insert_link(0, 1, "a)b").expect("link");
5000        assert_eq!(dj.source_str().unwrap(), "[w](a\\)b)\n");
5001
5002        // Whitespace is where the formats part ways: Markdown needs the angle
5003        // form (a bare space ends the destination and kills the link outright),
5004        // Djot must NOT use it (it would link to the literal text `<a b>`).
5005        let mut md = Editor::new_str("w\n", Format::Markdown).expect("editor");
5006        md.insert_link(0, 1, "a b").expect("link");
5007        assert_eq!(md.source_str().unwrap(), "[w](<a b>)\n");
5008
5009        let mut dj2 = Editor::new_str("w\n", Format::Djot).expect("editor");
5010        dj2.insert_link(0, 1, "a b").expect("link");
5011        assert_eq!(dj2.source_str().unwrap(), "[w](a b)\n");
5012    }
5013
5014    #[test]
5015    fn editor_insert_image_escapes_the_destination_per_format() {
5016        // The whole point of the op: a caller's `![](my cat.png)` is not an image
5017        // in Markdown, and the correct repair differs by format.
5018        let mut md = Editor::new_str("w\n", Format::Markdown).expect("editor");
5019        md.insert_image(0, 1, "my cat.png").expect("image");
5020        assert_eq!(md.source_str().unwrap(), "![w](<my cat.png>)\n");
5021
5022        let mut dj = Editor::new_str("w\n", Format::Djot).expect("editor");
5023        dj.insert_image(0, 1, "my cat.png").expect("image");
5024        assert_eq!(dj.source_str().unwrap(), "![w](my cat.png)\n");
5025
5026        // A `)` would close the image early and spill the rest as literal text.
5027        let mut paren = Editor::new_str("w\n", Format::Djot).expect("editor");
5028        paren.insert_image(0, 1, "a)b.png").expect("image");
5029        assert_eq!(paren.source_str().unwrap(), "![w](a\\)b.png)\n");
5030    }
5031
5032    #[test]
5033    fn editor_insert_image_keeps_an_empty_alt_empty() {
5034        // Unlike a link, where an empty range spells an autolink or doubles the
5035        // destination as text — an image with no alt is ordinary.
5036        let mut ed = Editor::new_str("ab\n", Format::Markdown).expect("editor");
5037        ed.insert_image(1, 1, "cat.png").expect("image");
5038        assert_eq!(ed.source_str().unwrap(), "a![](cat.png)b\n");
5039    }
5040
5041    #[test]
5042    fn editor_insert_image_rejects_a_newline_destination() {
5043        let mut ed = Editor::new_str("w\n", Format::Djot).expect("editor");
5044        assert_eq!(
5045            ed.insert_image(0, 1, "a\nb.png"),
5046            Err(Error::InvalidArgument)
5047        );
5048
5049        let mut xml = Editor::new_str("<a>hi</a>", Format::Xml).expect("editor");
5050        assert_eq!(
5051            xml.insert_image(3, 5, "x.png"),
5052            Err(Error::UnsupportedFormat)
5053        );
5054    }
5055
5056    #[test]
5057    fn editor_insert_link_rejects_a_newline_destination() {
5058        let mut ed = Editor::new_str("w\n", Format::Djot).expect("editor");
5059        assert_eq!(ed.insert_link(0, 1, "a\nb"), Err(Error::InvalidArgument));
5060
5061        let mut xml = Editor::new_str("<a>hi</a>", Format::Xml).expect("editor");
5062        assert_eq!(xml.insert_link(3, 5, "u"), Err(Error::UnsupportedFormat));
5063    }
5064
5065    #[test]
5066    fn editor_insert_literal_keeps_typed_specials_literal() {
5067        for format in [Format::Markdown, Format::Djot] {
5068            let mut ed = Editor::new_str("z\n", format).expect("editor");
5069            // A `*` at a line start would open emphasis unescaped.
5070            ed.insert_literal(0, "*hi*").expect("literal");
5071
5072            // Source that looks right can still parse wrong: assert the reparse.
5073            let nodes = ed.nodes().expect("nodes");
5074            assert!(
5075                !nodes
5076                    .iter()
5077                    .any(|n| n.kind == Kind::Emph || n.kind == Kind::Strong)
5078            );
5079            let text: String = nodes
5080                .iter()
5081                .filter(|n| n.kind == Kind::Str)
5082                .filter_map(|n| n.text.clone())
5083                .collect();
5084            assert_eq!(text, "*hi*z");
5085        }
5086    }
5087
5088    #[test]
5089    fn editor_insert_literal_escapes_block_markers_only_at_line_start() {
5090        // Mid-line, a `#` opens nothing and is left as typed.
5091        let mut ed = Editor::new_str("az\n", Format::Markdown).expect("editor");
5092        ed.insert_literal(1, "# ").expect("literal");
5093        assert_eq!(ed.source_str().unwrap(), "a# z\n");
5094
5095        // At a line start it would open a heading, so it is escaped.
5096        let mut ed2 = Editor::new_str("z\n", Format::Markdown).expect("editor");
5097        ed2.insert_literal(0, "# ").expect("literal");
5098        assert_eq!(ed2.source_str().unwrap(), "\\# z\n");
5099        assert!(
5100            !ed2.nodes()
5101                .expect("nodes")
5102                .iter()
5103                .any(|n| n.kind == Kind::Heading)
5104        );
5105    }
5106
5107    #[test]
5108    fn editor_insert_literal_rejects_bad_offset_and_parse_only_format() {
5109        let mut ed = Editor::new_str("ab\n", Format::Markdown).expect("editor");
5110        assert_eq!(ed.insert_literal(99, "x"), Err(Error::InvalidArgument));
5111
5112        let mut xml = Editor::new_str("<a>hi</a>", Format::Xml).expect("editor");
5113        assert_eq!(xml.insert_literal(3, "x"), Err(Error::UnsupportedFormat));
5114    }
5115
5116    #[test]
5117    fn editor_insert_line_break_splices_in_cell_br() {
5118        let mut ed =
5119            Editor::new_str("| a | b |\n| --- | --- |\n", Format::Markdown).expect("editor");
5120        // Caret just after `a` in the header cell.
5121        ed.insert_line_break(3).expect("line break");
5122        assert_eq!(ed.source_str().unwrap(), "| a<br> | b |\n| --- | --- |\n");
5123        // The break reads back as a semantic node, not raw HTML.
5124        let nodes = ed.nodes().expect("nodes");
5125        assert!(nodes.iter().any(|n| n.kind == Kind::HardBreak));
5126        assert!(!nodes.iter().any(|n| n.kind == Kind::RawInline));
5127    }
5128
5129    #[test]
5130    fn editor_insert_line_break_rejects_off_cell_off_format_and_bad_offset() {
5131        // Not inside a cell → NotFound.
5132        let mut para = Editor::new_str("just text\n", Format::Markdown).expect("editor");
5133        assert_eq!(para.insert_line_break(3), Err(Error::NotFound));
5134
5135        // Djot has no in-cell break spelling → UnsupportedFormat.
5136        let mut dj = Editor::new_str("| a | b |\n| --- | --- |\n", Format::Djot).expect("editor");
5137        assert_eq!(dj.insert_line_break(3), Err(Error::UnsupportedFormat));
5138
5139        // Out-of-range offset → InvalidArgument.
5140        let mut ed =
5141            Editor::new_str("| a | b |\n| --- | --- |\n", Format::Markdown).expect("editor");
5142        assert_eq!(ed.insert_line_break(9999), Err(Error::InvalidArgument));
5143    }
5144
5145    #[test]
5146    fn editor_insert_thematic_break_is_blank_separated_per_format() {
5147        // The blank line above is load-bearing, not cosmetic: flush against the
5148        // paragraph, Markdown's `---` is a setext underline and the paragraph
5149        // becomes an <h2>. So assert the reparsed KIND, not just the bytes.
5150        let mut md = Editor::new_str("a\n", Format::Markdown).expect("editor");
5151        md.insert_thematic_break(0).expect("rule");
5152        assert_eq!(md.source_str().unwrap(), "a\n\n---\n");
5153        let nodes = md.nodes().expect("nodes");
5154        assert!(nodes.iter().any(|n| n.kind == Kind::ThematicBreak));
5155        assert!(!nodes.iter().any(|n| n.kind == Kind::Heading));
5156
5157        // Djot spells the same construct differently — the reason the spelling
5158        // is the library's and not the caller's.
5159        let mut dj = Editor::new_str("a\n", Format::Djot).expect("editor");
5160        dj.insert_thematic_break(0).expect("rule");
5161        assert_eq!(dj.source_str().unwrap(), "a\n\n* * *\n");
5162
5163        let mut xml = Editor::new_str("<a>hi</a>", Format::Xml).expect("editor");
5164        assert_eq!(xml.insert_thematic_break(3), Err(Error::UnsupportedFormat));
5165    }
5166
5167    #[test]
5168    fn editor_split_block_keeps_both_halves_the_same_kind() {
5169        // A list item's halves are both items — the marker is repeated, so the
5170        // second half doesn't fall out of the list as a paragraph.
5171        let mut item = Editor::new_str("- this is a list item\n", Format::Markdown).expect("editor");
5172        item.split_block(10).expect("split");
5173        assert_eq!(item.source_str().unwrap(), "- this is \n- a list item\n");
5174        let nodes = item.nodes().expect("nodes");
5175        assert_eq!(nodes.iter().filter(|n| n.kind == Kind::ListItem).count(), 2);
5176
5177        // At the item's end the empty sibling IS the point — that is Enter.
5178        let mut tail = Editor::new_str("- a\n", Format::Markdown).expect("editor");
5179        tail.split_block(3).expect("split");
5180        assert_eq!(tail.source_str().unwrap(), "- a\n- \n");
5181
5182        // A paragraph divides on a blank line instead.
5183        let mut para = Editor::new_str("ab\n", Format::Markdown).expect("editor");
5184        para.split_block(1).expect("split");
5185        assert_eq!(para.source_str().unwrap(), "a\n\nb\n");
5186
5187        // A table has no honest caret-split: a newline mid-cell destroys it.
5188        let mut table =
5189            Editor::new_str("| a | b |\n|---|---|\n| c | d |\n", Format::Markdown).expect("editor");
5190        assert_eq!(table.split_block(3), Err(Error::NotEditable));
5191
5192        let mut empty = Editor::new_str("", Format::Markdown).expect("editor");
5193        assert_eq!(empty.split_block(0), Err(Error::NotFound));
5194    }
5195
5196    #[test]
5197    fn editor_toggle_code_block_round_trips_and_measures_the_fence() {
5198        let mut ed = Editor::new_str("a\n", Format::Markdown).expect("editor");
5199        ed.toggle_code_block(0, 1, Some("zig")).expect("fence");
5200        assert_eq!(ed.source_str().unwrap(), "```zig\na\n```\n");
5201        let nodes = ed.nodes().expect("nodes");
5202        assert!(nodes.iter().any(|n| n.kind == Kind::CodeBlock));
5203
5204        ed.toggle_code_block(0, 0, None).expect("unfence");
5205        assert_eq!(ed.source_str().unwrap(), "a\n");
5206
5207        // Three backticks in the body would close a three-backtick fence, so the
5208        // fence is measured against the body rather than fixed.
5209        let mut runs = Editor::new_str("a ``` b\n", Format::Markdown).expect("editor");
5210        runs.toggle_code_block(0, 7, None).expect("fence");
5211        assert_eq!(runs.source_str().unwrap(), "````\na ``` b\n````\n");
5212    }
5213
5214    #[test]
5215    fn editor_toggle_code_block_refuses_inside_a_list_item() {
5216        // A fence at column zero here would pull the item's `- ` into the code
5217        // body and the item would stop being an item.
5218        let mut ed = Editor::new_str("- a\n- b\n", Format::Markdown).expect("editor");
5219        assert_eq!(ed.toggle_code_block(2, 3, None), Err(Error::NotEditable));
5220        assert_eq!(ed.source_str().unwrap(), "- a\n- b\n");
5221    }
5222
5223    #[test]
5224    fn editor_set_code_language_retags_clears_and_refuses() {
5225        let mut ed = Editor::new_str("```zig\na\n```\n", Format::Markdown).expect("editor");
5226        ed.set_code_language(0, Some("rust")).expect("retag");
5227        assert_eq!(ed.source_str().unwrap(), "```rust\na\n```\n");
5228
5229        // `None` clears the info string; `Some("")` writes the same bytes but is
5230        // a different request.
5231        ed.set_code_language(0, None).expect("clear");
5232        assert_eq!(ed.source_str().unwrap(), "```\na\n```\n");
5233        ed.set_code_language(0, Some("")).expect("empty");
5234        assert_eq!(ed.source_str().unwrap(), "```\na\n```\n");
5235
5236        // Markdown's info string ends at whitespace, so a space would come back
5237        // truncated — refused rather than silently clipped.
5238        assert_eq!(
5239            ed.set_code_language(0, Some("a b")),
5240            Err(Error::InvalidArgument)
5241        );
5242        // Djot's runs to the end of the line, so the same string is fine there.
5243        let mut dj = Editor::new_str("```\na\n```\n", Format::Djot).expect("editor");
5244        dj.set_code_language(0, Some("a b"))
5245            .expect("djot info string");
5246        assert_eq!(dj.source_str().unwrap(), "```a b\na\n```\n");
5247
5248        let mut para = Editor::new_str("x\n", Format::Markdown).expect("editor");
5249        assert_eq!(para.set_code_language(0, Some("zig")), Err(Error::NotFound));
5250    }
5251
5252    #[test]
5253    fn editor_task_checkbox_gestures() {
5254        let mut ed = Editor::new_str("- a\n", Format::Markdown).expect("editor");
5255
5256        // The box is added by one gesture and ticked by another — adding
5257        // converts the item's kind, ticking only changes what the box holds.
5258        ed.toggle_task_item(2).expect("add box");
5259        assert_eq!(ed.source_str().unwrap(), "- [ ] a\n");
5260        assert!(
5261            ed.nodes()
5262                .unwrap()
5263                .iter()
5264                .any(|n| n.kind == Kind::TaskListItem)
5265        );
5266
5267        ed.set_task_checked(6, true).expect("tick");
5268        assert_eq!(ed.source_str().unwrap(), "- [x] a\n");
5269        // Already checked: a no-op that still succeeds and moves nothing.
5270        ed.set_task_checked(6, true).expect("no-op");
5271        assert_eq!(ed.source_str().unwrap(), "- [x] a\n");
5272
5273        ed.toggle_task_checked(6).expect("flip");
5274        assert_eq!(ed.source_str().unwrap(), "- [ ] a\n");
5275
5276        ed.toggle_task_item(6).expect("remove box");
5277        assert_eq!(ed.source_str().unwrap(), "- a\n");
5278
5279        // A plain bullet has no box to tick; `toggle_task_item` is how a caller
5280        // asks for one.
5281        assert_eq!(ed.set_task_checked(2, true), Err(Error::NotEditable));
5282        // And a caret in no list item has no item at all.
5283        let mut para = Editor::new_str("a\n", Format::Markdown).expect("editor");
5284        assert_eq!(para.toggle_task_item(0), Err(Error::NotFound));
5285    }
5286
5287    #[test]
5288    fn editor_insert_footnote_writes_both_halves_as_one_edit() {
5289        for format in [Format::Markdown, Format::Djot] {
5290            let mut ed = Editor::new_str("see\n", format).expect("editor");
5291            ed.insert_footnote(3, "a").expect("footnote");
5292            assert_eq!(ed.source_str().unwrap(), "see[^a]\n\n[^a]: \n");
5293
5294            // Half a footnote is not a footnote, so assert both nodes exist.
5295            let nodes = ed.nodes().expect("nodes");
5296            assert!(nodes.iter().any(|n| n.kind == Kind::FootnoteReference));
5297            assert!(nodes.iter().any(|n| n.kind == Kind::Footnote));
5298
5299            // One edit, so one undo takes both halves back.
5300            ed.undo().expect("undo");
5301            assert_eq!(ed.source_str().unwrap(), "see\n");
5302        }
5303    }
5304
5305    #[test]
5306    fn editor_insert_footnote_reuses_an_existing_definition() {
5307        let mut ed = Editor::new_str("see\n", Format::Markdown).expect("editor");
5308        ed.insert_footnote(3, "a").expect("first");
5309        ed.insert_footnote(7, "a").expect("second reference");
5310        assert_eq!(ed.source_str().unwrap(), "see[^a][^a]\n\n[^a]: \n");
5311        let defs = ed
5312            .nodes()
5313            .unwrap()
5314            .iter()
5315            .filter(|n| n.kind == Kind::Footnote)
5316            .count();
5317        assert_eq!(defs, 1);
5318
5319        assert_eq!(ed.insert_footnote(3, ""), Err(Error::InvalidArgument));
5320        assert_eq!(ed.insert_footnote(3, "a]b"), Err(Error::InvalidArgument));
5321    }
5322
5323    #[test]
5324    fn editor_undo_redo_round_trip() {
5325        let mut ed = Editor::new_str("hello\n", Format::Markdown).expect("editor");
5326        ed.edit_range(5, 5, "!").expect("edit");
5327        assert_eq!(ed.source_str().unwrap(), "hello!\n");
5328
5329        let change = ed.undo().expect("undo ok").expect("something to undo");
5330        assert_eq!(ed.source_str().unwrap(), "hello\n");
5331        assert_eq!(change.new.end, 5);
5332        assert!(ed.undo().expect("undo ok").is_none(), "history exhausted");
5333
5334        ed.redo().expect("redo ok").expect("something to redo");
5335        assert_eq!(ed.source_str().unwrap(), "hello!\n");
5336    }
5337
5338    #[test]
5339    fn editor_coalesce_folds_a_run() {
5340        let mut ed = Editor::new_str("\n", Format::Markdown).expect("editor");
5341        ed.edit_range(0, 0, "a").expect("edit");
5342        ed.edit_range(1, 1, "b").expect("edit");
5343        ed.coalesce_last_undo().expect("coalesce");
5344        assert_eq!(ed.source_str().unwrap(), "ab\n");
5345        // One undo removes the whole coalesced run.
5346        ed.undo().expect("undo ok").expect("something to undo");
5347        assert_eq!(ed.source_str().unwrap(), "\n");
5348        assert!(ed.undo().expect("undo ok").is_none());
5349    }
5350
5351    #[test]
5352    fn editor_revision_bumps_per_successful_mutation() {
5353        let mut ed = Editor::new_str("x\n", Format::Markdown).expect("editor");
5354        assert_eq!(ed.revision(), 0);
5355        ed.edit_range(1, 1, "y").expect("edit");
5356        assert_eq!(ed.revision(), 1);
5357
5358        // A reparse-breaking edit is rolled back and must not bump the revision.
5359        let mut xml = Editor::new_str("<a>ok</a>", Format::Xml).expect("editor");
5360        assert_eq!(xml.revision(), 0);
5361        assert!(xml.replace_content("0", "<b>").is_err());
5362        assert_eq!(xml.revision(), 0);
5363
5364        // undo and redo are mutations too.
5365        ed.undo().expect("undo ok").expect("something to undo");
5366        assert_eq!(ed.revision(), 2);
5367        ed.redo().expect("redo ok").expect("something to redo");
5368        assert_eq!(ed.revision(), 3);
5369    }
5370
5371    #[test]
5372    fn editor_dirty_range_tracks_and_clears() {
5373        let mut ed = Editor::new_str("abcdefgh\n", Format::Markdown).expect("editor");
5374        // Clean to start.
5375        assert_eq!(ed.dirty_range(), None);
5376
5377        // One insertion of two bytes at offset 2 dirties exactly [2, 4).
5378        ed.edit_range(2, 2, "XY").expect("edit");
5379        assert_eq!(ed.dirty_range(), Some(2..4));
5380
5381        // A second, disjoint edit near the end accumulates conservatively: the
5382        // reported range is a superset covering both edits.
5383        ed.edit_range(9, 9, "Z").expect("edit"); // source is now "abXYcdefgZh\n"
5384        let d = ed.dirty_range().expect("dirty");
5385        assert!(
5386            d.start <= 2 && d.end >= 10,
5387            "range {d:?} must cover both edits"
5388        );
5389
5390        // clear_dirty acknowledges without moving the revision.
5391        let rev = ed.revision();
5392        ed.clear_dirty();
5393        assert_eq!(ed.dirty_range(), None);
5394        assert_eq!(ed.revision(), rev);
5395
5396        // Post-clear, only new mutations show up — and undo counts as one.
5397        ed.undo().expect("undo ok").expect("something to undo");
5398        assert!(ed.dirty_range().is_some());
5399    }
5400
5401    #[test]
5402    fn editor_caret_blob_follows_undo_and_redo() {
5403        let mut ed = Editor::new_str("hello\n", Format::Markdown).expect("editor");
5404        assert!(ed.caret_blob().unwrap().is_empty());
5405
5406        // Set the pre-edit caret, then edit: the retired undo step captures it.
5407        ed.set_caret_blob(b"before").expect("set caret");
5408        ed.edit_range(5, 5, "!").expect("edit");
5409        // A fresh state starts caret-less until the host sets one.
5410        assert!(ed.caret_blob().unwrap().is_empty());
5411        ed.set_caret_blob(b"after").expect("set caret");
5412
5413        // Undo restores the pre-edit source AND the pre-edit caret.
5414        ed.undo().expect("undo ok").expect("something to undo");
5415        assert_eq!(ed.source_str().unwrap(), "hello\n");
5416        assert_eq!(ed.caret_blob().unwrap(), b"before");
5417
5418        // Redo restores the post-edit source AND the post-edit caret.
5419        ed.redo().expect("redo ok").expect("something to redo");
5420        assert_eq!(ed.source_str().unwrap(), "hello!\n");
5421        assert_eq!(ed.caret_blob().unwrap(), b"after");
5422    }
5423
5424    #[test]
5425    fn editor_coalesced_run_keeps_the_pre_run_caret() {
5426        let mut ed = Editor::new_str("\n", Format::Markdown).expect("editor");
5427        ed.set_caret_blob(b"c0").expect("set caret");
5428        ed.edit_range(0, 0, "a").expect("edit");
5429        ed.set_caret_blob(b"c1").expect("set caret");
5430        ed.edit_range(1, 1, "b").expect("edit");
5431        ed.coalesce_last_undo().expect("coalesce");
5432        ed.set_caret_blob(b"c2").expect("set caret");
5433
5434        // One undo folds the run and restores the caret from before it began.
5435        ed.undo().expect("undo ok").expect("something to undo");
5436        assert_eq!(ed.source_str().unwrap(), "\n");
5437        assert_eq!(ed.caret_blob().unwrap(), b"c0");
5438    }
5439
5440    #[test]
5441    fn editor_renumber_ordered_lists_fixes_a_stale_sequence() {
5442        let mut ed = Editor::new_str("1. a\n2. x\n2. b\n3. c\n", Format::Markdown).expect("editor");
5443        ed.renumber_ordered_lists(0).expect("renumber ok");
5444        assert_eq!(ed.source_str().unwrap(), "1. a\n2. x\n3. b\n4. c\n");
5445    }
5446
5447    #[test]
5448    fn editor_renumber_ordered_lists_leaves_djot_prose_alone() {
5449        // Djot reads `   2. b` as text inside item `a`; Markdown reads the same
5450        // bytes as a nested item. The author's digit survives in the one case.
5451        let src = "1. a\n   2. b\n2. c\n";
5452        let mut dj = Editor::new_str(src, Format::Djot).expect("editor");
5453        dj.renumber_ordered_lists(0).expect("renumber ok");
5454        assert_eq!(dj.source_str().unwrap(), src);
5455
5456        let mut md = Editor::new_str(src, Format::Markdown).expect("editor");
5457        md.renumber_ordered_lists(0).expect("renumber ok");
5458        assert_eq!(md.source_str().unwrap(), "1. a\n   1. b\n2. c\n");
5459    }
5460
5461    #[test]
5462    fn editor_renumber_ordered_lists_off_a_list_is_not_found() {
5463        let mut ed = Editor::new_str("a paragraph\n", Format::Markdown).expect("editor");
5464        assert!(matches!(ed.renumber_ordered_lists(2), Err(Error::NotFound)));
5465    }
5466
5467    #[test]
5468    fn editor_table_insert_row_and_set_alignment() {
5469        let src = "| a | b |\n| --- | --- |\n| 1 | 2 |\n";
5470        let mut ed = Editor::new_str(src, Format::Markdown).expect("editor");
5471        ed.table_insert_row(24, true).expect("insert row"); // caret in body `1`
5472        assert_eq!(
5473            ed.source_str().unwrap(),
5474            "| a | b |\n| --- | --- |\n| 1 | 2 |\n|  |  |\n"
5475        );
5476        ed.table_set_alignment(6, Alignment::Center).expect("align"); // column `b`
5477        assert!(ed.source_str().unwrap().contains("| --- | :---: |"));
5478    }
5479
5480    #[test]
5481    fn editor_table_edit_off_a_table_is_not_found() {
5482        let mut ed = Editor::new_str("nope\n", Format::Markdown).expect("editor");
5483        assert!(matches!(ed.table_delete_row(2), Err(Error::NotFound)));
5484    }
5485
5486    #[test]
5487    fn editor_set_block_converts_setext_heading() {
5488        // A setext heading rebuilt from its content_span collapses the underline.
5489        let mut ed = Editor::new_str("Title\n=====\n\nbody\n", Format::Markdown).expect("editor");
5490        ed.set_block(0, BlockKind::Heading(1))
5491            .expect("setext to atx");
5492        assert_eq!(ed.source_str().unwrap(), "# Title\n\nbody\n");
5493    }
5494
5495    #[test]
5496    fn editor_unwrap_and_smart_delete() {
5497        let mut ed = Editor::new_str("<r><box><b/><c/></box></r>", Format::Xml).expect("editor");
5498        ed.unwrap_node("0.0").expect("unwrap"); // <box>
5499        assert_eq!(ed.source_str().expect("source"), "<r><b/><c/></r>");
5500
5501        let mut md = Editor::new_str("A\n\nB\n\nC\n", Format::Markdown).expect("editor");
5502        md.delete_smart("1").expect("delete_smart"); // the "B" paragraph
5503        assert_eq!(md.source_str().expect("source"), "A\n\nC\n");
5504    }
5505
5506    #[test]
5507    fn editor_directives_require_the_extension_flag() {
5508        let src = ":::vis{.public}\nhi\n:::\n";
5509        // Without the flag, the colon-fence lines are plain paragraph text —
5510        // no directive node.
5511        let mut plain = Editor::new_str(src, Format::Markdown).expect("editor");
5512        assert_eq!(plain.query("directive").expect("query").len(), 0);
5513        // With it enabled, the container directive is recognized.
5514        let mut ext = Editor::new_ext(
5515            src.as_bytes(),
5516            Format::Markdown,
5517            MarkdownExtensions {
5518                directives: true,
5519                ..Default::default()
5520            },
5521        )
5522        .expect("editor");
5523        assert_eq!(ext.query("directive").expect("query").len(), 1);
5524    }
5525
5526    #[test]
5527    fn document_html_elements_make_embedded_img_queryable() {
5528        let src = "text <img src=\"a.png\" alt=\"x\"> more\n";
5529        // Without the flag, the `<img>` is opaque raw HTML — no `image` node.
5530        let mut plain = Document::parse_str(src, Format::Markdown).expect("parse");
5531        assert_eq!(plain.query("image").expect("query").len(), 0);
5532        // With it enabled on the read path, the promoted image is queryable.
5533        let mut ext = Document::parse_str_with(
5534            src,
5535            Format::Markdown,
5536            MarkdownExtensions {
5537                html_elements: true,
5538                ..Default::default()
5539            },
5540        )
5541        .expect("parse");
5542        let images = ext.query("image").expect("query");
5543        assert_eq!(images.len(), 1);
5544        assert_eq!(images[0].kind, Kind::Image);
5545    }
5546
5547    #[test]
5548    fn editor_filter_public_audience_view() {
5549        let src = "# Archive\n\n:::vis{.public}\nPublic.\n:::\n\n:::vis{.family}\nPrivate.\n:::\n";
5550        let mut ed = Editor::new_ext(
5551            src.as_bytes(),
5552            Format::Markdown,
5553            MarkdownExtensions {
5554                directives: true,
5555                ..Default::default()
5556            },
5557        )
5558        .expect("editor");
5559        // Drop every vis block except the public one, then unwrap it.
5560        ed.filter(
5561            "directive[name=vis]",
5562            Some("directive[class~=public]"),
5563            true,
5564        )
5565        .expect("filter");
5566        assert_eq!(ed.source_str().expect("source"), "# Archive\n\nPublic.\n");
5567    }
5568
5569    #[test]
5570    fn editor_filter_rejects_a_malformed_selector() {
5571        let mut ed = Editor::new_str("hi\n", Format::Markdown).expect("editor");
5572        assert_eq!(
5573            ed.filter("list >", None, false),
5574            Err(Error::InvalidArgument)
5575        );
5576    }
5577
5578    #[test]
5579    fn builder_builds_and_renders_a_document() {
5580        let mut b = Builder::new().expect("builder");
5581
5582        // # Title\n\nhello *world*
5583        let title = b.add_text(TextKind::Str, "Title").unwrap();
5584        let heading = b.add_heading(1).unwrap();
5585        b.set_children(heading, &[title]).unwrap();
5586
5587        let hello = b.add_text(TextKind::Str, "hello ").unwrap();
5588        let world = b.add_text(TextKind::Str, "world").unwrap();
5589        let emph = b.add(VoidKind::Emph).unwrap();
5590        b.set_children(emph, &[world]).unwrap();
5591        let para = b.add(VoidKind::Para).unwrap();
5592        b.set_children(para, &[hello, emph]).unwrap();
5593
5594        let doc = b.add(VoidKind::Doc).unwrap();
5595        b.set_children(doc, &[heading, para]).unwrap();
5596
5597        let html = String::from_utf8(b.render_html(doc).unwrap()).unwrap();
5598        assert!(html.contains("<h1>Title</h1>"), "{html}");
5599        assert!(html.contains("<em>world</em>"), "{html}");
5600
5601        let md = String::from_utf8(b.serialize(doc, Format::Markdown).unwrap()).unwrap();
5602        assert!(md.contains("# Title"), "{md}");
5603        assert!(md.contains("*world*"), "{md}");
5604
5605        let matches = b.query(doc, "heading").unwrap();
5606        assert_eq!(matches.len(), 1);
5607        assert_eq!(matches[0].kind, Kind::Heading);
5608
5609        let json = String::from_utf8(b.ast_json(doc).unwrap()).unwrap();
5610        assert!(json.contains("\"kind\": \"doc\""), "{json}");
5611    }
5612
5613    #[test]
5614    fn builder_element_with_attributes() {
5615        let mut b = Builder::new().expect("builder");
5616        let inner = b.add_text(TextKind::Str, "hi").unwrap();
5617        let el = b.add_element("section").unwrap();
5618        b.set_children(el, &[inner]).unwrap();
5619        b.set_attrs(el, &[("class", Some("note")), ("hidden", None)])
5620            .unwrap();
5621
5622        let html = String::from_utf8(b.render_html(el).unwrap()).unwrap();
5623        assert!(html.contains("<section"), "{html}");
5624        assert!(html.contains("class=\"note\""), "{html}");
5625        assert!(html.contains("hidden"), "{html}");
5626    }
5627
5628    #[test]
5629    fn builder_lists_round_trip_to_markdown() {
5630        let mut b = Builder::new().expect("builder");
5631
5632        // An ordered list: 1. one / 2. two
5633        let one_txt = b.add_text(TextKind::Str, "one").unwrap();
5634        let one_para = b.add(VoidKind::Para).unwrap();
5635        b.set_children(one_para, &[one_txt]).unwrap();
5636        let one = b.add(VoidKind::ListItem).unwrap();
5637        b.set_children(one, &[one_para]).unwrap();
5638
5639        let two_txt = b.add_text(TextKind::Str, "two").unwrap();
5640        let two_para = b.add(VoidKind::Para).unwrap();
5641        b.set_children(two_para, &[two_txt]).unwrap();
5642        let two = b.add(VoidKind::ListItem).unwrap();
5643        b.set_children(two, &[two_para]).unwrap();
5644
5645        let list = b
5646            .add_ordered_list(
5647                OrderedNumbering::Decimal,
5648                OrderedDelim::Period,
5649                true,
5650                Some(1),
5651            )
5652            .unwrap();
5653        b.set_children(list, &[one, two]).unwrap();
5654        let doc = b.add(VoidKind::Doc).unwrap();
5655        b.set_children(doc, &[list]).unwrap();
5656
5657        let md = String::from_utf8(b.serialize(doc, Format::Markdown).unwrap()).unwrap();
5658        assert!(md.contains("1. one"), "{md}");
5659        assert!(md.contains("2. two"), "{md}");
5660    }
5661
5662    #[test]
5663    fn builder_rejects_invalid_kind_and_id() {
5664        let b = Builder::new().expect("builder");
5665        // `heading` (code 2) carries a payload, so the void-kind `add` rejects it
5666        // — the safe `VoidKind` enum has no such variant, so we go through the raw
5667        // ABI to prove the guard.
5668        let mut id = 0u32;
5669        let status = unsafe { ffi::twig_builder_add(b.raw.as_ptr(), 2, &mut id) };
5670        assert_eq!(Error::from_status(status), Err(Error::InvalidArgument));
5671
5672        // A root id past the end can't be rendered.
5673        let mut ptr = std::ptr::null();
5674        let mut len = 0usize;
5675        let status =
5676            unsafe { ffi::twig_builder_render_html(b.raw.as_ptr(), 4242, &mut ptr, &mut len) };
5677        assert_eq!(Error::from_status(status), Err(Error::InvalidArgument));
5678    }
5679
5680    // ── Format capability ───────────────────────────────────────────────────
5681
5682    /// Every gesture with a format-level gate, both kind vocabularies in full.
5683    fn all_gestures() -> Vec<Gesture> {
5684        let inline = [
5685            InlineKind::Strong,
5686            InlineKind::Emph,
5687            InlineKind::Verbatim,
5688            InlineKind::Mark,
5689            InlineKind::Superscript,
5690            InlineKind::Subscript,
5691            InlineKind::Insert,
5692            InlineKind::Delete,
5693        ];
5694        let mut all: Vec<Gesture> = Vec::new();
5695        for k in inline {
5696            all.push(Gesture::WrapRange(k));
5697            all.push(Gesture::ToggleInline(k));
5698        }
5699        for k in [
5700            BlockContainerKind::BlockQuote,
5701            BlockContainerKind::BulletList,
5702            BlockContainerKind::OrderedList,
5703        ] {
5704            all.push(Gesture::ToggleBlockContainer(k));
5705        }
5706        all.extend([
5707            Gesture::SetMarkColor,
5708            Gesture::SetBlock,
5709            Gesture::InsertThematicBreak,
5710            Gesture::ToggleCodeBlock,
5711            Gesture::SetCodeLanguage,
5712            Gesture::ToggleTaskItem,
5713            Gesture::SetTaskChecked,
5714            Gesture::ToggleTaskChecked,
5715            Gesture::InsertLink,
5716            Gesture::InsertImage,
5717            Gesture::InsertFootnote,
5718            Gesture::InsertLiteral,
5719            Gesture::InsertLineBreak,
5720            Gesture::SplitBlock,
5721            Gesture::RenumberOrderedLists,
5722            Gesture::TableInsertRow,
5723            Gesture::TableDeleteRow,
5724            Gesture::TableInsertColumn,
5725            Gesture::TableDeleteColumn,
5726            Gesture::TableSetAlignment,
5727            Gesture::TableMoveRow,
5728            Gesture::TableMoveColumn,
5729        ]);
5730        all
5731    }
5732
5733    #[test]
5734    fn the_wire_space_ends_where_the_sweep_does() {
5735        // `all_gestures` is hand-written and, unlike the Zig union it mirrors,
5736        // has no compile-time cross-check: a variant added to the enum and to
5737        // `to_c` can silently miss the sweep below. So pin the space from both
5738        // ends — the sweep must cover a contiguous range of codes, every one of
5739        // them must decode C-side, and one past the end must not.
5740        let mut codes: Vec<c_int> = all_gestures().iter().map(|g| g.to_c().0).collect();
5741        codes.sort_unstable();
5742        codes.dedup();
5743        assert_eq!(codes, (0..=24).collect::<Vec<c_int>>());
5744
5745        let mut supported = -1;
5746        for code in &codes {
5747            let status = unsafe {
5748                ffi::twig_format_supports(
5749                    ffi::TwigFormat::from(Format::Markdown) as c_int,
5750                    *code,
5751                    0,
5752                    &mut supported,
5753                )
5754            };
5755            assert_eq!(Error::from_status(status), Ok(()), "code {code} did not decode");
5756        }
5757        // One past the end is not a gesture, which is what makes appending safe.
5758        let status = unsafe {
5759            ffi::twig_format_supports(
5760                ffi::TwigFormat::from(Format::Markdown) as c_int,
5761                25,
5762                0,
5763                &mut supported,
5764            )
5765        };
5766        assert_eq!(Error::from_status(status), Err(Error::InvalidArgument));
5767    }
5768
5769    #[test]
5770    fn supports_answers_per_gesture_where_authorable_cannot() {
5771        // HTML is why the per-gesture query exists. `is_authorable` is true for
5772        // it — it spells the inline marks — while a toolbar built on that
5773        // predicate would show a heading button, a quote button and a
5774        // code-block button that all fail.
5775        assert!(Format::Html.is_authorable());
5776        assert!(Format::Html.supports(Gesture::ToggleInline(InlineKind::Strong)));
5777        assert!(!Format::Html.supports(Gesture::SetBlock));
5778        assert!(!Format::Html.supports(Gesture::ToggleBlockContainer(
5779            BlockContainerKind::BlockQuote
5780        )));
5781        assert!(!Format::Html.supports(Gesture::ToggleCodeBlock));
5782        assert!(!Format::Html.supports(Gesture::InsertLiteral));
5783        // The nine that used to answer nothing at all: HTML has a table its
5784        // parser reads and no spelling to write one back with, no blank-line
5785        // block separation, and no numbered list marker.
5786        assert!(!Format::Html.supports(Gesture::TableInsertRow));
5787        assert!(!Format::Html.supports(Gesture::TableSetAlignment));
5788        assert!(!Format::Html.supports(Gesture::SplitBlock));
5789        assert!(!Format::Html.supports(Gesture::RenumberOrderedLists));
5790        assert!(Format::Markdown.supports(Gesture::TableInsertRow));
5791        assert!(Format::Djot.supports(Gesture::SplitBlock));
5792
5793        // A format that spells nothing answers false everywhere, so the coarse
5794        // predicate agrees there — it only misleads in the middle of the range.
5795        for fmt in [Format::Xml] {
5796            assert!(!fmt.is_authorable());
5797            for g in all_gestures() {
5798                assert!(!fmt.supports(g), "{fmt:?} claims to spell {g:?}");
5799            }
5800        }
5801        // AsciiDoc is in the middle of the range the other way round from
5802        // HTML: the block gestures work, the link/footnote/table shapes don't.
5803        assert!(Format::Asciidoc.is_authorable());
5804        assert!(Format::Asciidoc.supports(Gesture::SetBlock));
5805        assert!(Format::Asciidoc.supports(Gesture::ToggleInline(InlineKind::Mark)));
5806        assert!(!Format::Asciidoc.supports(Gesture::InsertLink));
5807        assert!(!Format::Asciidoc.supports(Gesture::TableInsertRow));
5808
5809        // And the two authorable formats differ from each other, which is the
5810        // other half of why one boolean can't serve.
5811        assert!(Format::Djot.supports(Gesture::ToggleInline(InlineKind::Mark)));
5812        assert!(!Format::Markdown.supports(Gesture::ToggleInline(InlineKind::Mark)));
5813        assert!(Format::Markdown.supports(Gesture::InsertLineBreak));
5814        assert!(!Format::Djot.supports(Gesture::InsertLineBreak));
5815    }
5816
5817    #[test]
5818    fn supports_agrees_with_what_the_editor_then_does() {
5819        // The pin at this layer: for the gestures whose refusal an `Editor`
5820        // can be made to demonstrate, the query's answer is the call's answer.
5821        // Zig covers the full (format x gesture) sweep; what's checked here is
5822        // that the Rust decode reaches the same question.
5823        for fmt in [Format::Djot, Format::Markdown, Format::Html] {
5824            let mut ed = Editor::new_str("ab\n", fmt).expect("editor");
5825            let claimed = fmt.supports(Gesture::ToggleInline(InlineKind::Mark));
5826            let observed = ed.toggle_inline(0, 2, InlineKind::Mark);
5827            assert_eq!(
5828                claimed,
5829                !matches!(observed, Err(Error::UnsupportedFormat)),
5830                "{fmt:?}: supports said {claimed}, gesture said {observed:?}",
5831            );
5832
5833            let mut ed = Editor::new_str("ab\n", fmt).expect("editor");
5834            let claimed = fmt.supports(Gesture::SetBlock);
5835            let observed = ed.set_block(0, BlockKind::Heading(1));
5836            assert_eq!(
5837                claimed,
5838                !matches!(observed, Err(Error::UnsupportedFormat)),
5839                "{fmt:?}: supports said {claimed}, gesture said {observed:?}",
5840            );
5841        }
5842
5843        // The destructive one, spelled out: an HTML `<table>` extracts as a grid
5844        // and cannot be written back, so the refusal has to arrive before
5845        // anything is spliced. A `Ok(())` here once meant a destroyed table.
5846        let src = "<table><tr><td>a</td></tr></table>";
5847        let mut ed = Editor::new_str(src, Format::Html).expect("editor");
5848        assert!(!Format::Html.supports(Gesture::TableInsertRow));
5849        assert_eq!(ed.table_insert_row(15, true), Err(Error::UnsupportedFormat));
5850        assert_eq!(ed.renumber_ordered_lists(15), Err(Error::UnsupportedFormat));
5851        assert!(matches!(ed.split_block(15), Err(Error::UnsupportedFormat)));
5852        assert_eq!(ed.source().expect("source"), src.as_bytes());
5853    }
5854
5855    #[test]
5856    fn supports_rides_the_gestures_own_kind_space() {
5857        // The same integer means different things per gesture on the wire (1 is
5858        // `emph` inline and `bullet_list` container). The Rust types make that
5859        // unrepresentable, which is why `supports` returns a bare bool — but
5860        // the raw call underneath still has to be handed the right pair.
5861        let (g, k) = Gesture::ToggleBlockContainer(BlockContainerKind::BulletList).to_c();
5862        assert_eq!((g, k), (3, 1));
5863        let (g, k) = Gesture::ToggleInline(InlineKind::Emph).to_c();
5864        assert_eq!((g, k), (1, 1));
5865        // A kindless gesture sends 0, which the C side requires rather than
5866        // ignores.
5867        assert_eq!(Gesture::InsertLink.to_c(), (10, 0));
5868
5869        // And the C side does reject the combinations Rust can't build.
5870        let mut out: c_int = 0;
5871        let status = unsafe {
5872            ffi::twig_format_supports(ffi::TwigFormat::Markdown as c_int, 10, 3, &mut out)
5873        };
5874        assert_eq!(Error::from_status(status), Err(Error::InvalidArgument));
5875        let status = unsafe {
5876            ffi::twig_format_supports(ffi::TwigFormat::Markdown as c_int, 9999, 0, &mut out)
5877        };
5878        assert_eq!(Error::from_status(status), Err(Error::InvalidArgument));
5879    }
5880}