leaf_core/source.rs
1//! Syntax highlighting for the source view — the AST read as *markup* rather
2//! than as rendered text.
3//!
4//! [`crate::View::Wysiwyg`] resolves the document's markup away and styles what
5//! is left; [`crate::View::Source`] shows the markup itself, and until now
6//! showed it unstyled. This module is the missing half: a [`SourceMap`] of
7//! styled byte ranges over `Doc::source`, so a frontend painting raw source can
8//! tell a heading from its `# `, a link from its destination, and a fence from
9//! the code inside it.
10//!
11//! # Why this and not a syntax-highlighting library
12//!
13//! leaf already has a parse of these exact bytes — twig's, the one the caret
14//! rides. A second parser (syntect, tree-sitter) is a second opinion about what
15//! the document is, and the two disagreeing is visible: text painted as emphasis
16//! that the editor then refuses to treat as emphasis. Reading the styling off
17//! the same AST the editing model uses makes that class of bug unrepresentable.
18//!
19//! It also costs nothing per format. twig normalizes Markdown, Djot, HTML and
20//! XML into one [`Kind`] vocabulary, so `<b>bold</b>`, `**bold**` and `*bold*`
21//! all arrive as [`Kind::Strong`] and are styled by the same line of code.
22//!
23//! # The rule
24//!
25//! Every node knows its whole extent ([`FlatNode::span`]) and, where it has
26//! delimiters, the extent of what is *inside* them
27//! ([`FlatNode::content_span`]). The difference between the two is exactly the
28//! markup:
29//!
30//! ```text
31//! [link](https://example.dev)
32//! ^^^^^^^^^^^^^^^^^^^^^^^^^^^ span
33//! ^^^^ content_span
34//! ^ ^^^^^^^^^^^^^^^^^^^^^^ the gaps — the markup
35//! ```
36//!
37//! So the whole highlighter is: style a node's span by its kind, then restyle
38//! the bytes its content doesn't cover as [`Role::Delimiter`]. Children paint
39//! over their parents, inheriting the parent's style the same way
40//! [`crate::wysiwyg`] threads a `base` down the tree — which is what keeps
41//! `*em*` inside a heading both heading-colored and italic.
42//!
43//! # The one thing read off a second parser
44//!
45//! **The inner language of a fenced code block.** ` ```rust ` gets
46//! [`Role::Code`] over the whole body from twig, which knows the fence and the
47//! info string but not Rust. The Rust is [`crate::syntax`]'s — the same
48//! grammars and the same eight-way [`crate::style::Token`] the rendered view colours a block
49//! with — laid over the body a token per byte range, so ⌘E leaves a fenced
50//! block's keywords where they were. Without the `syntax` feature the body
51//! stays plain [`Role::Code`], as it does in the rendered view.
52//!
53//! # What it does not do
54//!
55//! **Bytes no node covers.** A link-reference definition and a footnote
56//! definition hang off no parent (see `Editor::definitions`), and twig leaves
57//! some inter-element whitespace unparented; the walk starts at the root, so
58//! those stay [`Role::Body`]. Unstyled is the correct failure here — the text is
59//! still the text.
60
61use std::ops::Range;
62
63use twig::{FlatNode, Kind};
64
65use crate::style::{Baseline, MarkColor, Role, Style};
66
67/// A run of source bytes that share one style. Ranges are source byte offsets,
68/// like the caret and [`crate::Highlight`], so nothing has to be converted to
69/// paint one.
70#[derive(Clone, Debug, PartialEq, Eq)]
71pub struct StyledRun {
72 /// The bytes this run covers, `[start, end)`.
73 pub span: Range<usize>,
74 /// What to paint them as.
75 pub style: Style,
76}
77
78/// The source view's styling, as non-overlapping runs in ascending order.
79///
80/// Gaps between runs are [`Role::Body`] — the map stores only what differs from
81/// plain text, so an ordinary prose document is a handful of runs rather than
82/// one per byte.
83///
84/// Built by [`build`] and cached on the [`Doc`](crate::Doc) against its
85/// revision; a frontend reads it through [`SourceMap::style_at`] for a one-shot
86/// question, or [`SourceMap::edges_in`] when it is already walking lines in
87/// order and wants to know where the styling changes.
88#[derive(Clone, Debug, Default, PartialEq, Eq)]
89pub struct SourceMap {
90 /// Ascending, non-overlapping, and never [`Role::Body`] — see the type docs.
91 runs: Vec<StyledRun>,
92}
93
94impl SourceMap {
95 /// The styled runs, ascending and non-overlapping. Bytes between them are
96 /// [`Style::default`].
97 pub fn runs(&self) -> &[StyledRun] {
98 &self.runs
99 }
100
101 /// Whether the map styles nothing — a document with no markup in it, or one
102 /// that has not been built yet.
103 pub fn is_empty(&self) -> bool {
104 self.runs.is_empty()
105 }
106
107 /// The style covering source byte `offset`, or [`Style::default`] where no
108 /// run does.
109 ///
110 /// A binary search, for a caller asking about one offset. A painter walking
111 /// the document in order should use [`edges_in`](Self::edges_in) instead and
112 /// ask once per *run* rather than once per byte.
113 pub fn style_at(&self, offset: usize) -> Style {
114 match self.runs.binary_search_by(|r| {
115 if r.span.end <= offset {
116 std::cmp::Ordering::Less
117 } else if offset < r.span.start {
118 std::cmp::Ordering::Greater
119 } else {
120 std::cmp::Ordering::Equal
121 }
122 }) {
123 Ok(i) => self.runs[i].style,
124 Err(_) => Style::default(),
125 }
126 }
127
128 /// Append every styling boundary strictly inside `range` to `out`, in
129 /// ascending order — the offsets where a painter has to break a span
130 /// because the style changes there.
131 ///
132 /// Both edges of every overlapping run, since a run that starts inside the
133 /// range and one that ends inside it are equally a place the color changes.
134 /// The range's own ends are left to the caller, which already has them.
135 pub fn edges_in(&self, range: Range<usize>, out: &mut Vec<usize>) {
136 // The first run that reaches into the range. Runs are ascending and
137 // disjoint, so from here it is a walk until one starts past the end.
138 let from = self.runs.partition_point(|r| r.span.end <= range.start);
139 // Two runs that abut share one boundary; it is one place the style
140 // changes, so it is reported once. `last` rather than a dedup pass
141 // because the edges arrive in ascending order already.
142 let mut last = None;
143 for run in &self.runs[from..] {
144 if run.span.start >= range.end {
145 break;
146 }
147 for edge in [run.span.start, run.span.end] {
148 if edge > range.start && edge < range.end && last != Some(edge) {
149 out.push(edge);
150 last = Some(edge);
151 }
152 }
153 }
154 }
155}
156
157/// Style the source of a parsed document.
158///
159/// `nodes` is the whole arena as [`twig::Editor::nodes`] returns it, over the
160/// `source` it was parsed from — the spans do the work, and the text is read
161/// only to tell a delimiter from the whitespace around it (see
162/// [`fill_markup`]).
163///
164/// The walk starts at the [`Kind::Doc`] root and goes depth-first, so a node is
165/// always painted before the children that overwrite parts of it. It uses an
166/// explicit stack rather than recursion: nesting depth is the *document's*, and
167/// a thousand nested block quotes should slow a repaint down, not end it.
168pub fn build(nodes: &[FlatNode], source: &str) -> SourceMap {
169 let Some(root) = nodes.iter().position(|n| n.kind == Kind::Doc) else {
170 return SourceMap::default();
171 };
172 let len = source.len();
173 if len == 0 {
174 return SourceMap::default();
175 }
176
177 // One style per byte, collapsed to runs at the end. The document is walked
178 // once and each byte written once per level of nesting over it, which for
179 // real markup is a small constant — and it makes "the child wins" fall out
180 // of the write order instead of needing an interval tree to arbitrate.
181 let mut paint = vec![Style::default(); len];
182 let mut stack = vec![(root, Style::default())];
183 while let Some((id, base)) = stack.pop() {
184 let node = &nodes[id];
185 let style = style_of(node, base);
186
187 // The node's own extent first, then the bytes its content leaves out —
188 // those are its delimiters, and they are scaffolding whatever the node
189 // itself is. `Role::Delimiter` sits on top of the run's own emphasis,
190 // exactly as `wysiwyg::Builder::push_delim` lays it on a revealed line,
191 // so the `**` around a bold phrase comes out dim *and* bold.
192 //
193 // Markup goes down through `fill_markup`, which declines a stretch with
194 // no markup actually in it — a `soft_break` that is one bare newline, a
195 // block whose span runs a line further than its content. Both are gaps
196 // in the arithmetic sense and neither has anything to dim.
197 if style.role == Role::Delimiter {
198 fill_markup(&mut paint, source, &node.span, style);
199 } else {
200 fill(&mut paint, &node.span, style);
201 }
202 if let Some(content) = &node.content_span {
203 let delim = style.role(Role::Delimiter);
204 fill_markup(&mut paint, source, &(node.span.start..content.start), delim);
205 fill_markup(&mut paint, source, &(content.end..node.span.end), delim);
206 }
207 if node.kind == Kind::CodeBlock {
208 fill_tokens(&mut paint, source, node, style);
209 }
210
211 let mut child = node.first_child;
212 while let Some(cid) = child {
213 let i = cid.0 as usize;
214 let Some(n) = nodes.get(i) else { break };
215 stack.push((i, style));
216 child = n.next_sibling;
217 }
218 }
219
220 SourceMap {
221 runs: to_runs(paint),
222 }
223}
224
225/// Paint `span` with `style`, clipped to the buffer. A span reaching past the
226/// source can only come from an arena and a string that have drifted apart; the
227/// clip means that renders wrong rather than panicking in a paint loop.
228fn fill(paint: &mut [Style], span: &Range<usize>, style: Style) {
229 let start = span.start.min(paint.len());
230 let end = span.end.min(paint.len());
231 if start < end {
232 paint[start..end].fill(style);
233 }
234}
235
236/// [`fill`] for a stretch of *markup*, which declines one that holds none.
237///
238/// Almost every block's span runs to the end of the line its content ends on, so
239/// the arithmetic leaves a trailing `"\n"` outside `content_span` — and a plain
240/// `soft_break` is a bare newline that this module dims for the sake of the
241/// `"> "` a block quote sometimes hangs on it. Painting either changes nothing a
242/// reader can see: whitespace has no glyph to dim.
243///
244/// It is not free, though. It splits the run that covers it, so a document of
245/// ordinary prose comes back as one styled run per line instead of none — which
246/// is a map every painter then walks, and a `SourceMap::is_empty` that is never
247/// true. Declining is what keeps "no markup" costing nothing.
248fn fill_markup(paint: &mut [Style], source: &str, span: &Range<usize>, style: Style) {
249 let blank = source
250 .get(span.start.min(source.len())..span.end.min(source.len()))
251 .is_none_or(|s| s.trim().is_empty());
252 if !blank {
253 fill(paint, span, style);
254 }
255}
256
257/// Lay a fenced block's syntax highlighting over its body: the
258/// [`crate::style::Token`] of
259/// each range [`crate::syntax::highlight`] reports, on top of the [`Role::Code`]
260/// `style` the body already wears — exactly what `wysiwyg::push_code_text`
261/// does to the same block's glyphs, so a keyword is the same colour in both
262/// views.
263///
264/// The grammar is fed the block's *text* (`FlatNode::text`, the lines with
265/// their container prefixes and indentation stripped), not the raw source
266/// lines: a block inside a quote spells every line `> `, and the Rust grammar
267/// would read that as a shift. Each highlighted line is then placed back at
268/// the end of the source line it came from — `content_span` runs 1:1 with the
269/// text's lines, and anchoring at the end lands past whatever prefix was
270/// stripped without knowing how wide it was, as `code_line_offsets` does for
271/// the rendered view. A body whose lines don't line up that way keeps its
272/// plain code colour; so does a fence naming no grammar, and any block at all
273/// without the `syntax` feature.
274///
275/// Every block is re-highlighted on every build, which is once per revision:
276/// the rendered view keeps a block's rows across edits elsewhere and this map
277/// has no such cache, so a keystroke in a document that is mostly code costs
278/// the grammar over all of it. It is the same door `Doc::build_source` leaves
279/// open for the walk itself, and nothing has needed it yet.
280#[cfg(feature = "syntax")]
281fn fill_tokens(paint: &mut [Style], source: &str, node: &FlatNode, style: Style) {
282 let Some(content) = &node.content_span else {
283 return;
284 };
285 let Some(lang) = crate::wysiwyg::code_language(source, node.span.start) else {
286 return;
287 };
288 let text = node.text.as_deref().unwrap_or_default();
289 // The block's terminator and no more — a last line left empty is a second
290 // `\n`, and its own line, as the rendered view also counts it.
291 let lines: Vec<&str> = text
292 .strip_suffix('\n')
293 .unwrap_or(text)
294 .split('\n')
295 .collect();
296 let Some(body) = source.get(content.start..content.end) else {
297 return;
298 };
299 let mut at = content.start;
300 let src_lines: Vec<(usize, &str)> = body
301 .split('\n')
302 .map(|l| {
303 let start = at;
304 at += l.len() + 1;
305 (start, l)
306 })
307 .collect();
308 if src_lines.len() != lines.len() {
309 return;
310 }
311 let Some(tokens) = crate::syntax::highlight(&lang, &lines) else {
312 return;
313 };
314 for ((line, (start, src_line)), spans) in lines.iter().zip(&src_lines).zip(&tokens) {
315 let at = start + src_line.len().saturating_sub(line.len());
316 for (range, token) in spans {
317 fill(
318 paint,
319 &(at + range.start..at + range.end),
320 style.token(Some(*token)),
321 );
322 }
323 }
324}
325
326#[cfg(not(feature = "syntax"))]
327fn fill_tokens(_paint: &mut [Style], _source: &str, _node: &FlatNode, _style: Style) {}
328
329/// Collapse the per-byte buffer into ascending runs, dropping the [`Role::Body`]
330/// stretches — those are the default the map's gaps already mean.
331fn to_runs(paint: Vec<Style>) -> Vec<StyledRun> {
332 let mut runs: Vec<StyledRun> = Vec::new();
333 let mut start = 0usize;
334 for i in 1..=paint.len() {
335 if i < paint.len() && paint[i] == paint[start] {
336 continue;
337 }
338 if paint[start] != Style::default() {
339 runs.push(StyledRun {
340 span: start..i,
341 style: paint[start],
342 });
343 }
344 start = i;
345 }
346 runs
347}
348
349/// A node's style, layered on the style it inherits from its parent.
350///
351/// Deliberately the same decisions [`crate::wysiwyg`] makes for the rendered
352/// view — `emph` is italic in both, `verbatim` is [`Role::Code`] in both — so
353/// toggling ⌘E between the two views recolors the markup without recoloring the
354/// prose.
355///
356/// [`Kind`] is `#[non_exhaustive]`; an unmapped kind inherits its parent's
357/// style, which is why a node twig grows later shows up as ordinary text rather
358/// than as a compile error.
359fn style_of(node: &FlatNode, base: Style) -> Style {
360 match node.kind {
361 // A heading's level picks the style, as it does in the rendered view.
362 // `level` is `None` on a malformed heading; treat it as the top one.
363 Kind::Heading => base.role(Role::Heading(node.level.unwrap_or(1).clamp(1, 255) as u8)),
364
365 // The inline marks, matched to `wysiwyg`'s arms one for one.
366 Kind::Emph => base.italic(),
367 Kind::Strong => base.bold(),
368 Kind::Mark => base.role(Role::Mark(MarkColor::from_attrs(&node.attrs))),
369 Kind::Insert => base.underline(),
370 Kind::Delete => base.strikethrough(),
371 Kind::Superscript => base.baseline(Baseline::Super),
372 Kind::Subscript => base.baseline(Baseline::Sub),
373
374 // Code, and the things that read like it. `raw_block`/`raw_inline` are
375 // markup twig passed through untouched (an HTML tag in a Markdown
376 // document) — verbatim source inside a document, which is what
377 // `Role::Code` means.
378 Kind::CodeBlock
379 | Kind::Verbatim
380 | Kind::InlineMath
381 | Kind::DisplayMath
382 | Kind::RawBlock
383 | Kind::RawInline => base.role(Role::Code),
384
385 // Anything that points somewhere. A reference and a citation resolve to
386 // a definition elsewhere in the document, which is a link by another
387 // name — `wysiwyg` styles them `Role::Link` for the same reason.
388 Kind::Link
389 | Kind::Url
390 | Kind::Email
391 | Kind::Reference
392 | Kind::Citation
393 | Kind::FootnoteReference
394 | Kind::CitationReference
395 | Kind::SubstitutionReference => base.role(Role::Link),
396
397 Kind::ThematicBreak => base.role(Role::Rule),
398
399 // Scaffolding with no rendered form of its own: an XML declaration, a
400 // doctype, a comment, a CDATA wrapper. Dimmed whole rather than by its
401 // delimiters, because all of it is machinery.
402 Kind::Comment | Kind::Doctype | Kind::ProcessingInstruction | Kind::Cdata => {
403 base.role(Role::Delimiter)
404 }
405
406 // A soft break carries the *continuation* markers with it — the `> ` a
407 // block quote repeats on its second line, the indent under a list item
408 // — so dimming it dims those, which no node's delimiter gap reaches. A
409 // plain soft break is one invisible newline and is dimmed for nothing.
410 Kind::SoftBreak => base.role(Role::Delimiter),
411
412 _ => base,
413 }
414}
415
416#[cfg(test)]
417mod tests {
418 use super::*;
419 use twig::{Editor, Format};
420
421 /// Build a map the way `Doc` does, and hand back the source alongside it so
422 /// assertions can name bytes by the text they cover rather than by offset.
423 fn map(src: &str, format: Format) -> SourceMap {
424 let mut ed = Editor::new_str(src, format).unwrap();
425 let nodes = ed.nodes().unwrap();
426 build(&nodes, src)
427 }
428
429 fn md(src: &str) -> SourceMap {
430 map(src, Format::Markdown)
431 }
432
433 /// Every byte of `src` whose style satisfies `pred`, as a string — the
434 /// readable form of "what came out dim?".
435 fn where_style(m: &SourceMap, src: &str, pred: impl Fn(Style) -> bool) -> String {
436 (0..src.len())
437 .filter(|&i| src.is_char_boundary(i) && pred(m.style_at(i)))
438 .filter_map(|i| src[i..].chars().next())
439 .collect()
440 }
441
442 #[test]
443 fn a_headings_hash_is_markup_and_its_text_is_a_heading() {
444 let src = "# Title\n";
445 let m = md(src);
446 assert_eq!(
447 where_style(&m, src, |s| s.role == Role::Delimiter),
448 "# ",
449 "the `# ` opens the heading and is not part of it"
450 );
451 assert_eq!(
452 where_style(&m, src, |s| s.role == Role::Heading(1)),
453 "Title",
454 "the text is the heading"
455 );
456 }
457
458 #[test]
459 fn a_links_destination_is_markup_and_its_label_is_a_link() {
460 let src = "see [here](https://example.dev) now\n";
461 let m = md(src);
462 assert_eq!(where_style(&m, src, |s| s.role == Role::Link), "here");
463 assert_eq!(
464 where_style(&m, src, |s| s.role == Role::Delimiter),
465 "[](https://example.dev)",
466 "the brackets and the destination are the link's markup"
467 );
468 }
469
470 #[test]
471 fn emphasis_inside_a_heading_is_both() {
472 let src = "## a *b* c\n";
473 let m = md(src);
474 let b = src.find('b').unwrap();
475 let style = m.style_at(b);
476 assert_eq!(style.role, Role::Heading(2), "still heading text");
477 assert!(style.italic, "and italic");
478 }
479
480 #[test]
481 fn a_coloured_highlights_emoji_is_markup_and_its_words_are_the_mark() {
482 // The source view's answer to the same question the rendered one gets:
483 // `==🔴 ` is the delimiter, `red` is the mark, and the mark knows
484 // which colour it was written in. Parsed with `parse_extensions` — the
485 // flags leaf actually opens documents with — because `==…==` is a
486 // Markdown *extension*, and a map built without them would show the
487 // literal text this test would then be asserting nothing about.
488 let src = "a ==🔴 red== b\n";
489 let mut ed = twig::Editor::new_ext(
490 src.as_bytes(),
491 Format::Markdown,
492 crate::doc::parse_extensions(),
493 )
494 .unwrap();
495 let m = build(&ed.nodes().unwrap(), src);
496 assert_eq!(
497 where_style(&m, src, |s| s.role == Role::Mark(Some(MarkColor::Red))),
498 "red",
499 "the words carry the mark and its colour"
500 );
501 assert_eq!(
502 where_style(&m, src, |s| s.role == Role::Delimiter),
503 "==🔴 ==",
504 "the fences and the emoji between them are its markup"
505 );
506 }
507
508 /// The delimiter role sits *on top of* the run's own emphasis rather than
509 /// replacing it, so a frontend can dim the `**` and still draw it bold —
510 /// the same composition `wysiwyg::Builder::push_delim` does.
511 #[test]
512 fn a_marks_delimiters_keep_the_emphasis_they_delimit() {
513 let src = "a **b** c\n";
514 let m = md(src);
515 let star = src.find('*').unwrap();
516 assert_eq!(m.style_at(star).role, Role::Delimiter);
517 assert!(m.style_at(star).bold, "the `**` belongs to the bold run");
518 assert!(m.style_at(src.find('b').unwrap()).bold);
519 assert_eq!(m.style_at(src.find('b').unwrap()).role, Role::Body);
520 }
521
522 #[test]
523 fn a_fence_is_markup_and_the_body_is_code() {
524 let src = "```rust\nfn main() {}\n```\n";
525 let m = md(src);
526 assert_eq!(
527 m.style_at(src.find("fn").unwrap()).role,
528 Role::Code,
529 "the body of the block is code"
530 );
531 assert_eq!(
532 m.style_at(0).role,
533 Role::Delimiter,
534 "the opening fence is markup"
535 );
536 assert_eq!(
537 m.style_at(src.rfind("```").unwrap()).role,
538 Role::Delimiter,
539 "and so is the closing one"
540 );
541 }
542
543 /// The body of a fence in a known language carries the grammar's tokens,
544 /// beside the code role rather than instead of it — the same pairing the
545 /// rendered view's glyphs make, so a keyword reads the same colour in both.
546 #[cfg(feature = "syntax")]
547 #[test]
548 fn a_fence_in_a_known_language_carries_tokens() {
549 use crate::style::Token;
550 let src = "```rust\nlet x = \"s\"; // c\n```\n";
551 let m = md(src);
552 let at = |needle: &str| src.find(needle).unwrap();
553 assert_eq!(m.style_at(at("let")).role, Role::Code, "still code");
554 assert_eq!(m.style_at(at("let")).token, Some(Token::Keyword));
555 assert_eq!(m.style_at(at("\"s\"")).token, Some(Token::String));
556 assert_eq!(m.style_at(at("// c")).token, Some(Token::Comment));
557 assert_eq!(
558 m.style_at(0).token,
559 None,
560 "the fence itself is markup, not a token"
561 );
562 assert_eq!(m.style_at(0).role, Role::Delimiter);
563 }
564
565 /// The grammar sees the block's *text*, not the source lines: a fence
566 /// indented two spaces has two stripped from every body line, and the
567 /// highlighting has to land past them. The indent carries no token, the
568 /// `let` after it is a keyword.
569 ///
570 /// (A fence inside a quote or a list item strips its container's prefix the
571 /// same way, but has no language in either view yet — `code_language` reads
572 /// the fence at the block's start and finds the `> ` — so it is not the
573 /// case tested here.)
574 #[cfg(feature = "syntax")]
575 #[test]
576 fn an_indented_fence_is_highlighted_past_its_indent() {
577 use crate::style::Token;
578 let src = " ```rust\n let x = 1;\n ```\n";
579 let m = md(src);
580 let at = |needle: &str| src.find(needle).unwrap();
581 assert_eq!(m.style_at(at("let")).token, Some(Token::Keyword));
582 assert_eq!(m.style_at(at("1")).token, Some(Token::Constant));
583 assert_eq!(
584 m.style_at(at("let") - 1).token,
585 None,
586 "the indent carries none"
587 );
588 }
589
590 /// A fence in no known language, or with no language at all, is plain code
591 /// — as the rendered view draws it.
592 #[cfg(feature = "syntax")]
593 #[test]
594 fn a_fence_in_an_unknown_language_is_plain_code() {
595 for src in ["```nosuchlang\nlet x\n```\n", "```\nlet x\n```\n"] {
596 let m = md(src);
597 let at = src.find("let").unwrap();
598 assert_eq!(m.style_at(at).role, Role::Code, "{src:?}");
599 assert_eq!(m.style_at(at).token, None, "{src:?}");
600 }
601 }
602
603 #[test]
604 fn frontmatter_fences_are_markup() {
605 let src = "---\ntitle: x\n---\n\ntext\n";
606 let m = md(src);
607 assert_eq!(m.style_at(0).role, Role::Delimiter, "the opening `---`");
608 assert_eq!(
609 m.style_at(src.find("title").unwrap()).role,
610 Role::Body,
611 "the metadata itself is text"
612 );
613 }
614
615 /// A list marker and a block quote's gutter are authored bytes with no node
616 /// of their own; they fall in the leading gap of the paragraph inside, which
617 /// is exactly what the delimiter rule is for.
618 #[test]
619 fn list_markers_and_quote_gutters_are_markup() {
620 let src = "- one\n- [ ] two\n";
621 let m = md(src);
622 assert_eq!(m.style_at(0).role, Role::Delimiter, "the `- `");
623 assert_eq!(m.style_at(src.find("one").unwrap()).role, Role::Body);
624 let box_at = src.find("[ ]").unwrap();
625 assert_eq!(m.style_at(box_at).role, Role::Delimiter, "the task box");
626 }
627
628 /// The `> ` a quote repeats on its continuation lines is inside the
629 /// paragraph's content span, so no delimiter gap reaches it — the soft break
630 /// it rides does.
631 #[test]
632 fn a_quotes_continuation_marker_is_markup_too() {
633 let src = "> one\n> two\n";
634 let m = md(src);
635 assert_eq!(m.style_at(0).role, Role::Delimiter, "the opening `> `");
636 let second = src.rfind('>').unwrap();
637 assert_eq!(
638 m.style_at(second).role,
639 Role::Delimiter,
640 "and the one on the second line"
641 );
642 assert_eq!(m.style_at(src.find("two").unwrap()).role, Role::Body);
643 }
644
645 /// One vocabulary, three grammars: the same assertion holds however the
646 /// document spells its markup, which is the whole argument for reading this
647 /// off twig's AST instead of off a per-language grammar.
648 #[test]
649 fn every_format_styles_bold_the_same_way() {
650 for (format, src, word) in [
651 (Format::Markdown, "a **b** c\n", "b"),
652 (Format::Djot, "a *b* c\n", "b"),
653 (Format::Html, "<p>a <b>bee</b> c</p>\n", "bee"),
654 ] {
655 let m = map(src, format);
656 let at = src.find(word).unwrap();
657 assert!(
658 m.style_at(at).bold,
659 "{format:?} should style {word:?} bold in {src:?}"
660 );
661 assert_eq!(
662 m.style_at(at).role,
663 Role::Body,
664 "{format:?}: the bold text is prose, not markup"
665 );
666 }
667 }
668
669 #[test]
670 fn html_tags_are_markup_and_a_comment_is_dim_throughout() {
671 let src = "<h1>Title</h1>\n<!-- note -->\n";
672 let m = map(src, Format::Html);
673 assert_eq!(m.style_at(0).role, Role::Delimiter, "the `<h1>` tag");
674 assert_eq!(
675 m.style_at(src.find("Title").unwrap()).role,
676 Role::Heading(1),
677 "what the tag contains is a heading"
678 );
679 assert!(
680 where_style(&m, src, |s| s.role == Role::Delimiter).contains("note"),
681 "a comment is machinery all the way through"
682 );
683 }
684
685 #[test]
686 fn plain_prose_styles_nothing() {
687 let m = md("Just a sentence with no markup in it at all.\n");
688 assert!(m.is_empty(), "no runs, so a painter does no extra work");
689 }
690
691 #[test]
692 fn an_empty_document_is_an_empty_map() {
693 assert!(md("").is_empty());
694 }
695
696 /// The invariant every consumer relies on: ascending, disjoint, and never
697 /// the default style (which the gaps already mean).
698 #[test]
699 fn runs_are_ascending_disjoint_and_never_default() {
700 let src =
701 "---\na: b\n---\n\n# H *i*\n\n- [ ] t `c`\n\n> q\n> r\n\n```rs\nx\n```\n\n[l](d)\n";
702 let m = md(src);
703 assert!(!m.is_empty());
704 let mut prev = 0;
705 for run in m.runs() {
706 assert!(run.span.start < run.span.end, "no empty runs: {run:?}");
707 assert!(run.span.start >= prev, "ascending and disjoint: {run:?}");
708 assert_ne!(run.style, Style::default(), "no default runs: {run:?}");
709 assert!(run.span.end <= src.len(), "inside the source: {run:?}");
710 prev = run.span.end;
711 }
712 }
713
714 /// `style_at` and `runs()` are two views of one answer, so a scan through
715 /// either has to agree with the other at every byte.
716 #[test]
717 fn style_at_agrees_with_the_runs_it_reads() {
718 let src = "# H\n\ntext **b** and `c` and [l](d)\n";
719 let m = md(src);
720 for run in m.runs() {
721 for i in run.span.clone() {
722 assert_eq!(m.style_at(i), run.style, "byte {i}");
723 }
724 }
725 // And a byte in no run is plain.
726 let gap = src.find("text").unwrap();
727 assert_eq!(m.style_at(gap), Style::default());
728 }
729
730 #[test]
731 fn edges_in_reports_every_boundary_inside_the_line_and_none_outside() {
732 let src = "a **b** c\n";
733 let m = md(src);
734 let mut cuts = Vec::new();
735 m.edges_in(0..src.len(), &mut cuts);
736 // `**b**` spans 2..7: dim `**` at 2..4, bold `b` at 4..5, dim `**` 5..7.
737 assert_eq!(cuts, vec![2, 4, 5, 7]);
738
739 // A range that ends mid-run reports only what falls strictly inside it.
740 let mut cuts = Vec::new();
741 m.edges_in(0..5, &mut cuts);
742 assert_eq!(cuts, vec![2, 4]);
743 }
744
745 /// The source view paints line by line, so the map has to answer for a
746 /// window that starts and ends in the middle of runs.
747 #[test]
748 fn edges_in_answers_for_a_line_in_the_middle_of_a_document() {
749 let src = "# One\n\ntwo **three** four\n\n# Five\n";
750 let m = md(src);
751 let line_start = src.find("two").unwrap();
752 let line_end = src[line_start..].find('\n').unwrap() + line_start;
753 let mut cuts = Vec::new();
754 m.edges_in(line_start..line_end, &mut cuts);
755 assert!(
756 cuts.iter().all(|&c| c > line_start && c < line_end),
757 "every cut lands inside the line: {cuts:?}"
758 );
759 assert_eq!(cuts.len(), 4, "the two `**` pairs and the word between");
760 }
761
762 /// A document twig cannot make sense of still has to paint. The arena and
763 /// the string can only disagree through a bug, but a paint loop is the wrong
764 /// place to find out.
765 #[test]
766 fn a_span_past_the_end_of_the_source_is_clipped_not_panicked() {
767 let mut ed = Editor::new_str("# H\n", Format::Markdown).unwrap();
768 let nodes = ed.nodes().unwrap();
769 let m = build(&nodes, "# ");
770 for run in m.runs() {
771 assert!(run.span.end <= 2, "clipped to the length given: {run:?}");
772 }
773 }
774}