Skip to main content

twig/
lib.rs

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