kimun-notes 0.21.0

A terminal-based notes application
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
//! A lean, line-level markdown styler shared by surfaces that want to *style*
//! markdown without editing it (currently the Ask workspace's answer body).
//!
//! Design constraints that shape it:
//!
//! - **Emphasis sigils are hidden; structural markers stay.** Balanced
//!   `**`/`__` (bold) and `*`/`_` (italic) delimiters are dropped from the
//!   rendered text (the run between them is styled instead), matching what a
//!   reader expects. Everything else stays visible: `#` headings, `>` quotes,
//!   fences, list markers, and citation `[n]` markers. Because hiding breaks
//!   the old 1:1 byte↔column identity, [`style_slice_mapped`] emits, alongside
//!   the styled line, a **column map** (`rendered char index → source byte
//!   offset`) so callers can still hit-test a click back to the right source
//!   byte. (This is why we don't reuse the editor's `ParsedBuffer`, which fully
//!   re-lays-out the visual line.)
//! - **Only same-line, balanced pairs are hidden.** A sigil is hidden only when
//!   an opener and a closer of the same kind appear in the *same wrapped slice*
//!   (the per-slice approximation — we never look across the wrap boundary). A
//!   lone `*` (an unmatched sigil, a bullet, arithmetic) stays visible and does
//!   not emphasize anything. Sigils inside inline code are literal.
//! - **Intraword `_` is not emphasis.** Following CommonMark, a `_`/`__` run may
//!   open only when the char before it is absent/non-alphanumeric and close only
//!   when the char after it is — so `snake_case` and `foo_bar_baz` identifiers
//!   render verbatim. `*`/`**` keep the laxer rule (intraword `*` is legal).
//! - **Citations are the citations module's job.** `[n]` markers are found
//!   only through [`crate::ask::citations::scan`]; we merely *style* the ranges
//!   it reports. Code (fenced blocks and inline spans) is never citation-styled.
//!
//! The unit of work is one *logical* source line, split across two layers:
//!
//! - **Block identity is not ours to decide.** [`classify_block_kinds`] hands
//!   the whole answer to the editor's buffer-aware markdown model
//!   ([`crate::components::text_editor::markdown::ParsedBuffer`], the real
//!   pulldown-cmark classifier) and maps each row's result onto a [`LineKind`].
//!   There is exactly one opinion about what a line *is*, and it is the
//!   editor's — so answers and the note editor never disagree, and cross-line
//!   constructs the model resolves natively (unclosed fences, setext
//!   underlines, lazily-continued blockquotes) come along for free.
//! - **Inline styling stays here.** [`style_slice_mapped`] styles one wrapped
//!   visual slice of a line (given its [`LineKind`]) and returns its column
//!   map — this is the answer-domain layer (emphasis hiding, citation styling,
//!   `col_map`) the editor's fully-relaid `ParsedBuffer` render can't provide.

use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};

use crate::ask::citations;
use crate::settings::themes::Theme;

/// The block role of one logical source line.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum LineKind {
    /// A code line — a fenced-code delimiter (```` ``` ````/`~~~`), a line
    /// inside a fence, or an indented (4-space/tab) code block: styled as code
    /// verbatim, with no inline markdown or citation restyling.
    Code,
    /// An ATX heading (`#`..`######`).
    Heading,
    /// A blockquote line (`>`).
    Quote,
    /// Paragraph text or a list item — inline styling (bold/italic/inline code)
    /// and citations apply.
    Normal,
}

/// The semantic styles the answer body renders with, resolved from the theme
/// once per render and reused across every line.
#[derive(Clone, Copy)]
pub struct MdStyles {
    pub base: Style,
    pub heading: Style,
    pub quote: Style,
    pub code: Style,
    pub bold: Style,
    pub italic: Style,
    pub citation: Style,
}

