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//! # What it does not do
44//!
45//! **The inner language of a fenced code block.** ` ```rust ` gets
46//! [`Role::Code`] over the whole body; twig knows the fence and the info string,
47//! not Rust. Highlighting *that* is the one job an external highlighter is
48//! actually right for, and it belongs in the frontends that can afford the
49//! dependency — not in a core that also ships to wasm and iOS.
50//!
51//! **Bytes no node covers.** A link-reference definition and a footnote
52//! definition hang off no parent (see `Editor::definitions`), and twig leaves
53//! some inter-element whitespace unparented; the walk starts at the root, so
54//! those stay [`Role::Body`]. Unstyled is the correct failure here — the text is
55//! still the text.
56
57use std::ops::Range;
58
59use twig::{FlatNode, Kind};
60
61use crate::style::{Baseline, Role, Style};
62
63/// A run of source bytes that share one style. Ranges are source byte offsets,
64/// like the caret and [`crate::Highlight`], so nothing has to be converted to
65/// paint one.
66#[derive(Clone, Debug, PartialEq, Eq)]
67pub struct StyledRun {
68 /// The bytes this run covers, `[start, end)`.
69 pub span: Range<usize>,
70 /// What to paint them as.
71 pub style: Style,
72}
73
74/// The source view's styling, as non-overlapping runs in ascending order.
75///
76/// Gaps between runs are [`Role::Body`] — the map stores only what differs from
77/// plain text, so an ordinary prose document is a handful of runs rather than
78/// one per byte.
79///
80/// Built by [`build`] and cached on the [`Doc`](crate::Doc) against its
81/// revision; a frontend reads it through [`SourceMap::style_at`] for a one-shot
82/// question, or [`SourceMap::edges_in`] when it is already walking lines in
83/// order and wants to know where the styling changes.
84#[derive(Clone, Debug, Default, PartialEq, Eq)]
85pub struct SourceMap {
86 /// Ascending, non-overlapping, and never [`Role::Body`] — see the type docs.
87 runs: Vec<StyledRun>,
88}
89
90impl SourceMap {
91 /// The styled runs, ascending and non-overlapping. Bytes between them are
92 /// [`Style::default`].
93 pub fn runs(&self) -> &[StyledRun] {
94 &self.runs
95 }
96
97 /// Whether the map styles nothing — a document with no markup in it, or one
98 /// that has not been built yet.
99 pub fn is_empty(&self) -> bool {
100 self.runs.is_empty()
101 }
102
103 /// The style covering source byte `offset`, or [`Style::default`] where no
104 /// run does.
105 ///
106 /// A binary search, for a caller asking about one offset. A painter walking
107 /// the document in order should use [`edges_in`](Self::edges_in) instead and
108 /// ask once per *run* rather than once per byte.
109 pub fn style_at(&self, offset: usize) -> Style {
110 match self.runs.binary_search_by(|r| {
111 if r.span.end <= offset {
112 std::cmp::Ordering::Less
113 } else if offset < r.span.start {
114 std::cmp::Ordering::Greater
115 } else {
116 std::cmp::Ordering::Equal
117 }
118 }) {
119 Ok(i) => self.runs[i].style,
120 Err(_) => Style::default(),
121 }
122 }
123
124 /// Append every styling boundary strictly inside `range` to `out`, in
125 /// ascending order — the offsets where a painter has to break a span
126 /// because the style changes there.
127 ///
128 /// Both edges of every overlapping run, since a run that starts inside the
129 /// range and one that ends inside it are equally a place the color changes.
130 /// The range's own ends are left to the caller, which already has them.
131 pub fn edges_in(&self, range: Range<usize>, out: &mut Vec<usize>) {
132 // The first run that reaches into the range. Runs are ascending and
133 // disjoint, so from here it is a walk until one starts past the end.
134 let from = self.runs.partition_point(|r| r.span.end <= range.start);
135 // Two runs that abut share one boundary; it is one place the style
136 // changes, so it is reported once. `last` rather than a dedup pass
137 // because the edges arrive in ascending order already.
138 let mut last = None;
139 for run in &self.runs[from..] {
140 if run.span.start >= range.end {
141 break;
142 }
143 for edge in [run.span.start, run.span.end] {
144 if edge > range.start && edge < range.end && last != Some(edge) {
145 out.push(edge);
146 last = Some(edge);
147 }
148 }
149 }
150 }
151}
152
153/// Style the source of a parsed document.
154///
155/// `nodes` is the whole arena as [`twig::Editor::nodes`] returns it, over the
156/// `source` it was parsed from — the spans do the work, and the text is read
157/// only to tell a delimiter from the whitespace around it (see
158/// [`fill_markup`]).
159///
160/// The walk starts at the [`Kind::Doc`] root and goes depth-first, so a node is
161/// always painted before the children that overwrite parts of it. It uses an
162/// explicit stack rather than recursion: nesting depth is the *document's*, and
163/// a thousand nested block quotes should slow a repaint down, not end it.
164pub fn build(nodes: &[FlatNode], source: &str) -> SourceMap {
165 let Some(root) = nodes.iter().position(|n| n.kind == Kind::Doc) else {
166 return SourceMap::default();
167 };
168 let len = source.len();
169 if len == 0 {
170 return SourceMap::default();
171 }
172
173 // One style per byte, collapsed to runs at the end. The document is walked
174 // once and each byte written once per level of nesting over it, which for
175 // real markup is a small constant — and it makes "the child wins" fall out
176 // of the write order instead of needing an interval tree to arbitrate.
177 let mut paint = vec![Style::default(); len];
178 let mut stack = vec![(root, Style::default())];
179 while let Some((id, base)) = stack.pop() {
180 let node = &nodes[id];
181 let style = style_of(node, base);
182
183 // The node's own extent first, then the bytes its content leaves out —
184 // those are its delimiters, and they are scaffolding whatever the node
185 // itself is. `Role::Delimiter` sits on top of the run's own emphasis,
186 // exactly as `wysiwyg::Builder::push_delim` lays it on a revealed line,
187 // so the `**` around a bold phrase comes out dim *and* bold.
188 //
189 // Markup goes down through `fill_markup`, which declines a stretch with
190 // no markup actually in it — a `soft_break` that is one bare newline, a
191 // block whose span runs a line further than its content. Both are gaps
192 // in the arithmetic sense and neither has anything to dim.
193 if style.role == Role::Delimiter {
194 fill_markup(&mut paint, source, &node.span, style);
195 } else {
196 fill(&mut paint, &node.span, style);
197 }
198 if let Some(content) = &node.content_span {
199 let delim = style.role(Role::Delimiter);
200 fill_markup(&mut paint, source, &(node.span.start..content.start), delim);
201 fill_markup(&mut paint, source, &(content.end..node.span.end), delim);
202 }
203
204 let mut child = node.first_child;
205 while let Some(cid) = child {
206 let i = cid.0 as usize;
207 let Some(n) = nodes.get(i) else { break };
208 stack.push((i, style));
209 child = n.next_sibling;
210 }
211 }
212
213 SourceMap {
214 runs: to_runs(paint),
215 }
216}
217
218/// Paint `span` with `style`, clipped to the buffer. A span reaching past the
219/// source can only come from an arena and a string that have drifted apart; the
220/// clip means that renders wrong rather than panicking in a paint loop.
221fn fill(paint: &mut [Style], span: &Range<usize>, style: Style) {
222 let start = span.start.min(paint.len());
223 let end = span.end.min(paint.len());
224 if start < end {
225 paint[start..end].fill(style);
226 }
227}
228
229/// [`fill`] for a stretch of *markup*, which declines one that holds none.
230///
231/// Almost every block's span runs to the end of the line its content ends on, so
232/// the arithmetic leaves a trailing `"\n"` outside `content_span` — and a plain
233/// `soft_break` is a bare newline that this module dims for the sake of the
234/// `"> "` a block quote sometimes hangs on it. Painting either changes nothing a
235/// reader can see: whitespace has no glyph to dim.
236///
237/// It is not free, though. It splits the run that covers it, so a document of
238/// ordinary prose comes back as one styled run per line instead of none — which
239/// is a map every painter then walks, and a `SourceMap::is_empty` that is never
240/// true. Declining is what keeps "no markup" costing nothing.
241fn fill_markup(paint: &mut [Style], source: &str, span: &Range<usize>, style: Style) {
242 let blank = source
243 .get(span.start.min(source.len())..span.end.min(source.len()))
244 .is_none_or(|s| s.trim().is_empty());
245 if !blank {
246 fill(paint, span, style);
247 }
248}
249
250/// Collapse the per-byte buffer into ascending runs, dropping the [`Role::Body`]
251/// stretches — those are the default the map's gaps already mean.
252fn to_runs(paint: Vec<Style>) -> Vec<StyledRun> {
253 let mut runs: Vec<StyledRun> = Vec::new();
254 let mut start = 0usize;
255 for i in 1..=paint.len() {
256 if i < paint.len() && paint[i] == paint[start] {
257 continue;
258 }
259 if paint[start] != Style::default() {
260 runs.push(StyledRun {
261 span: start..i,
262 style: paint[start],
263 });
264 }
265 start = i;
266 }
267 runs
268}
269
270/// A node's style, layered on the style it inherits from its parent.
271///
272/// Deliberately the same decisions [`crate::wysiwyg`] makes for the rendered
273/// view — `emph` is italic in both, `verbatim` is [`Role::Code`] in both — so
274/// toggling ⌘E between the two views recolors the markup without recoloring the
275/// prose.
276///
277/// [`Kind`] is `#[non_exhaustive]`; an unmapped kind inherits its parent's
278/// style, which is why a node twig grows later shows up as ordinary text rather
279/// than as a compile error.
280fn style_of(node: &FlatNode, base: Style) -> Style {
281 match node.kind {
282 // A heading's level picks the style, as it does in the rendered view.
283 // `level` is `None` on a malformed heading; treat it as the top one.
284 Kind::Heading => base.role(Role::Heading(node.level.unwrap_or(1).clamp(1, 255) as u8)),
285
286 // The inline marks, matched to `wysiwyg`'s arms one for one.
287 Kind::Emph => base.italic(),
288 Kind::Strong => base.bold(),
289 Kind::Mark => base.role(Role::Mark),
290 Kind::Insert => base.underline(),
291 Kind::Delete => base.strikethrough(),
292 Kind::Superscript => base.baseline(Baseline::Super),
293 Kind::Subscript => base.baseline(Baseline::Sub),
294
295 // Code, and the things that read like it. `raw_block`/`raw_inline` are
296 // markup twig passed through untouched (an HTML tag in a Markdown
297 // document) — verbatim source inside a document, which is what
298 // `Role::Code` means.
299 Kind::CodeBlock
300 | Kind::Verbatim
301 | Kind::InlineMath
302 | Kind::DisplayMath
303 | Kind::RawBlock
304 | Kind::RawInline => base.role(Role::Code),
305
306 // Anything that points somewhere. A reference and a citation resolve to
307 // a definition elsewhere in the document, which is a link by another
308 // name — `wysiwyg` styles them `Role::Link` for the same reason.
309 Kind::Link
310 | Kind::Url
311 | Kind::Email
312 | Kind::Reference
313 | Kind::Citation
314 | Kind::FootnoteReference
315 | Kind::CitationReference
316 | Kind::SubstitutionReference => base.role(Role::Link),
317
318 Kind::ThematicBreak => base.role(Role::Rule),
319
320 // Scaffolding with no rendered form of its own: an XML declaration, a
321 // doctype, a comment, a CDATA wrapper. Dimmed whole rather than by its
322 // delimiters, because all of it is machinery.
323 Kind::Comment | Kind::Doctype | Kind::ProcessingInstruction | Kind::Cdata => {
324 base.role(Role::Delimiter)
325 }
326
327 // A soft break carries the *continuation* markers with it — the `> ` a
328 // block quote repeats on its second line, the indent under a list item
329 // — so dimming it dims those, which no node's delimiter gap reaches. A
330 // plain soft break is one invisible newline and is dimmed for nothing.
331 Kind::SoftBreak => base.role(Role::Delimiter),
332
333 _ => base,
334 }
335}
336
337#[cfg(test)]
338mod tests {
339 use super::*;
340 use twig::{Editor, Format};
341
342 /// Build a map the way `Doc` does, and hand back the source alongside it so
343 /// assertions can name bytes by the text they cover rather than by offset.
344 fn map(src: &str, format: Format) -> SourceMap {
345 let mut ed = Editor::new_str(src, format).unwrap();
346 let nodes = ed.nodes().unwrap();
347 build(&nodes, src)
348 }
349
350 fn md(src: &str) -> SourceMap {
351 map(src, Format::Markdown)
352 }
353
354 /// Every byte of `src` whose style satisfies `pred`, as a string — the
355 /// readable form of "what came out dim?".
356 fn where_style(m: &SourceMap, src: &str, pred: impl Fn(Style) -> bool) -> String {
357 (0..src.len())
358 .filter(|&i| src.is_char_boundary(i) && pred(m.style_at(i)))
359 .filter_map(|i| src[i..].chars().next())
360 .collect()
361 }
362
363 #[test]
364 fn a_headings_hash_is_markup_and_its_text_is_a_heading() {
365 let src = "# Title\n";
366 let m = md(src);
367 assert_eq!(
368 where_style(&m, src, |s| s.role == Role::Delimiter),
369 "# ",
370 "the `# ` opens the heading and is not part of it"
371 );
372 assert_eq!(
373 where_style(&m, src, |s| s.role == Role::Heading(1)),
374 "Title",
375 "the text is the heading"
376 );
377 }
378
379 #[test]
380 fn a_links_destination_is_markup_and_its_label_is_a_link() {
381 let src = "see [here](https://example.dev) now\n";
382 let m = md(src);
383 assert_eq!(where_style(&m, src, |s| s.role == Role::Link), "here");
384 assert_eq!(
385 where_style(&m, src, |s| s.role == Role::Delimiter),
386 "[](https://example.dev)",
387 "the brackets and the destination are the link's markup"
388 );
389 }
390
391 #[test]
392 fn emphasis_inside_a_heading_is_both() {
393 let src = "## a *b* c\n";
394 let m = md(src);
395 let b = src.find('b').unwrap();
396 let style = m.style_at(b);
397 assert_eq!(style.role, Role::Heading(2), "still heading text");
398 assert!(style.italic, "and italic");
399 }
400
401 /// The delimiter role sits *on top of* the run's own emphasis rather than
402 /// replacing it, so a frontend can dim the `**` and still draw it bold —
403 /// the same composition `wysiwyg::Builder::push_delim` does.
404 #[test]
405 fn a_marks_delimiters_keep_the_emphasis_they_delimit() {
406 let src = "a **b** c\n";
407 let m = md(src);
408 let star = src.find('*').unwrap();
409 assert_eq!(m.style_at(star).role, Role::Delimiter);
410 assert!(m.style_at(star).bold, "the `**` belongs to the bold run");
411 assert!(m.style_at(src.find('b').unwrap()).bold);
412 assert_eq!(m.style_at(src.find('b').unwrap()).role, Role::Body);
413 }
414
415 #[test]
416 fn a_fence_is_markup_and_the_body_is_code() {
417 let src = "```rust\nfn main() {}\n```\n";
418 let m = md(src);
419 assert_eq!(
420 m.style_at(src.find("fn").unwrap()).role,
421 Role::Code,
422 "the body of the block is code"
423 );
424 assert_eq!(
425 m.style_at(0).role,
426 Role::Delimiter,
427 "the opening fence is markup"
428 );
429 assert_eq!(
430 m.style_at(src.rfind("```").unwrap()).role,
431 Role::Delimiter,
432 "and so is the closing one"
433 );
434 }
435
436 #[test]
437 fn frontmatter_fences_are_markup() {
438 let src = "---\ntitle: x\n---\n\ntext\n";
439 let m = md(src);
440 assert_eq!(m.style_at(0).role, Role::Delimiter, "the opening `---`");
441 assert_eq!(
442 m.style_at(src.find("title").unwrap()).role,
443 Role::Body,
444 "the metadata itself is text"
445 );
446 }
447
448 /// A list marker and a block quote's gutter are authored bytes with no node
449 /// of their own; they fall in the leading gap of the paragraph inside, which
450 /// is exactly what the delimiter rule is for.
451 #[test]
452 fn list_markers_and_quote_gutters_are_markup() {
453 let src = "- one\n- [ ] two\n";
454 let m = md(src);
455 assert_eq!(m.style_at(0).role, Role::Delimiter, "the `- `");
456 assert_eq!(m.style_at(src.find("one").unwrap()).role, Role::Body);
457 let box_at = src.find("[ ]").unwrap();
458 assert_eq!(m.style_at(box_at).role, Role::Delimiter, "the task box");
459 }
460
461 /// The `> ` a quote repeats on its continuation lines is inside the
462 /// paragraph's content span, so no delimiter gap reaches it — the soft break
463 /// it rides does.
464 #[test]
465 fn a_quotes_continuation_marker_is_markup_too() {
466 let src = "> one\n> two\n";
467 let m = md(src);
468 assert_eq!(m.style_at(0).role, Role::Delimiter, "the opening `> `");
469 let second = src.rfind('>').unwrap();
470 assert_eq!(
471 m.style_at(second).role,
472 Role::Delimiter,
473 "and the one on the second line"
474 );
475 assert_eq!(m.style_at(src.find("two").unwrap()).role, Role::Body);
476 }
477
478 /// One vocabulary, three grammars: the same assertion holds however the
479 /// document spells its markup, which is the whole argument for reading this
480 /// off twig's AST instead of off a per-language grammar.
481 #[test]
482 fn every_format_styles_bold_the_same_way() {
483 for (format, src, word) in [
484 (Format::Markdown, "a **b** c\n", "b"),
485 (Format::Djot, "a *b* c\n", "b"),
486 (Format::Html, "<p>a <b>bee</b> c</p>\n", "bee"),
487 ] {
488 let m = map(src, format);
489 let at = src.find(word).unwrap();
490 assert!(
491 m.style_at(at).bold,
492 "{format:?} should style {word:?} bold in {src:?}"
493 );
494 assert_eq!(
495 m.style_at(at).role,
496 Role::Body,
497 "{format:?}: the bold text is prose, not markup"
498 );
499 }
500 }
501
502 #[test]
503 fn html_tags_are_markup_and_a_comment_is_dim_throughout() {
504 let src = "<h1>Title</h1>\n<!-- note -->\n";
505 let m = map(src, Format::Html);
506 assert_eq!(m.style_at(0).role, Role::Delimiter, "the `<h1>` tag");
507 assert_eq!(
508 m.style_at(src.find("Title").unwrap()).role,
509 Role::Heading(1),
510 "what the tag contains is a heading"
511 );
512 assert!(
513 where_style(&m, src, |s| s.role == Role::Delimiter).contains("note"),
514 "a comment is machinery all the way through"
515 );
516 }
517
518 #[test]
519 fn plain_prose_styles_nothing() {
520 let m = md("Just a sentence with no markup in it at all.\n");
521 assert!(m.is_empty(), "no runs, so a painter does no extra work");
522 }
523
524 #[test]
525 fn an_empty_document_is_an_empty_map() {
526 assert!(md("").is_empty());
527 }
528
529 /// The invariant every consumer relies on: ascending, disjoint, and never
530 /// the default style (which the gaps already mean).
531 #[test]
532 fn runs_are_ascending_disjoint_and_never_default() {
533 let src =
534 "---\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";
535 let m = md(src);
536 assert!(!m.is_empty());
537 let mut prev = 0;
538 for run in m.runs() {
539 assert!(run.span.start < run.span.end, "no empty runs: {run:?}");
540 assert!(run.span.start >= prev, "ascending and disjoint: {run:?}");
541 assert_ne!(run.style, Style::default(), "no default runs: {run:?}");
542 assert!(run.span.end <= src.len(), "inside the source: {run:?}");
543 prev = run.span.end;
544 }
545 }
546
547 /// `style_at` and `runs()` are two views of one answer, so a scan through
548 /// either has to agree with the other at every byte.
549 #[test]
550 fn style_at_agrees_with_the_runs_it_reads() {
551 let src = "# H\n\ntext **b** and `c` and [l](d)\n";
552 let m = md(src);
553 for run in m.runs() {
554 for i in run.span.clone() {
555 assert_eq!(m.style_at(i), run.style, "byte {i}");
556 }
557 }
558 // And a byte in no run is plain.
559 let gap = src.find("text").unwrap();
560 assert_eq!(m.style_at(gap), Style::default());
561 }
562
563 #[test]
564 fn edges_in_reports_every_boundary_inside_the_line_and_none_outside() {
565 let src = "a **b** c\n";
566 let m = md(src);
567 let mut cuts = Vec::new();
568 m.edges_in(0..src.len(), &mut cuts);
569 // `**b**` spans 2..7: dim `**` at 2..4, bold `b` at 4..5, dim `**` 5..7.
570 assert_eq!(cuts, vec![2, 4, 5, 7]);
571
572 // A range that ends mid-run reports only what falls strictly inside it.
573 let mut cuts = Vec::new();
574 m.edges_in(0..5, &mut cuts);
575 assert_eq!(cuts, vec![2, 4]);
576 }
577
578 /// The source view paints line by line, so the map has to answer for a
579 /// window that starts and ends in the middle of runs.
580 #[test]
581 fn edges_in_answers_for_a_line_in_the_middle_of_a_document() {
582 let src = "# One\n\ntwo **three** four\n\n# Five\n";
583 let m = md(src);
584 let line_start = src.find("two").unwrap();
585 let line_end = src[line_start..].find('\n').unwrap() + line_start;
586 let mut cuts = Vec::new();
587 m.edges_in(line_start..line_end, &mut cuts);
588 assert!(
589 cuts.iter().all(|&c| c > line_start && c < line_end),
590 "every cut lands inside the line: {cuts:?}"
591 );
592 assert_eq!(cuts.len(), 4, "the two `**` pairs and the word between");
593 }
594
595 /// A document twig cannot make sense of still has to paint. The arena and
596 /// the string can only disagree through a bug, but a paint loop is the wrong
597 /// place to find out.
598 #[test]
599 fn a_span_past_the_end_of_the_source_is_clipped_not_panicked() {
600 let mut ed = Editor::new_str("# H\n", Format::Markdown).unwrap();
601 let nodes = ed.nodes().unwrap();
602 let m = build(&nodes, "# ");
603 for run in m.runs() {
604 assert!(run.span.end <= 2, "clipped to the length given: {run:?}");
605 }
606 }
607}