Skip to main content

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    #[cfg(feature = "syntax")]
570    #[test]
571    fn an_indented_fence_is_highlighted_past_its_indent() {
572        use crate::style::Token;
573        let src = "  ```rust\n  let x = 1;\n  ```\n";
574        let m = md(src);
575        let at = |needle: &str| src.find(needle).unwrap();
576        assert_eq!(m.style_at(at("let")).token, Some(Token::Keyword));
577        assert_eq!(m.style_at(at("1")).token, Some(Token::Constant));
578        assert_eq!(
579            m.style_at(at("let") - 1).token,
580            None,
581            "the indent carries none"
582        );
583    }
584
585    /// A fence inside a quote or a list item strips its container's prefix
586    /// the same way, and the language is read past the marker.
587    #[cfg(feature = "syntax")]
588    #[test]
589    fn a_fence_in_a_container_is_highlighted() {
590        use crate::style::Token;
591        for src in [
592            "> ```rust\n> let x = 1;\n> ```\n",
593            "- ```rust\n  let x = 1;\n  ```\n",
594        ] {
595            let m = md(src);
596            let at = src.find("let").unwrap();
597            assert_eq!(m.style_at(at).role, Role::Code, "{src:?}");
598            assert_eq!(m.style_at(at).token, Some(Token::Keyword), "{src:?}");
599        }
600    }
601
602    /// A fence in no known language, or with no language at all, is plain code
603    /// — as the rendered view draws it.
604    #[cfg(feature = "syntax")]
605    #[test]
606    fn a_fence_in_an_unknown_language_is_plain_code() {
607        for src in ["```nosuchlang\nlet x\n```\n", "```\nlet x\n```\n"] {
608            let m = md(src);
609            let at = src.find("let").unwrap();
610            assert_eq!(m.style_at(at).role, Role::Code, "{src:?}");
611            assert_eq!(m.style_at(at).token, None, "{src:?}");
612        }
613    }
614
615    #[test]
616    fn frontmatter_fences_are_markup() {
617        let src = "---\ntitle: x\n---\n\ntext\n";
618        let m = md(src);
619        assert_eq!(m.style_at(0).role, Role::Delimiter, "the opening `---`");
620        assert_eq!(
621            m.style_at(src.find("title").unwrap()).role,
622            Role::Body,
623            "the metadata itself is text"
624        );
625    }
626
627    /// A list marker and a block quote's gutter are authored bytes with no node
628    /// of their own; they fall in the leading gap of the paragraph inside, which
629    /// is exactly what the delimiter rule is for.
630    #[test]
631    fn list_markers_and_quote_gutters_are_markup() {
632        let src = "- one\n- [ ] two\n";
633        let m = md(src);
634        assert_eq!(m.style_at(0).role, Role::Delimiter, "the `- `");
635        assert_eq!(m.style_at(src.find("one").unwrap()).role, Role::Body);
636        let box_at = src.find("[ ]").unwrap();
637        assert_eq!(m.style_at(box_at).role, Role::Delimiter, "the task box");
638    }
639
640    /// The `> ` a quote repeats on its continuation lines is inside the
641    /// paragraph's content span, so no delimiter gap reaches it — the soft break
642    /// it rides does.
643    #[test]
644    fn a_quotes_continuation_marker_is_markup_too() {
645        let src = "> one\n> two\n";
646        let m = md(src);
647        assert_eq!(m.style_at(0).role, Role::Delimiter, "the opening `> `");
648        let second = src.rfind('>').unwrap();
649        assert_eq!(
650            m.style_at(second).role,
651            Role::Delimiter,
652            "and the one on the second line"
653        );
654        assert_eq!(m.style_at(src.find("two").unwrap()).role, Role::Body);
655    }
656
657    /// One vocabulary, three grammars: the same assertion holds however the
658    /// document spells its markup, which is the whole argument for reading this
659    /// off twig's AST instead of off a per-language grammar.
660    #[test]
661    fn every_format_styles_bold_the_same_way() {
662        for (format, src, word) in [
663            (Format::Markdown, "a **b** c\n", "b"),
664            (Format::Djot, "a *b* c\n", "b"),
665            (Format::Html, "<p>a <b>bee</b> c</p>\n", "bee"),
666        ] {
667            let m = map(src, format);
668            let at = src.find(word).unwrap();
669            assert!(
670                m.style_at(at).bold,
671                "{format:?} should style {word:?} bold in {src:?}"
672            );
673            assert_eq!(
674                m.style_at(at).role,
675                Role::Body,
676                "{format:?}: the bold text is prose, not markup"
677            );
678        }
679    }
680
681    #[test]
682    fn html_tags_are_markup_and_a_comment_is_dim_throughout() {
683        let src = "<h1>Title</h1>\n<!-- note -->\n";
684        let m = map(src, Format::Html);
685        assert_eq!(m.style_at(0).role, Role::Delimiter, "the `<h1>` tag");
686        assert_eq!(
687            m.style_at(src.find("Title").unwrap()).role,
688            Role::Heading(1),
689            "what the tag contains is a heading"
690        );
691        assert!(
692            where_style(&m, src, |s| s.role == Role::Delimiter).contains("note"),
693            "a comment is machinery all the way through"
694        );
695    }
696
697    #[test]
698    fn plain_prose_styles_nothing() {
699        let m = md("Just a sentence with no markup in it at all.\n");
700        assert!(m.is_empty(), "no runs, so a painter does no extra work");
701    }
702
703    #[test]
704    fn an_empty_document_is_an_empty_map() {
705        assert!(md("").is_empty());
706    }
707
708    /// The invariant every consumer relies on: ascending, disjoint, and never
709    /// the default style (which the gaps already mean).
710    #[test]
711    fn runs_are_ascending_disjoint_and_never_default() {
712        let src =
713            "---\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";
714        let m = md(src);
715        assert!(!m.is_empty());
716        let mut prev = 0;
717        for run in m.runs() {
718            assert!(run.span.start < run.span.end, "no empty runs: {run:?}");
719            assert!(run.span.start >= prev, "ascending and disjoint: {run:?}");
720            assert_ne!(run.style, Style::default(), "no default runs: {run:?}");
721            assert!(run.span.end <= src.len(), "inside the source: {run:?}");
722            prev = run.span.end;
723        }
724    }
725
726    /// `style_at` and `runs()` are two views of one answer, so a scan through
727    /// either has to agree with the other at every byte.
728    #[test]
729    fn style_at_agrees_with_the_runs_it_reads() {
730        let src = "# H\n\ntext **b** and `c` and [l](d)\n";
731        let m = md(src);
732        for run in m.runs() {
733            for i in run.span.clone() {
734                assert_eq!(m.style_at(i), run.style, "byte {i}");
735            }
736        }
737        // And a byte in no run is plain.
738        let gap = src.find("text").unwrap();
739        assert_eq!(m.style_at(gap), Style::default());
740    }
741
742    #[test]
743    fn edges_in_reports_every_boundary_inside_the_line_and_none_outside() {
744        let src = "a **b** c\n";
745        let m = md(src);
746        let mut cuts = Vec::new();
747        m.edges_in(0..src.len(), &mut cuts);
748        // `**b**` spans 2..7: dim `**` at 2..4, bold `b` at 4..5, dim `**` 5..7.
749        assert_eq!(cuts, vec![2, 4, 5, 7]);
750
751        // A range that ends mid-run reports only what falls strictly inside it.
752        let mut cuts = Vec::new();
753        m.edges_in(0..5, &mut cuts);
754        assert_eq!(cuts, vec![2, 4]);
755    }
756
757    /// The source view paints line by line, so the map has to answer for a
758    /// window that starts and ends in the middle of runs.
759    #[test]
760    fn edges_in_answers_for_a_line_in_the_middle_of_a_document() {
761        let src = "# One\n\ntwo **three** four\n\n# Five\n";
762        let m = md(src);
763        let line_start = src.find("two").unwrap();
764        let line_end = src[line_start..].find('\n').unwrap() + line_start;
765        let mut cuts = Vec::new();
766        m.edges_in(line_start..line_end, &mut cuts);
767        assert!(
768            cuts.iter().all(|&c| c > line_start && c < line_end),
769            "every cut lands inside the line: {cuts:?}"
770        );
771        assert_eq!(cuts.len(), 4, "the two `**` pairs and the word between");
772    }
773
774    /// A document twig cannot make sense of still has to paint. The arena and
775    /// the string can only disagree through a bug, but a paint loop is the wrong
776    /// place to find out.
777    #[test]
778    fn a_span_past_the_end_of_the_source_is_clipped_not_panicked() {
779        let mut ed = Editor::new_str("# H\n", Format::Markdown).unwrap();
780        let nodes = ed.nodes().unwrap();
781        let m = build(&nodes, "# ");
782        for run in m.runs() {
783            assert!(run.span.end <= 2, "clipped to the length given: {run:?}");
784        }
785    }
786}