Skip to main content

twig/
lib.rs

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