impl MdStyles {
    /// Build from the theme, mirroring the editor's markdown color conventions
    /// (`text_editor::markdown::span_style`): headings bright+bold, inline/code
    /// aqua on a soft background, bold accent+bold, italic secondary+italic,
    /// blockquote secondary. Citations keep the answer's accent marker color.
    pub fn from_theme(theme: &Theme) -> Self {
        Self {
            base: Style::default().fg(theme.fg.to_ratatui()),
            heading: Style::default()
                .fg(theme.fg_bright.to_ratatui())
                .add_modifier(Modifier::BOLD),
            quote: Style::default().fg(theme.fg_secondary.to_ratatui()),
            code: Style::default()
                .fg(theme.aqua.to_ratatui())
                .bg(theme.bg_soft.to_ratatui()),
            bold: Style::default()
                .fg(theme.accent.to_ratatui())
                .add_modifier(Modifier::BOLD),
            italic: Style::default()
                .fg(theme.fg_secondary.to_ratatui())
                .add_modifier(Modifier::ITALIC),
            citation: Style::default().fg(theme.accent.to_ratatui()),
        }
    }
}

/// Classify the block role of every logical (newline-free) source `line` in
/// `lines`, in order, by delegating wholesale to the editor's markdown model
/// ([`ParsedBuffer::parse`]) — so there is a single, buffer-aware opinion about
/// block identity shared with the note editor.
///
/// Because the model sees the whole answer at once, cross-line constructs the
/// old per-line scanner could not are handled natively:
///
/// - an **unclosed fence** keeps every following line `Code` to end-of-answer;
/// - a **setext underline** (`Title` then `====`/`----`) tags *both* rows as a
///   heading. Pulldown spans the heading element across the underline and
///   resets the *title* row's coarse `LineConstructKind` back to `Plain`, so we
///   read the heading off each row's per-line `elements`, not the coarse kind;
/// - a **lazy blockquote continuation** (a bare line folded into a preceding
///   `>` quote) reports the quote's depth via [`ParsedLine::blockquote_depth`]
///   and so styles as `Quote`, matching what the editor renders.
///
/// Code wins over the heading/quote signals: inside a fenced or indented code
/// block a `>` or `#` is literal, so the coarse code kinds take precedence.
pub fn classify_block_kinds(lines: &[&str]) -> Vec<LineKind> {
    use crate::components::text_editor::markdown::{ElementKind, ParsedBuffer};
    use crate::components::text_editor::parse_incremental::LineConstructKind;

    let owned: Vec<String> = lines.iter().map(|s| s.to_string()).collect();
    let parsed = ParsedBuffer::parse(&owned);
    parsed
        .lines
        .iter()
        .zip(parsed.kinds.iter())
        .map(|(parsed_line, &kind)| {
            if matches!(
                kind,
                LineConstructKind::FenceMarker
                    | LineConstructKind::FenceContent
                    | LineConstructKind::IndentedCode
            ) {
                return LineKind::Code;
            }
            let is_heading = matches!(kind, LineConstructKind::SetextUnderline)
                || parsed_line.elements.iter().any(|e| {
                    matches!(
                        e.kind,
                        ElementKind::HeadingH1 | ElementKind::HeadingH2 | ElementKind::HeadingH3
                    )
                });
            if is_heading {
                LineKind::Heading
            } else if parsed_line.blockquote_depth().is_some() {
                LineKind::Quote
            } else {
                LineKind::Normal
            }
        })
        .collect()
}

/// Style one wrapped visual `slice` of a logical line whose block role is
/// `kind`, returning the styled [`Line`] and its **column map**: `map[k]` is
/// the source byte offset (into `slice`) of the `k`-th *rendered* character.
///
/// For every kind except `Normal` nothing is hidden, so the map is the identity
/// over the slice's chars. For `Normal`, balanced emphasis sigils are dropped
/// (see the module doc), so `map` skips their bytes — a caller resolving a
/// rendered column back to a source byte walks `map`.
///
/// `slice` must be the exact source text shown on the row (structural markers
/// included).
pub fn style_slice_mapped(
    slice: &str,
    kind: LineKind,
    styles: &MdStyles,
) -> (Line<'static>, Vec<usize>) {
    match kind {
        LineKind::Code => whole_slice(slice, styles.code),
        LineKind::Heading => whole_slice(slice, styles.heading),
        LineKind::Quote => whole_slice(slice, styles.quote),
        LineKind::Normal => inline_spans(slice, styles),
    }
}

