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}