use ratatui::{
style::{Color, Modifier, Style},
text::{Line, Span},
};
use std::sync::LazyLock;
fn header_style() -> Style {
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD)
}
fn bullet_style() -> Style {
Style::default().fg(Color::Magenta)
}
fn bold_style() -> Style {
Style::default().add_modifier(Modifier::BOLD)
}
fn code_style() -> Style {
Style::default()
.fg(Color::Yellow)
.bg(Color::Rgb(50, 40, 25))
}
fn url_style() -> Style {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::UNDERLINED)
}
static INLINE_RE: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"\*\*(.+?)\*\*|`([^`]+)`|(https?://[^\s)\]>]+)").unwrap());
fn parse_inline_spans(text: &str) -> Vec<Span<'static>> {
let mut spans = Vec::new();
let mut last_end = 0;
for cap in INLINE_RE.captures_iter(text) {
let whole = cap.get(0).unwrap();
if whole.start() > last_end {
spans.push(Span::raw(text[last_end..whole.start()].to_owned()));
}
if let Some(bold) = cap.get(1) {
spans.push(Span::styled(bold.as_str().to_owned(), bold_style()));
} else if let Some(code) = cap.get(2) {
spans.push(Span::styled(code.as_str().to_owned(), code_style()));
} else if let Some(url) = cap.get(3) {
spans.push(Span::styled(url.as_str().to_owned(), url_style()));
}
last_end = whole.end();
}
if last_end < text.len() {
spans.push(Span::raw(text[last_end..].to_owned()));
}
if spans.is_empty() {
spans.push(Span::raw(text.to_owned()));
}
spans
}
fn highlight_style() -> Style {
Style::default()
.fg(Color::Black)
.bg(Color::Yellow)
.add_modifier(Modifier::BOLD)
}
fn highlight_spans(spans: Vec<Span<'static>>, query: &str) -> Vec<Span<'static>> {
if query.is_empty() {
return spans;
}
let query_lower = query.to_lowercase();
let mut result = Vec::new();
for span in spans {
let text = span.content.as_ref();
let text_lower = text.to_lowercase();
let mut last = 0;
for (start, _) in text_lower.match_indices(&query_lower) {
if start > last {
result.push(Span::styled(text[last..start].to_owned(), span.style));
}
result.push(Span::styled(
text[start..start + query.len()].to_owned(),
highlight_style(),
));
last = start + query.len();
}
if last < text.len() {
result.push(Span::styled(text[last..].to_owned(), span.style));
} else if last == 0 {
result.push(span);
}
}
result
}
pub fn changelog_to_lines(body: &str) -> Vec<Line<'static>> {
let mut lines = Vec::new();
for line in body.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
lines.push(Line::from(""));
continue;
}
if let Some(rest) = trimmed
.strip_prefix("### ")
.or_else(|| trimmed.strip_prefix("## "))
{
lines.push(Line::from(Span::styled(rest.to_owned(), header_style())));
continue;
}
if trimmed.starts_with('#') && trimmed.chars().nth(1) == Some(' ') {
continue;
}
if let Some(rest) = trimmed
.strip_prefix("- ")
.or_else(|| trimmed.strip_prefix("* "))
.or_else(|| trimmed.strip_prefix("+ "))
{
let mut spans = vec![Span::styled(" \u{2022} ", bullet_style())];
spans.extend(parse_inline_spans(rest));
lines.push(Line::from(spans));
continue;
}
let mut spans = vec![Span::raw(" ".to_owned())];
spans.extend(parse_inline_spans(trimmed));
lines.push(Line::from(spans));
}
lines
}
pub fn changelog_to_lines_highlighted(body: &str, query: &str) -> Vec<Line<'static>> {
changelog_to_lines(body)
.into_iter()
.map(|line| {
let spans: Vec<Span<'static>> = line.spans;
Line::from(highlight_spans(spans, query))
})
.collect()
}
pub fn line_contains_match(line: &Line, query: &str) -> bool {
if query.is_empty() {
return false;
}
let query_lower = query.to_lowercase();
line.spans
.iter()
.any(|span| span.content.to_lowercase().contains(&query_lower))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn header_is_magenta_bold() {
let lines = changelog_to_lines("## Bug Fixes");
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].spans.len(), 1);
assert_eq!(lines[0].spans[0].content, "Bug Fixes");
assert_eq!(lines[0].spans[0].style, header_style());
}
#[test]
fn h3_header_is_magenta_bold() {
let lines = changelog_to_lines("### New Features");
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].spans[0].content, "New Features");
assert_eq!(lines[0].spans[0].style, header_style());
}
#[test]
fn h1_header_is_skipped() {
let lines = changelog_to_lines("# What's Changed");
assert!(lines.is_empty());
}
#[test]
fn bullet_with_inline_code() {
let lines = changelog_to_lines("- Fixed `crash` on startup");
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].spans[0].content, " \u{2022} ");
assert_eq!(lines[0].spans[0].style, bullet_style());
assert_eq!(lines[0].spans[1].content, "Fixed ");
assert_eq!(lines[0].spans[2].content, "crash");
assert_eq!(lines[0].spans[2].style, code_style());
assert_eq!(lines[0].spans[3].content, " on startup");
}
#[test]
fn bold_text() {
let lines = changelog_to_lines("- **Full Changelog**: see repo");
assert_eq!(lines[0].spans[1].content, "Full Changelog");
assert_eq!(lines[0].spans[1].style, bold_style());
}
#[test]
fn url_is_cyan_underlined() {
let lines = changelog_to_lines("- See https://github.com/foo/bar");
let url_span = lines[0]
.spans
.iter()
.find(|s| s.content.starts_with("https://"))
.unwrap();
assert_eq!(url_span.style, url_style());
}
#[test]
fn blank_lines_preserved() {
let lines = changelog_to_lines("## Header\n\n- item");
assert_eq!(lines.len(), 3);
assert_eq!(lines[1].spans.len(), 0); }
#[test]
fn plain_text_indented() {
let lines = changelog_to_lines("Some plain text");
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].spans[0].content, " ");
assert_eq!(lines[0].spans[1].content, "Some plain text");
}
#[test]
fn highlight_splits_spans() {
let lines = changelog_to_lines_highlighted("- Fixed crash on startup", "crash");
let spans = &lines[0].spans;
let highlighted = spans.iter().find(|s| s.content == "crash").unwrap();
assert_eq!(highlighted.style, highlight_style());
}
#[test]
fn highlight_case_insensitive() {
let lines = changelog_to_lines_highlighted("- Added STREAMING support", "streaming");
let highlighted = lines[0]
.spans
.iter()
.find(|s| s.content == "STREAMING")
.unwrap();
assert_eq!(highlighted.style, highlight_style());
}
#[test]
fn highlight_preserves_no_match_lines() {
let lines = changelog_to_lines_highlighted("## Bug Fixes\n- Fixed crash", "crash");
assert_eq!(lines[0].spans[0].style, header_style());
assert!(lines[1].spans.iter().any(|s| s.style == highlight_style()));
}
#[test]
fn line_contains_match_works() {
let lines = changelog_to_lines("- Fixed crash on startup");
assert!(line_contains_match(&lines[0], "crash"));
assert!(!line_contains_match(&lines[0], "missing"));
}
#[test]
fn mixed_inline_formatting() {
let lines = changelog_to_lines("- Added **new** `feature` at https://example.com");
let spans = &lines[0].spans;
assert_eq!(spans[0].content, " \u{2022} "); assert_eq!(spans[1].content, "Added "); assert_eq!(spans[2].content, "new"); assert_eq!(spans[2].style, bold_style());
assert_eq!(spans[3].content, " "); assert_eq!(spans[4].content, "feature"); assert_eq!(spans[4].style, code_style());
assert_eq!(spans[5].content, " at "); assert_eq!(spans[6].content, "https://example.com"); assert_eq!(spans[6].style, url_style());
}
}