/// Style `slice` as a single verbatim span (no hiding) with the identity map.
fn whole_slice(slice: &str, style: Style) -> (Line<'static>, Vec<usize>) {
    let map: Vec<usize> = slice.char_indices().map(|(i, _)| i).collect();
    (Line::from(Span::styled(slice.to_string(), style)), map)
}

/// Split a `Normal` slice into styled spans — dropping balanced emphasis
/// sigils and returning the column map alongside. Inline code (`` `…` ``) is
/// verbatim; citation `[n]` ranges (from [`citations::scan`]) win over
/// emphasis; inline code wins over everything and is never citation-styled.
/// The concatenation of the returned spans equals `slice` with exactly the
/// hidden sigil pairs removed.
fn inline_spans(slice: &str, styles: &MdStyles) -> (Line<'static>, Vec<usize>) {
    let chars: Vec<(usize, char)> = slice.char_indices().collect();
    let code_mask = code_mask(&chars);
    let (hidden, bold, italic) = analyze_emphasis(&chars, &code_mask);

    let cites = citations::scan(slice);
    let is_cited = |i: usize| cites.iter().any(|c| c.range.contains(&i));

    let mut spans: Vec<Span<'static>> = Vec::new();
    let mut buf = String::new();
    let mut buf_style = styles.base;
    let mut map: Vec<usize> = Vec::new();

    for (k, &(i, ch)) in chars.iter().enumerate() {
        if hidden[k] {
            continue; // a balanced sigil — dropped from the rendered text.
        }
        let style = if code_mask[k] {
            styles.code
        } else if is_cited(i) {
            styles.citation
        } else if bold[k] {
            styles.bold
        } else if italic[k] {
            styles.italic
        } else {
            styles.base
        };
        if style != buf_style && !buf.is_empty() {
            spans.push(Span::styled(std::mem::take(&mut buf), buf_style));
        }
        buf_style = style;
        buf.push(ch);
        map.push(i);
    }
    if !buf.is_empty() {
        spans.push(Span::styled(buf, buf_style));
    }
    if spans.is_empty() {
        spans.push(Span::styled(String::new(), styles.base));
    }
    (Line::from(spans), map)
}

/// Per-char mask marking inline-code spans (backticks included). A backtick
/// opens a span; every char up to and including the next backtick is code. An
/// unclosed span runs to the slice end (matching how a terminal would show it).
fn code_mask(chars: &[(usize, char)]) -> Vec<bool> {
    let mut mask = vec![false; chars.len()];
    let mut in_code = false;
    for (k, &(_, ch)) in chars.iter().enumerate() {
        if in_code {
            mask[k] = true;
            if ch == '`' {
                in_code = false;
            }
        } else if ch == '`' {
            in_code = true;
            mask[k] = true;
        }
    }
    mask
}

/// The four emphasis delimiter kinds, each paired independently.
#[derive(Clone, Copy, PartialEq, Eq)]
enum Emph {
    Star,        // `*…*`  → italic
    Under,       // `_…_`  → italic
    DoubleStar,  // `**…**` → bold
    DoubleUnder, // `__…__` → bold
}

struct Delim {
    /// First char index of the delimiter.
    k: usize,
    /// Number of chars (1 or 2).
    len: usize,
    kind: Emph,
    /// Whether this run may *open* emphasis. Simplified CommonMark
    /// left-flanking: a `*`/`**` run may open only when the char *after* it is
    /// present and non-whitespace (so `width * height` never opens); a `_`/`__`
    /// run additionally requires the char *before* it to be absent or
    /// non-alphanumeric (the intraword-underscore rule).
    can_open: bool,
    /// Whether this run may *close* emphasis. Simplified CommonMark
    /// right-flanking: a `*`/`**` run may close only when the char *before* it
    /// is present and non-whitespace; a `_`/`__` run additionally requires the
    /// char *after* it to be absent or non-alphanumeric.
    can_close: bool,
}

