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