Skip to main content

twig/
lib.rs

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