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