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