Skip to main content

kimun_notes/components/
markdown_lines.rs

1//! A lean, line-level markdown styler shared by surfaces that want to *style*
2//! markdown without editing it (currently the Ask workspace's answer body).
3//!
4//! Design constraints that shape it:
5//!
6//! - **Emphasis sigils are hidden; structural markers stay.** Balanced
7//!   `**`/`__` (bold) and `*`/`_` (italic) delimiters are dropped from the
8//!   rendered text (the run between them is styled instead), matching what a
9//!   reader expects. Everything else stays visible: `#` headings, `>` quotes,
10//!   fences, list markers, and citation `[n]` markers. Because hiding breaks
11//!   the old 1:1 byte↔column identity, [`style_slice_mapped`] emits, alongside
12//!   the styled line, a **column map** (`rendered char index → source byte
13//!   offset`) so callers can still hit-test a click back to the right source
14//!   byte. (This is why we don't reuse the editor's `ParsedBuffer`, which fully
15//!   re-lays-out the visual line.)
16//! - **Only same-line, balanced pairs are hidden.** A sigil is hidden only when
17//!   an opener and a closer of the same kind appear in the *same wrapped slice*
18//!   (the per-slice approximation — we never look across the wrap boundary). A
19//!   lone `*` (an unmatched sigil, a bullet, arithmetic) stays visible and does
20//!   not emphasize anything. Sigils inside inline code are literal.
21//! - **Intraword `_` is not emphasis.** Following CommonMark, a `_`/`__` run may
22//!   open only when the char before it is absent/non-alphanumeric and close only
23//!   when the char after it is — so `snake_case` and `foo_bar_baz` identifiers
24//!   render verbatim. `*`/`**` keep the laxer rule (intraword `*` is legal).
25//! - **Citations are the citations module's job.** `[n]` markers are found
26//!   only through [`crate::ask::citations::scan`]; we merely *style* the ranges
27//!   it reports. Code (fenced blocks and inline spans) is never citation-styled.
28//!
29//! The unit of work is one *logical* source line, split across two layers:
30//!
31//! - **Block identity is not ours to decide.** [`classify_block_kinds`] hands
32//!   the whole answer to the editor's buffer-aware markdown model
33//!   ([`crate::components::text_editor::markdown::ParsedBuffer`], the real
34//!   pulldown-cmark classifier) and maps each row's result onto a [`LineKind`].
35//!   There is exactly one opinion about what a line *is*, and it is the
36//!   editor's — so answers and the note editor never disagree, and cross-line
37//!   constructs the model resolves natively (unclosed fences, setext
38//!   underlines, lazily-continued blockquotes) come along for free.
39//! - **Inline styling stays here.** [`style_slice_mapped`] styles one wrapped
40//!   visual slice of a line (given its [`LineKind`]) and returns its column
41//!   map — this is the answer-domain layer (emphasis hiding, citation styling,
42//!   `col_map`) the editor's fully-relaid `ParsedBuffer` render can't provide.
43
44use ratatui::style::{Modifier, Style};
45use ratatui::text::{Line, Span};
46
47use crate::ask::citations;
48use crate::settings::themes::Theme;
49
50/// The block role of one logical source line.
51#[derive(Clone, Copy, PartialEq, Eq, Debug)]
52pub enum LineKind {
53    /// A code line — a fenced-code delimiter (```` ``` ````/`~~~`), a line
54    /// inside a fence, or an indented (4-space/tab) code block: styled as code
55    /// verbatim, with no inline markdown or citation restyling.
56    Code,
57    /// An ATX heading (`#`..`######`).
58    Heading,
59    /// A blockquote line (`>`).
60    Quote,
61    /// Paragraph text or a list item — inline styling (bold/italic/inline code)
62    /// and citations apply.
63    Normal,
64}
65
66/// The semantic styles the answer body renders with, resolved from the theme
67/// once per render and reused across every line.
68#[derive(Clone, Copy)]
69pub struct MdStyles {
70    pub base: Style,
71    pub heading: Style,
72    pub quote: Style,
73    pub code: Style,
74    pub bold: Style,
75    pub italic: Style,
76    pub citation: Style,
77}
78
79impl MdStyles {
80    /// Build from the theme, mirroring the editor's markdown color conventions
81    /// (`text_editor::markdown::span_style`): headings bright+bold, inline/code
82    /// aqua on a soft background, bold accent+bold, italic secondary+italic,
83    /// blockquote secondary. Citations keep the answer's accent marker color.
84    pub fn from_theme(theme: &Theme) -> Self {
85        Self {
86            base: Style::default().fg(theme.fg.to_ratatui()),
87            heading: Style::default()
88                .fg(theme.fg_bright.to_ratatui())
89                .add_modifier(Modifier::BOLD),
90            quote: Style::default().fg(theme.fg_secondary.to_ratatui()),
91            code: Style::default()
92                .fg(theme.aqua.to_ratatui())
93                .bg(theme.bg_soft.to_ratatui()),
94            bold: Style::default()
95                .fg(theme.accent.to_ratatui())
96                .add_modifier(Modifier::BOLD),
97            italic: Style::default()
98                .fg(theme.fg_secondary.to_ratatui())
99                .add_modifier(Modifier::ITALIC),
100            citation: Style::default().fg(theme.accent.to_ratatui()),
101        }
102    }
103}
104
105/// Classify the block role of every logical (newline-free) source `line` in
106/// `lines`, in order, by delegating wholesale to the editor's markdown model
107/// ([`ParsedBuffer::parse`]) — so there is a single, buffer-aware opinion about
108/// block identity shared with the note editor.
109///
110/// Because the model sees the whole answer at once, cross-line constructs the
111/// old per-line scanner could not are handled natively:
112///
113/// - an **unclosed fence** keeps every following line `Code` to end-of-answer;
114/// - a **setext underline** (`Title` then `====`/`----`) tags *both* rows as a
115///   heading. Pulldown spans the heading element across the underline and
116///   resets the *title* row's coarse `LineConstructKind` back to `Plain`, so we
117///   read the heading off each row's per-line `elements`, not the coarse kind;
118/// - a **lazy blockquote continuation** (a bare line folded into a preceding
119///   `>` quote) reports the quote's depth via [`ParsedLine::blockquote_depth`]
120///   and so styles as `Quote`, matching what the editor renders.
121///
122/// Code wins over the heading/quote signals: inside a fenced or indented code
123/// block a `>` or `#` is literal, so the coarse code kinds take precedence.
124pub fn classify_block_kinds(lines: &[&str]) -> Vec<LineKind> {
125    use crate::components::text_editor::markdown::{ElementKind, ParsedBuffer};
126    use crate::components::text_editor::parse_incremental::LineConstructKind;
127
128    let owned: Vec<String> = lines.iter().map(|s| s.to_string()).collect();
129    let parsed = ParsedBuffer::parse(&owned);
130    parsed
131        .lines
132        .iter()
133        .zip(parsed.kinds.iter())
134        .map(|(parsed_line, &kind)| {
135            if matches!(
136                kind,
137                LineConstructKind::FenceMarker
138                    | LineConstructKind::FenceContent
139                    | LineConstructKind::IndentedCode
140            ) {
141                return LineKind::Code;
142            }
143            let is_heading = matches!(kind, LineConstructKind::SetextUnderline)
144                || parsed_line.elements.iter().any(|e| {
145                    matches!(
146                        e.kind,
147                        ElementKind::HeadingH1 | ElementKind::HeadingH2 | ElementKind::HeadingH3
148                    )
149                });
150            if is_heading {
151                LineKind::Heading
152            } else if parsed_line.blockquote_depth().is_some() {
153                LineKind::Quote
154            } else {
155                LineKind::Normal
156            }
157        })
158        .collect()
159}
160
161/// Style one wrapped visual `slice` of a logical line whose block role is
162/// `kind`, returning the styled [`Line`] and its **column map**: `map[k]` is
163/// the source byte offset (into `slice`) of the `k`-th *rendered* character.
164///
165/// For every kind except `Normal` nothing is hidden, so the map is the identity
166/// over the slice's chars. For `Normal`, balanced emphasis sigils are dropped
167/// (see the module doc), so `map` skips their bytes — a caller resolving a
168/// rendered column back to a source byte walks `map`.
169///
170/// `slice` must be the exact source text shown on the row (structural markers
171/// included).
172pub fn style_slice_mapped(
173    slice: &str,
174    kind: LineKind,
175    styles: &MdStyles,
176) -> (Line<'static>, Vec<usize>) {
177    match kind {
178        LineKind::Code => whole_slice(slice, styles.code),
179        LineKind::Heading => whole_slice(slice, styles.heading),
180        LineKind::Quote => whole_slice(slice, styles.quote),
181        LineKind::Normal => inline_spans(slice, styles),
182    }
183}
184
185/// Style `slice` as a single verbatim span (no hiding) with the identity map.
186fn whole_slice(slice: &str, style: Style) -> (Line<'static>, Vec<usize>) {
187    let map: Vec<usize> = slice.char_indices().map(|(i, _)| i).collect();
188    (Line::from(Span::styled(slice.to_string(), style)), map)
189}
190
191/// Split a `Normal` slice into styled spans — dropping balanced emphasis
192/// sigils and returning the column map alongside. Inline code (`` `…` ``) is
193/// verbatim; citation `[n]` ranges (from [`citations::scan`]) win over
194/// emphasis; inline code wins over everything and is never citation-styled.
195/// The concatenation of the returned spans equals `slice` with exactly the
196/// hidden sigil pairs removed.
197fn inline_spans(slice: &str, styles: &MdStyles) -> (Line<'static>, Vec<usize>) {
198    let chars: Vec<(usize, char)> = slice.char_indices().collect();
199    let code_mask = code_mask(&chars);
200    let (hidden, bold, italic) = analyze_emphasis(&chars, &code_mask);
201
202    let cites = citations::scan(slice);
203    let is_cited = |i: usize| cites.iter().any(|c| c.range.contains(&i));
204
205    let mut spans: Vec<Span<'static>> = Vec::new();
206    let mut buf = String::new();
207    let mut buf_style = styles.base;
208    let mut map: Vec<usize> = Vec::new();
209
210    for (k, &(i, ch)) in chars.iter().enumerate() {
211        if hidden[k] {
212            continue; // a balanced sigil — dropped from the rendered text.
213        }
214        let style = if code_mask[k] {
215            styles.code
216        } else if is_cited(i) {
217            styles.citation
218        } else if bold[k] {
219            styles.bold
220        } else if italic[k] {
221            styles.italic
222        } else {
223            styles.base
224        };
225        if style != buf_style && !buf.is_empty() {
226            spans.push(Span::styled(std::mem::take(&mut buf), buf_style));
227        }
228        buf_style = style;
229        buf.push(ch);
230        map.push(i);
231    }
232    if !buf.is_empty() {
233        spans.push(Span::styled(buf, buf_style));
234    }
235    if spans.is_empty() {
236        spans.push(Span::styled(String::new(), styles.base));
237    }
238    (Line::from(spans), map)
239}
240
241/// Per-char mask marking inline-code spans (backticks included). A backtick
242/// opens a span; every char up to and including the next backtick is code. An
243/// unclosed span runs to the slice end (matching how a terminal would show it).
244fn code_mask(chars: &[(usize, char)]) -> Vec<bool> {
245    let mut mask = vec![false; chars.len()];
246    let mut in_code = false;
247    for (k, &(_, ch)) in chars.iter().enumerate() {
248        if in_code {
249            mask[k] = true;
250            if ch == '`' {
251                in_code = false;
252            }
253        } else if ch == '`' {
254            in_code = true;
255            mask[k] = true;
256        }
257    }
258    mask
259}
260
261/// The four emphasis delimiter kinds, each paired independently.
262#[derive(Clone, Copy, PartialEq, Eq)]
263enum Emph {
264    Star,        // `*…*`  → italic
265    Under,       // `_…_`  → italic
266    DoubleStar,  // `**…**` → bold
267    DoubleUnder, // `__…__` → bold
268}
269
270struct Delim {
271    /// First char index of the delimiter.
272    k: usize,
273    /// Number of chars (1 or 2).
274    len: usize,
275    kind: Emph,
276    /// Whether this run may *open* emphasis. Simplified CommonMark
277    /// left-flanking: a `*`/`**` run may open only when the char *after* it is
278    /// present and non-whitespace (so `width * height` never opens); a `_`/`__`
279    /// run additionally requires the char *before* it to be absent or
280    /// non-alphanumeric (the intraword-underscore rule).
281    can_open: bool,
282    /// Whether this run may *close* emphasis. Simplified CommonMark
283    /// right-flanking: a `*`/`**` run may close only when the char *before* it
284    /// is present and non-whitespace; a `_`/`__` run additionally requires the
285    /// char *after* it to be absent or non-alphanumeric.
286    can_close: bool,
287}
288
289/// Decide, per char, which emphasis sigils to *hide* and which chars fall under
290/// bold / italic styling. Delimiters are found outside inline code and paired
291/// within each kind with a stack (nearest matching opener). Every run obeys
292/// simplified CommonMark flanking — it may open only when the following char is
293/// non-whitespace and close only when the preceding char is — so `width *
294/// height * depth` and `match *.rs and *.md` stay verbatim; `_`/`__`
295/// additionally obey the intraword rule (a `_` run may only open when the char
296/// before it is absent/non-alphanumeric and only close when the char after it
297/// is), so `snake_case` identifiers are never mangled. An unmatched delimiter stays
298/// visible and styles nothing. This is the per-slice approximation — we never
299/// pair across the wrap boundary.
300fn analyze_emphasis(
301    chars: &[(usize, char)],
302    code_mask: &[bool],
303) -> (Vec<bool>, Vec<bool>, Vec<bool>) {
304    let n = chars.len();
305    let mut hidden = vec![false; n];
306    let mut bold = vec![false; n];
307    let mut italic = vec![false; n];
308
309    // Collect delimiter tokens (greedy: `**`/`__` before `*`/`_`), recording
310    // each run's open/close capability from its flanking chars.
311    let mut delims: Vec<Delim> = Vec::new();
312    let mut k = 0;
313    while k < n {
314        if code_mask[k] {
315            k += 1;
316            continue;
317        }
318        let ch = chars[k].1;
319        let next_same = k + 1 < n && !code_mask[k + 1] && chars[k + 1].1 == ch;
320        let (len, kind) = match ch {
321            '*' if next_same => (2, Emph::DoubleStar),
322            '_' if next_same => (2, Emph::DoubleUnder),
323            '*' => (1, Emph::Star),
324            '_' => (1, Emph::Under),
325            _ => {
326                k += 1;
327                continue;
328            }
329        };
330        let is_under = matches!(kind, Emph::Under | Emph::DoubleUnder);
331        let before = (k > 0).then(|| chars[k - 1].1);
332        let after = chars.get(k + len).map(|&(_, c)| c);
333        // Alphanumeric-boundary rule (underscore only) and whitespace-flanking
334        // rule (all kinds): a run left-flanks (can open) when what follows is
335        // non-whitespace, and right-flanks (can close) when what precedes is.
336        let alnum_free = |c: Option<char>| c.is_none_or(|c| !c.is_alphanumeric());
337        let non_ws = |c: Option<char>| c.is_some_and(|c| !c.is_whitespace());
338        let can_open = non_ws(after) && (!is_under || alnum_free(before));
339        let can_close = non_ws(before) && (!is_under || alnum_free(after));
340        delims.push(Delim {
341            k,
342            len,
343            kind,
344            can_open,
345            can_close,
346        });
347        k += len;
348    }
349
350    // Pair each kind with a stack: a closer binds to the nearest open delimiter
351    // of the same kind. Matched pairs hide their sigils and style the run.
352    for kind in [Emph::Star, Emph::Under, Emph::DoubleStar, Emph::DoubleUnder] {
353        let is_bold = matches!(kind, Emph::DoubleStar | Emph::DoubleUnder);
354        let mut open_stack: Vec<usize> = Vec::new();
355        for (di, d) in delims.iter().enumerate() {
356            if d.kind != kind {
357                continue;
358            }
359            if d.can_close && !open_stack.is_empty() {
360                let oi = open_stack.pop().unwrap();
361                let (open_k, open_len) = (delims[oi].k, delims[oi].len);
362                let close_k = d.k;
363                for slot in &mut hidden[open_k..open_k + open_len] {
364                    *slot = true;
365                }
366                for slot in &mut hidden[close_k..close_k + d.len] {
367                    *slot = true;
368                }
369                let run = &mut (if is_bold { &mut bold } else { &mut italic })
370                    [open_k + open_len..close_k];
371                for slot in run {
372                    *slot = true;
373                }
374            } else if d.can_open {
375                open_stack.push(di);
376            }
377        }
378    }
379    (hidden, bold, italic)
380}
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385
386    fn styles() -> MdStyles {
387        MdStyles::from_theme(&Theme::default())
388    }
389
390    /// The styled spans, concatenated — the rendered text of a line.
391    fn rendered(line: &Line<'static>) -> String {
392        line.spans.iter().map(|s| s.content.as_ref()).collect()
393    }
394
395    /// Just the styled line (the common case; the map is asserted separately).
396    fn style_slice(slice: &str, kind: LineKind, styles: &MdStyles) -> Line<'static> {
397        style_slice_mapped(slice, kind, styles).0
398    }
399
400    /// Classify a single context-free line (its block role does not depend on
401    /// neighbours) through the buffer classifier.
402    fn kind_of(line: &str) -> LineKind {
403        classify_block_kinds(&[line])[0]
404    }
405
406    #[test]
407    fn classify_toggles_fenced_code_blocks() {
408        // Opener, body, closer are all Code; the line after the fence is out.
409        assert_eq!(
410            classify_block_kinds(&["```rust", "let x = 1;", "```", "after"]),
411            vec![
412                LineKind::Code,
413                LineKind::Code,
414                LineKind::Code,
415                LineKind::Normal
416            ],
417        );
418    }
419
420    #[test]
421    fn unclosed_fence_keeps_trailing_lines_code() {
422        // An unterminated fence runs to the end of the answer — fence tracking
423        // behaviour is unchanged from the old scanner.
424        assert_eq!(
425            classify_block_kinds(&["```", "still code", "more code"]),
426            vec![LineKind::Code, LineKind::Code, LineKind::Code],
427        );
428    }
429
430    #[test]
431    fn classify_labels_headings_and_quotes() {
432        assert_eq!(kind_of("# Title"), LineKind::Heading);
433        assert_eq!(kind_of("###### h6"), LineKind::Heading);
434        assert_eq!(kind_of("####### too many"), LineKind::Normal);
435        assert_eq!(kind_of("#nospace"), LineKind::Normal);
436        assert_eq!(kind_of("> quoted"), LineKind::Quote);
437        assert_eq!(kind_of("plain text"), LineKind::Normal);
438    }
439
440    #[test]
441    fn setext_underline_styles_title_and_rule_as_heading() {
442        // `Title` + `====` is a setext H1: the editor model spans the heading
443        // across BOTH rows, so both style as a heading even though the title
444        // row carries no `#`. (`----` is a setext H2 the same way.)
445        assert_eq!(
446            classify_block_kinds(&["Title", "===="]),
447            vec![LineKind::Heading, LineKind::Heading],
448        );
449        assert_eq!(
450            classify_block_kinds(&["Title", "----"]),
451            vec![LineKind::Heading, LineKind::Heading],
452        );
453    }
454
455    #[test]
456    fn lazy_blockquote_continuation_styles_as_quote() {
457        // `> a` then a bare `b`: the editor model folds the bare line into the
458        // quote (CommonMark §5.1 lazy continuation), so BOTH style as Quote —
459        // the model is the truth and it says depth 1 on the continuation.
460        assert_eq!(
461            classify_block_kinds(&["> a", "b"]),
462            vec![LineKind::Quote, LineKind::Quote],
463        );
464    }
465
466    #[test]
467    fn blank_line_ends_the_blockquote() {
468        // A blank row ends the quote, so the line after it is not a lazy
469        // continuation (CommonMark §5.1 — pulldown closes the quote at the blank).
470        assert_eq!(
471            classify_block_kinds(&["> a", "", "b"]),
472            vec![LineKind::Quote, LineKind::Normal, LineKind::Normal],
473        );
474    }
475
476    #[test]
477    fn four_space_indent_is_code_per_editor_model() {
478        // The editor model treats a 4-space indent as an indented code block.
479        // The answer renderer now agrees (the old per-line scanner called this
480        // Normal) — a deliberate divergence, encoding the editor model's opinion.
481        assert_eq!(
482            classify_block_kinds(&["    let x = 1;"]),
483            vec![LineKind::Code]
484        );
485    }
486
487    #[test]
488    fn code_slice_is_never_citation_styled() {
489        let s = styles();
490        // A `[1]` sitting inside a code line keeps the code style — no accent.
491        let line = style_slice("let n = arr[1];", LineKind::Code, &s);
492        assert_eq!(line.spans.len(), 1, "code renders as one verbatim span");
493        assert_eq!(line.spans[0].style, s.code);
494        assert!(line.spans[0].style != s.citation);
495        assert_eq!(rendered(&line), "let n = arr[1];");
496    }
497
498    #[test]
499    fn heading_slice_gets_heading_styling() {
500        let s = styles();
501        let line = style_slice("## Overview", LineKind::Heading, &s);
502        assert_eq!(line.spans[0].style, s.heading);
503        assert_eq!(rendered(&line), "## Overview");
504    }
505
506    #[test]
507    fn prose_citation_gets_citation_style_and_preserves_bytes() {
508        let s = styles();
509        let line = style_slice("See [1] and [2].", LineKind::Normal, &s);
510        assert_eq!(rendered(&line), "See [1] and [2].", "1:1 with the source");
511        // The `[1]`/`[2]` markers carry the citation style.
512        let cited: String = line
513            .spans
514            .iter()
515            .filter(|sp| sp.style == s.citation)
516            .map(|sp| sp.content.as_ref())
517            .collect();
518        assert_eq!(cited, "[1][2]");
519    }
520
521    #[test]
522    fn bold_sigils_are_hidden_and_the_run_is_styled() {
523        let s = styles();
524        let line = style_slice("a **b** `c` d", LineKind::Normal, &s);
525        // The `**` pair is dropped; the code span's backticks stay literal.
526        assert_eq!(rendered(&line), "a b `c` d");
527        let bold: String = line
528            .spans
529            .iter()
530            .filter(|sp| sp.style == s.bold)
531            .map(|sp| sp.content.as_ref())
532            .collect();
533        assert_eq!(bold, "b", "only the run between the sigils is bold");
534        assert!(
535            line.spans.iter().any(|sp| sp.style == s.code),
536            "inline code run is styled"
537        );
538    }
539
540    #[test]
541    fn italic_sigils_are_hidden_for_both_star_and_underscore() {
542        let s = styles();
543        for (src, want) in [
544            ("an *em* word", "an em word"),
545            ("an _em_ word", "an em word"),
546        ] {
547            let line = style_slice(src, LineKind::Normal, &s);
548            assert_eq!(rendered(&line), want);
549            let italic: String = line
550                .spans
551                .iter()
552                .filter(|sp| sp.style == s.italic)
553                .map(|sp| sp.content.as_ref())
554                .collect();
555            assert_eq!(italic, "em");
556        }
557    }
558
559    #[test]
560    fn a_lone_sigil_stays_visible_and_emphasizes_nothing() {
561        let s = styles();
562        // An unbalanced `*` (a stray bullet / arithmetic) must not be eaten and
563        // must not italicize the tail of the line.
564        let line = style_slice("2 * 3 = 6 and rest", LineKind::Normal, &s);
565        assert_eq!(rendered(&line), "2 * 3 = 6 and rest", "lone sigil kept");
566        assert!(
567            line.spans.iter().all(|sp| sp.style != s.italic),
568            "no run is italicized by an unmatched sigil"
569        );
570    }
571
572    #[test]
573    fn space_flanked_stars_are_not_emphasis() {
574        let s = styles();
575        // Whitespace on the inner side means neither run can open/close, so the
576        // asterisks stay literal and nothing between them is italicized.
577        for src in ["width * height * depth = volume", "2 * 3 * 4"] {
578            let line = style_slice(src, LineKind::Normal, &s);
579            assert_eq!(rendered(&line), src, "{src} stays verbatim");
580            assert!(
581                line.spans
582                    .iter()
583                    .all(|sp| sp.style != s.italic && sp.style != s.bold),
584                "{src} gets no emphasis styling"
585            );
586        }
587    }
588
589    #[test]
590    fn glob_stars_stay_visible_and_emphasize_nothing() {
591        let s = styles();
592        // `*.rs`/`*.md`: each `*` can open (followed by `.`) but neither can
593        // close (preceded by a space), so no pair forms — both stars survive.
594        let line = style_slice("match *.rs and *.md files", LineKind::Normal, &s);
595        assert_eq!(rendered(&line), "match *.rs and *.md files");
596        assert!(
597            line.spans
598                .iter()
599                .all(|sp| sp.style != s.italic && sp.style != s.bold),
600            "glob stars italicize nothing"
601        );
602    }
603
604    #[test]
605    fn real_star_emphasis_still_works() {
606        let s = styles();
607        // `*real*` still italicizes and `**bold**` still bolds — the flanking
608        // rule only rejects whitespace-adjacent runs.
609        let line = style_slice("*real*", LineKind::Normal, &s);
610        assert_eq!(rendered(&line), "real");
611        let italic: String = line
612            .spans
613            .iter()
614            .filter(|sp| sp.style == s.italic)
615            .map(|sp| sp.content.as_ref())
616            .collect();
617        assert_eq!(italic, "real");
618
619        let line = style_slice("**bold**", LineKind::Normal, &s);
620        assert_eq!(rendered(&line), "bold");
621        let bold: String = line
622            .spans
623            .iter()
624            .filter(|sp| sp.style == s.bold)
625            .map(|sp| sp.content.as_ref())
626            .collect();
627        assert_eq!(bold, "bold");
628    }
629
630    #[test]
631    fn emphasis_inside_a_code_span_stays_literal() {
632        let s = styles();
633        // The `*x*` lives inside inline code — its asterisks are verbatim.
634        let line = style_slice("call `*x*` now", LineKind::Normal, &s);
635        assert_eq!(rendered(&line), "call `*x*` now", "code is verbatim");
636        assert!(
637            line.spans.iter().all(|sp| sp.style != s.italic),
638            "no italic from sigils inside code"
639        );
640    }
641
642    #[test]
643    fn rendered_text_is_raw_minus_exactly_the_hidden_sigil_pairs() {
644        let s = styles();
645        let raw = "**bold** and *it* and lone * kept `*z*`";
646        let line = style_slice(raw, LineKind::Normal, &s);
647        // Two balanced pairs (`**`+`**` and `*`+`*`) → 6 sigil bytes removed;
648        // the lone `*` and the in-code `*x*` survive.
649        let expected = "bold and it and lone * kept `*z*`";
650        assert_eq!(rendered(&line), expected);
651    }
652
653    #[test]
654    fn column_map_skips_hidden_sigils_and_points_at_source_bytes() {
655        let s = styles();
656        let raw = "**b** [1]";
657        let (line, map) = style_slice_mapped(raw, LineKind::Normal, &s);
658        assert_eq!(rendered(&line), "b [1]");
659        // Rendered chars: 'b'(raw 2) ' '(raw 5) '['(raw 6) '1'(raw 7) ']'(raw 8).
660        assert_eq!(map, vec![2, 5, 6, 7, 8]);
661    }
662
663    #[test]
664    fn non_normal_kinds_keep_the_identity_map() {
665        let s = styles();
666        let (_, map) = style_slice_mapped("## Head", LineKind::Heading, &s);
667        assert_eq!(map, (0.."## Head".len()).collect::<Vec<_>>());
668    }
669
670    #[test]
671    fn intraword_underscores_are_left_verbatim() {
672        let s = styles();
673        // snake_case identifiers must not be mangled: the `_` are intraword, so
674        // they neither open nor close emphasis — kept literal, nothing styled.
675        for src in ["foo_bar_baz", "some__thing__glued"] {
676            let line = style_slice(src, LineKind::Normal, &s);
677            assert_eq!(rendered(&line), src, "{src} stays verbatim");
678            assert!(
679                line.spans
680                    .iter()
681                    .all(|sp| sp.style != s.italic && sp.style != s.bold),
682                "{src} gets no emphasis styling"
683            );
684        }
685    }
686
687    #[test]
688    fn word_boundary_underscores_still_emphasize() {
689        let s = styles();
690        // `_word_` at word boundaries italicizes and hides its sigils.
691        let line = style_slice("_word_", LineKind::Normal, &s);
692        assert_eq!(rendered(&line), "word");
693        let italic: String = line
694            .spans
695            .iter()
696            .filter(|sp| sp.style == s.italic)
697            .map(|sp| sp.content.as_ref())
698            .collect();
699        assert_eq!(italic, "word");
700
701        // `__dunder__` at word boundaries bolds and hides its sigils.
702        let line = style_slice("__dunder__", LineKind::Normal, &s);
703        assert_eq!(rendered(&line), "dunder");
704        let bold: String = line
705            .spans
706            .iter()
707            .filter(|sp| sp.style == s.bold)
708            .map(|sp| sp.content.as_ref())
709            .collect();
710        assert_eq!(bold, "dunder");
711    }
712
713    #[test]
714    fn mixed_line_keeps_snake_case_and_styles_real_emphasis_with_correct_map() {
715        let s = styles();
716        let raw = "snake_case and _real_ emphasis";
717        let (line, map) = style_slice_mapped(raw, LineKind::Normal, &s);
718        // The intraword `_` in snake_case stay; only `_real_`'s sigils are hidden.
719        assert_eq!(rendered(&line), "snake_case and real emphasis");
720        let italic: String = line
721            .spans
722            .iter()
723            .filter(|sp| sp.style == s.italic)
724            .map(|sp| sp.content.as_ref())
725            .collect();
726        assert_eq!(italic, "real", "only the boundary emphasis is styled");
727
728        // The column map still points every rendered char at its source byte:
729        // reconstructing the rendered text via the map reproduces it exactly.
730        let rebuilt: String = map
731            .iter()
732            .map(|&b| raw[b..].chars().next().unwrap())
733            .collect();
734        assert_eq!(rebuilt, "snake_case and real emphasis");
735        // The rendered `real` maps back to the source `real` (byte 16), not the
736        // hidden `_` at byte 15.
737        let real_col = rendered(&line).find("real").unwrap();
738        assert_eq!(&raw[map[real_col]..map[real_col] + 4], "real");
739    }
740}