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