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