Skip to main content

twig/
lib.rs

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