use ratatui::style::{Modifier, Style};
use super::highlight::StyledRun;
use super::theme::Theme;
pub fn highlight_markdown_lines(
source: &str,
theme: &Theme,
) -> Vec<Vec<StyledRun>> {
let mut state = LineState::Normal;
let lines_in: Vec<&str> = source.split('\n').collect();
let mut out: Vec<Vec<StyledRun>> = Vec::with_capacity(lines_in.len());
for line in lines_in {
let (tokens, next_state) = tokenize_line(line, state, theme);
out.push(tokens);
state = next_state;
}
if out.is_empty() {
out.push(Vec::new());
}
out
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LineState {
Normal,
InFence,
}
fn tokenize_line(
line: &str,
enter: LineState,
theme: &Theme,
) -> (Vec<StyledRun>, LineState) {
let chars: Vec<char> = line.chars().collect();
let n = chars.len();
if enter == LineState::InFence {
let trimmed = line.trim_start();
if trimmed.starts_with("```")
&& trimmed.trim_end_matches(|c: char| c == '`').is_empty()
{
return (
vec![StyledRun {
text: line.to_string(),
style: Style::default().fg(theme.syntax_string),
}],
LineState::Normal,
);
}
return (
vec![StyledRun {
text: line.to_string(),
style: Style::default().fg(theme.syntax_string),
}],
LineState::InFence,
);
}
let trimmed_left = line.trim_start();
if trimmed_left.starts_with("```") {
return (
vec![StyledRun {
text: line.to_string(),
style: Style::default().fg(theme.syntax_string),
}],
LineState::InFence,
);
}
if is_horizontal_rule(&chars) {
return (
vec![StyledRun {
text: line.to_string(),
style: Style::default().fg(theme.syntax_comment),
}],
LineState::Normal,
);
}
if let Some((hash_count, rest_start)) = atx_heading_prefix(&chars) {
let mut out: Vec<StyledRun> = Vec::new();
let prefix: String = chars[..rest_start].iter().collect();
out.push(StyledRun {
text: prefix,
style: Style::default()
.fg(theme.syntax_function)
.add_modifier(Modifier::BOLD),
});
let text: String = chars[rest_start..].iter().collect();
out.push(StyledRun {
text,
style: Style::default()
.fg(theme.syntax_function)
.add_modifier(Modifier::BOLD),
});
let _ = hash_count;
return (out, LineState::Normal);
}
if trimmed_left.starts_with('>') {
return (
vec![StyledRun {
text: line.to_string(),
style: Style::default()
.fg(theme.syntax_comment)
.add_modifier(Modifier::ITALIC),
}],
LineState::Normal,
);
}
let (marker_end, marker_present) = list_marker_end(&chars);
let mut out: Vec<StyledRun> = Vec::new();
let mut i = 0;
if marker_present {
let marker: String = chars[..marker_end].iter().collect();
out.push(StyledRun {
text: marker,
style: Style::default()
.fg(theme.syntax_keyword)
.add_modifier(Modifier::BOLD),
});
i = marker_end;
}
while i < n {
if chars[i] == '`' {
let start = i;
i += 1;
while i < n && chars[i] != '`' {
i += 1;
}
if i < n {
i += 1; }
out.push(StyledRun {
text: chars[start..i].iter().collect(),
style: Style::default().fg(theme.syntax_string),
});
continue;
}
if i + 1 < n
&& ((chars[i] == '*' && chars[i + 1] == '*')
|| (chars[i] == '_' && chars[i + 1] == '_'))
{
let opener = chars[i];
let start = i;
i += 2;
while i + 1 < n
&& !(chars[i] == opener && chars[i + 1] == opener)
{
i += 1;
}
if i + 1 < n {
i += 2; }
out.push(StyledRun {
text: chars[start..i].iter().collect(),
style: Style::default().add_modifier(Modifier::BOLD),
});
continue;
}
if (chars[i] == '*' || chars[i] == '_')
&& i + 1 < n
&& !chars[i + 1].is_whitespace()
{
let opener = chars[i];
let start = i;
i += 1;
while i < n && chars[i] != opener {
i += 1;
}
if i < n {
i += 1;
}
out.push(StyledRun {
text: chars[start..i].iter().collect(),
style: Style::default().add_modifier(Modifier::ITALIC),
});
continue;
}
if chars[i] == '[' {
let bracket_start = i;
let mut j = i + 1;
while j < n && chars[j] != ']' {
j += 1;
}
if j < n
&& j + 1 < n
&& chars[j + 1] == '('
{
let bracket_end = j;
let paren_start = j + 1;
let mut k = paren_start + 1;
while k < n && chars[k] != ')' {
k += 1;
}
if k < n {
out.push(StyledRun {
text: chars[bracket_start..=bracket_end].iter().collect(),
style: Style::default().fg(theme.syntax_function),
});
out.push(StyledRun {
text: chars[paren_start..=k].iter().collect(),
style: Style::default().fg(theme.syntax_string),
});
i = k + 1;
continue;
}
}
out.push(StyledRun {
text: "[".to_string(),
style: Style::default(),
});
i += 1;
continue;
}
let start = i;
while i < n
&& chars[i] != '`'
&& chars[i] != '*'
&& chars[i] != '_'
&& chars[i] != '['
{
i += 1;
}
if i > start {
out.push(StyledRun {
text: chars[start..i].iter().collect(),
style: Style::default(),
});
} else {
out.push(StyledRun {
text: chars[i].to_string(),
style: Style::default(),
});
i += 1;
}
}
(out, LineState::Normal)
}
fn atx_heading_prefix(chars: &[char]) -> Option<(usize, usize)> {
let mut i = 0;
while i < chars.len() && i < 3 && chars[i] == ' ' {
i += 1;
}
let hash_start = i;
while i < chars.len() && chars[i] == '#' {
i += 1;
}
let hash_count = i - hash_start;
if !(1..=6).contains(&hash_count) {
return None;
}
if i < chars.len() && !chars[i].is_whitespace() {
return None;
}
let after = if i < chars.len() && chars[i] == ' ' {
i + 1
} else {
i
};
Some((hash_count, after))
}
fn is_horizontal_rule(chars: &[char]) -> bool {
let mut found: Option<char> = None;
let mut count = 0;
for &c in chars {
if c == ' ' || c == '\t' {
continue;
}
if c == '-' || c == '*' || c == '_' {
if let Some(seen) = found {
if seen != c {
return false;
}
} else {
found = Some(c);
}
count += 1;
} else {
return false;
}
}
count >= 3
}
fn list_marker_end(chars: &[char]) -> (usize, bool) {
let mut i = 0;
while i < chars.len() && (chars[i] == ' ' || chars[i] == '\t') {
i += 1;
}
if i >= chars.len() {
return (0, false);
}
if matches!(chars[i], '-' | '*' | '+')
&& i + 1 < chars.len()
&& chars[i + 1] == ' '
{
return (i + 2, true);
}
let digit_start = i;
while i < chars.len() && chars[i].is_ascii_digit() {
i += 1;
}
if i > digit_start
&& i < chars.len()
&& (chars[i] == '.' || chars[i] == ')')
&& i + 1 < chars.len()
&& chars[i + 1] == ' '
{
return (i + 2, true);
}
(0, false)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::ThemeConfig;
fn theme() -> Theme {
Theme::from_config(&ThemeConfig::default())
}
#[test]
fn heading_levels_get_function_colour() {
for level in 1..=6 {
let hashes = "#".repeat(level);
let src = format!("{hashes} Title");
let lines = highlight_markdown_lines(&src, &theme());
assert_eq!(
lines[0][0].style.fg,
Some(theme().syntax_function),
"h{level} prefix colour",
);
}
}
#[test]
fn non_heading_not_styled_as_heading() {
let lines = highlight_markdown_lines("#nothash", &theme());
assert_ne!(lines[0][0].style.fg, Some(theme().syntax_function));
}
#[test]
fn fenced_code_block_spans_multiple_lines() {
let src = "```rust\nfn main() {}\n```\nafter";
let lines = highlight_markdown_lines(src, &theme());
assert_eq!(lines.len(), 4);
for i in 0..3 {
assert!(
lines[i].iter().all(|r| r.style.fg == Some(theme().syntax_string)),
"line {i} not all in code-block colour: {:?}",
lines[i],
);
}
assert!(
!lines[3].iter().any(|r| r.style.fg == Some(theme().syntax_string)),
"line after fence still styled as code",
);
}
#[test]
fn inline_code_gets_string_colour() {
let lines = highlight_markdown_lines("call `foo()` to test", &theme());
let code_run = lines[0]
.iter()
.find(|r| r.text.contains("foo()"))
.expect("inline code run");
assert_eq!(code_run.style.fg, Some(theme().syntax_string));
}
#[test]
fn bold_and_italic_get_modifiers() {
let lines = highlight_markdown_lines("here is **bold** and *italic*", &theme());
let bold = lines[0]
.iter()
.find(|r| r.text.contains("**bold**"))
.expect("bold run");
assert!(bold.style.add_modifier.contains(Modifier::BOLD));
let italic = lines[0]
.iter()
.find(|r| r.text.contains("*italic*") && !r.text.contains("**"))
.expect("italic run");
assert!(italic.style.add_modifier.contains(Modifier::ITALIC));
}
#[test]
fn link_text_and_url_styled_separately() {
let lines = highlight_markdown_lines("see [docs](https://example.com)", &theme());
let text = lines[0]
.iter()
.find(|r| r.text == "[docs]")
.expect("link text");
assert_eq!(text.style.fg, Some(theme().syntax_function));
let url = lines[0]
.iter()
.find(|r| r.text == "(https://example.com)")
.expect("link url");
assert_eq!(url.style.fg, Some(theme().syntax_string));
}
#[test]
fn blockquote_entire_line_dimmed() {
let lines = highlight_markdown_lines("> quoted text", &theme());
assert_eq!(lines[0].len(), 1);
assert_eq!(lines[0][0].style.fg, Some(theme().syntax_comment));
}
#[test]
fn horizontal_rule_recognised() {
for hr in ["---", "***", "___", " ---", "- - -"] {
let lines = highlight_markdown_lines(hr, &theme());
assert_eq!(lines[0].len(), 1, "{hr}");
assert_eq!(
lines[0][0].style.fg,
Some(theme().syntax_comment),
"{hr} not styled as HR",
);
}
}
#[test]
fn bullet_markers_styled_separately() {
let lines = highlight_markdown_lines("- item one", &theme());
let marker = lines[0]
.iter()
.find(|r| r.text == "- ")
.expect("bullet marker");
assert_eq!(marker.style.fg, Some(theme().syntax_keyword));
}
#[test]
fn numbered_list_marker_recognised() {
let lines = highlight_markdown_lines("1. first", &theme());
let marker = lines[0]
.iter()
.find(|r| r.text == "1. ")
.expect("numbered marker");
assert_eq!(marker.style.fg, Some(theme().syntax_keyword));
}
}