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