/// Decide, per char, which emphasis sigils to *hide* and which chars fall under
/// bold / italic styling. Delimiters are found outside inline code and paired
/// within each kind with a stack (nearest matching opener). Every run obeys
/// simplified CommonMark flanking — it may open only when the following char is
/// non-whitespace and close only when the preceding char is — so `width *
/// height * depth` and `match *.rs and *.md` stay verbatim; `_`/`__`
/// additionally obey the intraword rule (a `_` run may only open when the char
/// before it is absent/non-alphanumeric and only close when the char after it
/// is), so `snake_case` identifiers are never mangled. An unmatched delimiter stays
/// visible and styles nothing. This is the per-slice approximation — we never
/// pair across the wrap boundary.
fn analyze_emphasis(
    chars: &[(usize, char)],
    code_mask: &[bool],
) -> (Vec<bool>, Vec<bool>, Vec<bool>) {
    let n = chars.len();
    let mut hidden = vec![false; n];
    let mut bold = vec![false; n];
    let mut italic = vec![false; n];

    // Collect delimiter tokens (greedy: `**`/`__` before `*`/`_`), recording
    // each run's open/close capability from its flanking chars.
    let mut delims: Vec<Delim> = Vec::new();
    let mut k = 0;
    while k < n {
        if code_mask[k] {
            k += 1;
            continue;
        }
        let ch = chars[k].1;
        let next_same = k + 1 < n && !code_mask[k + 1] && chars[k + 1].1 == ch;
        let (len, kind) = match ch {
            '*' if next_same => (2, Emph::DoubleStar),
            '_' if next_same => (2, Emph::DoubleUnder),
            '*' => (1, Emph::Star),
            '_' => (1, Emph::Under),
            _ => {
                k += 1;
                continue;
            }
        };
        let is_under = matches!(kind, Emph::Under | Emph::DoubleUnder);
        let before = (k > 0).then(|| chars[k - 1].1);
        let after = chars.get(k + len).map(|&(_, c)| c);
        // Alphanumeric-boundary rule (underscore only) and whitespace-flanking
        // rule (all kinds): a run left-flanks (can open) when what follows is
        // non-whitespace, and right-flanks (can close) when what precedes is.
        let alnum_free = |c: Option<char>| c.is_none_or(|c| !c.is_alphanumeric());
        let non_ws = |c: Option<char>| c.is_some_and(|c| !c.is_whitespace());
        let can_open = non_ws(after) && (!is_under || alnum_free(before));
        let can_close = non_ws(before) && (!is_under || alnum_free(after));
        delims.push(Delim {
            k,
            len,
            kind,
            can_open,
            can_close,
        });
        k += len;
    }

    // Pair each kind with a stack: a closer binds to the nearest open delimiter
    // of the same kind. Matched pairs hide their sigils and style the run.
    for kind in [Emph::Star, Emph::Under, Emph::DoubleStar, Emph::DoubleUnder] {
        let is_bold = matches!(kind, Emph::DoubleStar | Emph::DoubleUnder);
        let mut open_stack: Vec<usize> = Vec::new();
        for (di, d) in delims.iter().enumerate() {
            if d.kind != kind {
                continue;
            }
            if d.can_close && !open_stack.is_empty() {
                let oi = open_stack.pop().unwrap();
                let (open_k, open_len) = (delims[oi].k, delims[oi].len);
                let close_k = d.k;
                for slot in &mut hidden[open_k..open_k + open_len] {
                    *slot = true;
                }
                for slot in &mut hidden[close_k..close_k + d.len] {
                    *slot = true;
                }
                let run = &mut (if is_bold { &mut bold } else { &mut italic })
                    [open_k + open_len..close_k];
                for slot in run {
                    *slot = true;
                }
            } else if d.can_open {
                open_stack.push(di);
            }
        }
    }
    (hidden, bold, italic)
}

#[cfg(test)]
mod tests {
    use super::*;

    fn styles() -> MdStyles {
        MdStyles::from_theme(&Theme::default())
    }

    /// The styled spans, concatenated — the rendered text of a line.
    fn rendered(line: &Line<'static>) -> String {
        line.spans.iter().map(|s| s.content.as_ref()).collect()
    }

