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