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