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