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