    /// Just the styled line (the common case; the map is asserted separately).
    fn style_slice(slice: &str, kind: LineKind, styles: &MdStyles) -> Line<'static> {
        style_slice_mapped(slice, kind, styles).0
    }

    /// Classify a single context-free line (its block role does not depend on
    /// neighbours) through the buffer classifier.
    fn kind_of(line: &str) -> LineKind {
        classify_block_kinds(&[line])[0]
    }

    #[test]
    fn classify_toggles_fenced_code_blocks() {
        // Opener, body, closer are all Code; the line after the fence is out.
        assert_eq!(
            classify_block_kinds(&["```rust", "let x = 1;", "```", "after"]),
            vec![
                LineKind::Code,
                LineKind::Code,
                LineKind::Code,
                LineKind::Normal
            ],
        );
    }

    #[test]
    fn unclosed_fence_keeps_trailing_lines_code() {
        // An unterminated fence runs to the end of the answer — fence tracking
        // behaviour is unchanged from the old scanner.
        assert_eq!(
            classify_block_kinds(&["```", "still code", "more code"]),
            vec![LineKind::Code, LineKind::Code, LineKind::Code],
        );
    }

    #[test]
    fn classify_labels_headings_and_quotes() {
        assert_eq!(kind_of("# Title"), LineKind::Heading);
        assert_eq!(kind_of("###### h6"), LineKind::Heading);
        assert_eq!(kind_of("####### too many"), LineKind::Normal);
        assert_eq!(kind_of("#nospace"), LineKind::Normal);
        assert_eq!(kind_of("> quoted"), LineKind::Quote);
        assert_eq!(kind_of("plain text"), LineKind::Normal);
    }

    #[test]
    fn setext_underline_styles_title_and_rule_as_heading() {
        // `Title` + `====` is a setext H1: the editor model spans the heading
        // across BOTH rows, so both style as a heading even though the title
        // row carries no `#`. (`----` is a setext H2 the same way.)
        assert_eq!(
            classify_block_kinds(&["Title", "===="]),
            vec![LineKind::Heading, LineKind::Heading],
        );
        assert_eq!(
            classify_block_kinds(&["Title", "----"]),
            vec![LineKind::Heading, LineKind::Heading],
        );
    }

    #[test]
    fn lazy_blockquote_continuation_styles_as_quote() {
        // `> a` then a bare `b`: the editor model folds the bare line into the
        // quote (CommonMark §5.1 lazy continuation), so BOTH style as Quote —
        // the model is the truth and it says depth 1 on the continuation.
        assert_eq!(
            classify_block_kinds(&["> a", "b"]),
            vec![LineKind::Quote, LineKind::Quote],
        );
    }

    #[test]
    fn blank_line_ends_the_blockquote() {
        // A blank row ends the quote, so the line after it is not a lazy
        // continuation (CommonMark §5.1 — pulldown closes the quote at the blank).
        assert_eq!(
            classify_block_kinds(&["> a", "", "b"]),
            vec![LineKind::Quote, LineKind::Normal, LineKind::Normal],
        );
    }

    #[test]
    fn four_space_indent_is_code_per_editor_model() {
        // The editor model treats a 4-space indent as an indented code block.
        // The answer renderer now agrees (the old per-line scanner called this
        // Normal) — a deliberate divergence, encoding the editor model's opinion.
        assert_eq!(
            classify_block_kinds(&["    let x = 1;"]),
            vec![LineKind::Code]
        );
    }

    #[test]
    fn code_slice_is_never_citation_styled() {
        let s = styles();
        // A `[1]` sitting inside a code line keeps the code style — no accent.
        let line = style_slice("let n = arr[1];", LineKind::Code, &s);
        assert_eq!(line.spans.len(), 1, "code renders as one verbatim span");
        assert_eq!(line.spans[0].style, s.code);
        assert!(line.spans[0].style != s.citation);
        assert_eq!(rendered(&line), "let n = arr[1];");
    }

    #[test]
    fn heading_slice_gets_heading_styling() {
        let s = styles();
        let line = style_slice("## Overview", LineKind::Heading, &s);
        assert_eq!(line.spans[0].style, s.heading);
        assert_eq!(rendered(&line), "## Overview");
    }

    #[test]
    fn prose_citation_gets_citation_style_and_preserves_bytes() {
        let s = styles();
        let line = style_slice("See [1] and [2].", LineKind::Normal, &s);
        assert_eq!(rendered(&line), "See [1] and [2].", "1:1 with the source");
        // The `[1]`/`[2]` markers carry the citation style.
        let cited: String = line
            .spans
            .iter()
            .filter(|sp| sp.style == s.citation)
            .map(|sp| sp.content.as_ref())
            .collect();
        assert_eq!(cited, "[1][2]");
    }

    #[test]
    fn bold_sigils_are_hidden_and_the_run_is_styled() {
        let s = styles();
        let line = style_slice("a **b** `c` d", LineKind::Normal, &s);
        // The `**` pair is dropped; the code span's backticks stay literal.
        assert_eq!(rendered(&line), "a b `c` d");
        let bold: String = line
            .spans
            .iter()
            .filter(|sp| sp.style == s.bold)
            .map(|sp| sp.content.as_ref())
            .collect();
        assert_eq!(bold, "b", "only the run between the sigils is bold");
        assert!(
            line.spans.iter().any(|sp| sp.style == s.code),
            "inline code run is styled"
        );
    }

    #[test]
    fn italic_sigils_are_hidden_for_both_star_and_underscore() {
        let s = styles();
        for (src, want) in [
            ("an *em* word", "an em word"),
            ("an _em_ word", "an em word"),
        ] {
            let line = style_slice(src, LineKind::Normal, &s);
            assert_eq!(rendered(&line), want);
            let italic: String = line
                .spans
                .iter()
                .filter(|sp| sp.style == s.italic)
                .map(|sp| sp.content.as_ref())
                .collect();
            assert_eq!(italic, "em");
        }
    }

    #[test]
    fn a_lone_sigil_stays_visible_and_emphasizes_nothing() {
        let s = styles();
        // An unbalanced `*` (a stray bullet / arithmetic) must not be eaten and
        // must not italicize the tail of the line.
        let line = style_slice("2 * 3 = 6 and rest", LineKind::Normal, &s);
        assert_eq!(rendered(&line), "2 * 3 = 6 and rest", "lone sigil kept");
        assert!(
            line.spans.iter().all(|sp| sp.style != s.italic),
            "no run is italicized by an unmatched sigil"
        );
    }

    #[test]
    fn space_flanked_stars_are_not_emphasis() {
        let s = styles();
        // Whitespace on the inner side means neither run can open/close, so the
        // asterisks stay literal and nothing between them is italicized.
        for src in ["width * height * depth = volume", "2 * 3 * 4"] {
            let line = style_slice(src, LineKind::Normal, &s);
            assert_eq!(rendered(&line), src, "{src} stays verbatim");
            assert!(
                line.spans
                    .iter()
                    .all(|sp| sp.style != s.italic && sp.style != s.bold),
                "{src} gets no emphasis styling"
            );
        }
    }

    #[test]
    fn glob_stars_stay_visible_and_emphasize_nothing() {
        let s = styles();
        // `*.rs`/`*.md`: each `*` can open (followed by `.`) but neither can
        // close (preceded by a space), so no pair forms — both stars survive.
        let line = style_slice("match *.rs and *.md files", LineKind::Normal, &s);
        assert_eq!(rendered(&line), "match *.rs and *.md files");
        assert!(
            line.spans
                .iter()
                .all(|sp| sp.style != s.italic && sp.style != s.bold),
            "glob stars italicize nothing"
        );
    }

    #[test]
    fn real_star_emphasis_still_works() {
        let s = styles();
        // `*real*` still italicizes and `**bold**` still bolds — the flanking
        // rule only rejects whitespace-adjacent runs.
        let line = style_slice("*real*", LineKind::Normal, &s);
        assert_eq!(rendered(&line), "real");
        let italic: String = line
            .spans
            .iter()
            .filter(|sp| sp.style == s.italic)
            .map(|sp| sp.content.as_ref())
            .collect();
        assert_eq!(italic, "real");

        let line = style_slice("**bold**", LineKind::Normal, &s);
        assert_eq!(rendered(&line), "bold");
        let bold: String = line
            .spans
            .iter()
            .filter(|sp| sp.style == s.bold)
            .map(|sp| sp.content.as_ref())
            .collect();
        assert_eq!(bold, "bold");
    }

    #[test]
    fn emphasis_inside_a_code_span_stays_literal() {
        let s = styles();
        // The `*x*` lives inside inline code — its asterisks are verbatim.
        let line = style_slice("call `*x*` now", LineKind::Normal, &s);
        assert_eq!(rendered(&line), "call `*x*` now", "code is verbatim");
        assert!(
            line.spans.iter().all(|sp| sp.style != s.italic),
            "no italic from sigils inside code"
        );
    }

    #[test]
    fn rendered_text_is_raw_minus_exactly_the_hidden_sigil_pairs() {
        let s = styles();
        let raw = "**bold** and *it* and lone * kept `*z*`";
        let line = style_slice(raw, LineKind::Normal, &s);
        // Two balanced pairs (`**`+`**` and `*`+`*`) → 6 sigil bytes removed;
        // the lone `*` and the in-code `*x*` survive.
        let expected = "bold and it and lone * kept `*z*`";
        assert_eq!(rendered(&line), expected);
    }

    #[test]
    fn column_map_skips_hidden_sigils_and_points_at_source_bytes() {
        let s = styles();
        let raw = "**b** [1]";
        let (line, map) = style_slice_mapped(raw, LineKind::Normal, &s);
        assert_eq!(rendered(&line), "b [1]");
        // Rendered chars: 'b'(raw 2) ' '(raw 5) '['(raw 6) '1'(raw 7) ']'(raw 8).
        assert_eq!(map, vec![2, 5, 6, 7, 8]);
    }

    #[test]
    fn non_normal_kinds_keep_the_identity_map() {
        let s = styles();
        let (_, map) = style_slice_mapped("## Head", LineKind::Heading, &s);
        assert_eq!(map, (0.."## Head".len()).collect::<Vec<_>>());
    }

    #[test]
    fn intraword_underscores_are_left_verbatim() {
        let s = styles();
        // snake_case identifiers must not be mangled: the `_` are intraword, so
        // they neither open nor close emphasis — kept literal, nothing styled.
        for src in ["foo_bar_baz", "some__thing__glued"] {
            let line = style_slice(src, LineKind::Normal, &s);
            assert_eq!(rendered(&line), src, "{src} stays verbatim");
            assert!(
                line.spans
                    .iter()
                    .all(|sp| sp.style != s.italic && sp.style != s.bold),
                "{src} gets no emphasis styling"
            );
        }
    }

    #[test]
    fn word_boundary_underscores_still_emphasize() {
        let s = styles();
        // `_word_` at word boundaries italicizes and hides its sigils.
        let line = style_slice("_word_", LineKind::Normal, &s);
        assert_eq!(rendered(&line), "word");
        let italic: String = line
            .spans
            .iter()
            .filter(|sp| sp.style == s.italic)
            .map(|sp| sp.content.as_ref())
            .collect();
        assert_eq!(italic, "word");

        // `__dunder__` at word boundaries bolds and hides its sigils.
        let line = style_slice("__dunder__", LineKind::Normal, &s);
        assert_eq!(rendered(&line), "dunder");
        let bold: String = line
            .spans
            .iter()
            .filter(|sp| sp.style == s.bold)
            .map(|sp| sp.content.as_ref())
            .collect();
        assert_eq!(bold, "dunder");
    }

    #[test]
    fn mixed_line_keeps_snake_case_and_styles_real_emphasis_with_correct_map() {
        let s = styles();
        let raw = "snake_case and _real_ emphasis";
        let (line, map) = style_slice_mapped(raw, LineKind::Normal, &s);
        // The intraword `_` in snake_case stay; only `_real_`'s sigils are hidden.
        assert_eq!(rendered(&line), "snake_case and real emphasis");
        let italic: String = line
            .spans
            .iter()
            .filter(|sp| sp.style == s.italic)
            .map(|sp| sp.content.as_ref())
            .collect();
        assert_eq!(italic, "real", "only the boundary emphasis is styled");

        // The column map still points every rendered char at its source byte:
        // reconstructing the rendered text via the map reproduces it exactly.
        let rebuilt: String = map
            .iter()
            .map(|&b| raw[b..].chars().next().unwrap())
            .collect();
        assert_eq!(rebuilt, "snake_case and real emphasis");
        // The rendered `real` maps back to the source `real` (byte 16), not the
        // hidden `_` at byte 15.
        let real_col = rendered(&line).find("real").unwrap();
        assert_eq!(&raw[map[real_col]..map[real_col] + 4], "real");
    }
}