use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use crate::ask::citations;
use crate::settings::themes::Theme;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum LineKind {
Code,
Heading,
Quote,
Normal,
}
#[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 {
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()),
}
}
}
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()
}
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),
}
}
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)
}
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; }
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)
}
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
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Emph {
Star, Under, DoubleStar, DoubleUnder, }
struct Delim {
k: usize,
len: usize,
kind: Emph,
can_open: bool,
can_close: bool,
}
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];
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);
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;
}
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())
}
fn rendered(line: &Line<'static>) -> String {
line.spans.iter().map(|s| s.content.as_ref()).collect()
}
fn style_slice(slice: &str, kind: LineKind, styles: &MdStyles) -> Line<'static> {
style_slice_mapped(slice, kind, styles).0
}
fn kind_of(line: &str) -> LineKind {
classify_block_kinds(&[line])[0]
}
#[test]
fn classify_toggles_fenced_code_blocks() {
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() {
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() {
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() {
assert_eq!(
classify_block_kinds(&["> a", "b"]),
vec![LineKind::Quote, LineKind::Quote],
);
}
#[test]
fn blank_line_ends_the_blockquote() {
assert_eq!(
classify_block_kinds(&["> a", "", "b"]),
vec![LineKind::Quote, LineKind::Normal, LineKind::Normal],
);
}
#[test]
fn four_space_indent_is_code_per_editor_model() {
assert_eq!(
classify_block_kinds(&[" let x = 1;"]),
vec![LineKind::Code]
);
}
#[test]
fn code_slice_is_never_citation_styled() {
let s = styles();
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");
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);
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();
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();
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();
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();
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();
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);
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]");
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();
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();
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");
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);
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");
let rebuilt: String = map
.iter()
.map(|&b| raw[b..].chars().next().unwrap())
.collect();
assert_eq!(rebuilt, "snake_case and real emphasis");
let real_col = rendered(&line).find("real").unwrap();
assert_eq!(&raw[map[real_col]..map[real_col] + 4], "real");
}
}