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}