use ratatui::style::{Color, Modifier, Style};
#[derive(Debug, Clone, PartialEq)]
pub enum Segment {
Normal(String),
Bold(String),
Italic(String),
Code(String),
Link { text: String, url: String },
}
pub fn parse_inline(line: &str) -> Vec<Segment> {
let mut segments: Vec<Segment> = Vec::new();
let mut chars = line.chars().peekable();
let mut normal = String::new();
while let Some(c) = chars.next() {
match c {
'`' => {
if !normal.is_empty() {
segments.push(Segment::Normal(normal.clone()));
normal.clear();
}
let mut code = String::new();
while let Some(&next) = chars.peek() {
if next == '`' {
chars.next(); break;
}
code.push(chars.next().unwrap());
}
segments.push(Segment::Code(code));
}
'*' if chars.peek() == Some(&'*') => {
chars.next(); if !normal.is_empty() {
segments.push(Segment::Normal(normal.clone()));
normal.clear();
}
let mut bold = String::new();
while let Some(&next) = chars.peek() {
if next == '*' {
chars.next(); if chars.peek() == Some(&'*') {
chars.next(); break;
} else {
bold.push('*');
continue;
}
}
bold.push(chars.next().unwrap());
}
segments.push(Segment::Bold(bold));
}
'[' => {
if !normal.is_empty() {
segments.push(Segment::Normal(normal.clone()));
normal.clear();
}
let mut text = String::new();
while let Some(&next) = chars.peek() {
if next == ']' {
chars.next();
break;
}
text.push(chars.next().unwrap());
}
if chars.peek() == Some(&'(') {
chars.next(); let mut url = String::new();
while let Some(&next) = chars.peek() {
if next == ')' {
chars.next();
break;
}
url.push(chars.next().unwrap());
}
segments.push(Segment::Link { text, url });
} else {
segments.push(Segment::Normal(format!("[{}", text)));
}
}
_ => normal.push(c),
}
}
if !normal.is_empty() {
segments.push(Segment::Normal(normal));
}
segments
}
#[derive(Debug, Clone, PartialEq)]
pub enum LineType {
Normal,
Heading(u8),
CodeFence { lang: String },
ListItem,
HorizontalRule,
}
pub fn detect_line_type(line: &str) -> LineType {
let trimmed = line.trim();
if trimmed.starts_with("```") {
let lang = trimmed.trim_start_matches('`').trim().to_string();
return LineType::CodeFence { lang };
}
if (trimmed.starts_with("---") || trimmed.starts_with("***") || trimmed.starts_with("___"))
&& trimmed.chars().all(|c| c == '-' || c == '*' || c == '_' || c == ' ')
&& trimmed.len() >= 3
{
return LineType::HorizontalRule;
}
let hashes = trimmed.chars().take_while(|c| *c == '#').count();
if hashes > 0 && hashes <= 6 {
let after = trimmed.chars().nth(hashes);
if after.is_none() || after == Some(' ') {
return LineType::Heading(hashes as u8);
}
}
if trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ ") {
return LineType::ListItem;
}
if let Some(dot_pos) = trimmed.find(". ") {
let prefix = &trimmed[..dot_pos];
if prefix.chars().all(|c| c.is_ascii_digit()) && !prefix.is_empty() {
return LineType::ListItem;
}
}
LineType::Normal
}
pub fn code_style(base: Style) -> Style {
base.bg(Color::Indexed(236))
}
pub fn bold_style(base: Style) -> Style {
base.add_modifier(Modifier::BOLD)
}
pub fn link_style(base: Style) -> Style {
base.fg(Color::Cyan).add_modifier(Modifier::UNDERLINED)
}
pub fn heading_style(base: Style, level: u8) -> Style {
let s = base.add_modifier(Modifier::BOLD);
let _ = level; s
}
pub fn code_block_style(base: Style) -> Style {
base.bg(Color::Indexed(234))
}
pub fn heading_text(line: &str, _level: u8) -> String {
line.trim_start_matches('#').trim().to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_inline_normal() {
let segs = parse_inline("hello world");
assert_eq!(segs, vec![Segment::Normal("hello world".into())]);
}
#[test]
fn parse_inline_code() {
let segs = parse_inline("use `foo` here");
assert_eq!(segs.len(), 3);
assert_eq!(segs[0], Segment::Normal("use ".into()));
assert_eq!(segs[1], Segment::Code("foo".into()));
assert_eq!(segs[2], Segment::Normal(" here".into()));
}
#[test]
fn parse_inline_bold() {
let segs = parse_inline("this is **bold** text");
assert_eq!(segs.len(), 3);
assert_eq!(segs[1], Segment::Bold("bold".into()));
}
#[test]
fn parse_inline_link() {
let segs = parse_inline("click [here](https://example.com) now");
assert_eq!(segs.len(), 3);
assert_eq!(
segs[1],
Segment::Link {
text: "here".into(),
url: "https://example.com".into(),
}
);
}
#[test]
fn detect_heading() {
assert!(matches!(detect_line_type("# Title"), LineType::Heading(1)));
assert!(matches!(detect_line_type("## Sub"), LineType::Heading(2)));
assert!(matches!(detect_line_type("### H3"), LineType::Heading(3)));
}
#[test]
fn detect_code_fence() {
if let LineType::CodeFence { lang } = detect_line_type("```rust") {
assert_eq!(lang, "rust");
} else {
panic!("expected CodeFence");
}
}
#[test]
fn detect_list_item() {
assert!(matches!(detect_line_type("- item"), LineType::ListItem));
assert!(matches!(detect_line_type("1. item"), LineType::ListItem));
}
#[test]
fn detect_horizontal_rule() {
assert!(matches!(detect_line_type("---"), LineType::HorizontalRule));
assert!(matches!(detect_line_type("***"), LineType::HorizontalRule));
}
#[test]
fn detect_normal() {
assert!(matches!(detect_line_type("just text"), LineType::Normal));
}
#[test]
fn heading_text_extraction() {
assert_eq!(heading_text("### Hello", 3), "Hello");
assert_eq!(heading_text("# Title", 1), "Title");
}
}