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