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