Skip to main content

twig/
lib.rs

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