use ratatui::{
style::{Modifier, Style},
text::{Line, Span},
};
#[derive(Debug, Clone, Copy, PartialEq)]
enum TokenType {
Normal,
Keyword,
String,
Comment,
Number,
Type,
Function,
Punctuation,
}
fn token_style(token: TokenType) -> Style {
let dark = crate::theme::ColorScheme::dark();
let styles = dark.to_styles();
match token {
TokenType::Normal => styles.normal,
TokenType::Keyword => styles.accent.add_modifier(Modifier::BOLD),
TokenType::String => styles.secondary,
TokenType::Comment => styles.muted,
TokenType::Number => styles.warning,
TokenType::Type => styles.primary,
TokenType::Function => styles.success,
TokenType::Punctuation => styles.muted,
}
}
fn lang_keywords(lang: &str) -> &'static [&'static str] {
match lang {
"rust" | "rs" => &[
"fn", "let", "mut", "if", "else", "match", "loop", "while", "for", "in", "return",
"break", "continue", "struct", "enum", "impl", "trait", "type", "pub", "mod", "use",
"crate", "self", "super", "where", "async", "await", "move", "ref", "static", "const",
"unsafe", "extern", "dyn", "as",
],
"python" | "py" => &[
"def", "class", "if", "elif", "else", "for", "while", "return", "import", "from", "as",
"try", "except", "finally", "with", "yield", "lambda", "pass", "break", "continue",
"and", "or", "not", "in", "is", "True", "False", "None", "self", "async", "await",
"raise",
],
"javascript" | "js" | "typescript" | "ts" | "tsx" | "jsx" => &[
"function",
"const",
"let",
"var",
"if",
"else",
"for",
"while",
"do",
"return",
"class",
"new",
"this",
"super",
"import",
"export",
"from",
"default",
"async",
"await",
"try",
"catch",
"finally",
"throw",
"typeof",
"instanceof",
"void",
"null",
"undefined",
"true",
"false",
"switch",
"case",
"break",
"continue",
"yield",
"of",
"in",
],
"go" => &[
"func",
"var",
"const",
"type",
"struct",
"interface",
"map",
"chan",
"if",
"else",
"for",
"range",
"return",
"switch",
"case",
"default",
"break",
"continue",
"go",
"defer",
"select",
"package",
"import",
"nil",
"true",
"false",
],
"bash" | "sh" | "shell" | "zsh" => &[
"if", "then", "else", "elif", "fi", "for", "while", "do", "done", "case", "esac",
"function", "return", "local", "export", "source", "echo", "cd", "exit", "set",
"unset", "readonly", "shift",
],
"toml" | "yaml" | "yml" | "json" => &["true", "false", "null", "yes", "no"],
_ => &[],
}
}
fn line_comment_prefix(lang: &str) -> Option<&'static str> {
match lang {
"rust" | "rs" | "javascript" | "js" | "typescript" | "ts" | "tsx" | "jsx" | "go" | "c"
| "cpp" | "java" | "swift" | "kotlin" => Some("//"),
"python" | "py" | "bash" | "sh" | "shell" | "zsh" | "toml" => Some("#"),
"sql" => Some("--"),
_ => None,
}
}
fn is_pascal_case(s: &str) -> bool {
s.chars().next().is_some_and(|c| c.is_uppercase())
&& s.chars().any(|c| c.is_lowercase())
&& s.chars().all(|c| c.is_alphanumeric() || c == '_')
}
fn preceded_by_dot(text_before: &str) -> bool {
text_before.trim_end().ends_with('.')
}
fn highlight_line(line: &str, lang: &str) -> Line<'static> {
let keywords = lang_keywords(lang);
let comment_prefix = line_comment_prefix(lang);
let mut spans: Vec<Span<'static>> = Vec::new();
let chars: Vec<char> = line.chars().collect();
let mut i = 0;
while i < chars.len() {
if let Some(prefix) = comment_prefix {
if line[i..].starts_with(prefix) {
let rest: String = chars[i..].iter().collect();
spans.push(Span::styled(rest, token_style(TokenType::Comment)));
break;
}
}
if chars[i] == '"' {
let mut end = i + 1;
while end < chars.len() {
if chars[end] == '\\' && end + 1 < chars.len() {
end += 2;
} else if chars[end] == '"' {
end += 1;
break;
} else {
end += 1;
}
}
let s: String = chars[i..end].iter().collect();
spans.push(Span::styled(s, token_style(TokenType::String)));
i = end;
continue;
}
if chars[i] == '\'' {
let mut end = i + 1;
while end < chars.len() {
if chars[end] == '\\' && end + 1 < chars.len() {
end += 2;
} else if chars[end] == '\'' {
end += 1;
break;
} else {
end += 1;
}
}
let s: String = chars[i..end].iter().collect();
spans.push(Span::styled(s, token_style(TokenType::String)));
i = end;
continue;
}
if chars[i].is_ascii_digit()
|| (chars[i] == '0'
&& i + 1 < chars.len()
&& (chars[i + 1] == 'x' || chars[i + 1] == 'b'))
{
let mut end = i;
if chars[i] == '0' && end + 1 < chars.len() {
let next = chars[end + 1];
if next == 'x' || next == 'b' || next == 'o' {
end += 2;
}
}
while end < chars.len()
&& (chars[end].is_ascii_hexdigit() || chars[end] == '.' || chars[end] == '_')
{
end += 1;
}
while end < chars.len() && chars[end].is_ascii_alphabetic() {
end += 1;
}
let s: String = chars[i..end].iter().collect();
spans.push(Span::styled(s, token_style(TokenType::Number)));
i = end;
continue;
}
if chars[i].is_alphabetic() || chars[i] == '_' {
let mut end = i;
while end < chars.len() && (chars[end].is_alphanumeric() || chars[end] == '_') {
end += 1;
}
let word: String = chars[i..end].iter().collect();
let before: String = chars[..i].iter().collect();
let token_type = if keywords.contains(&word.as_str()) {
TokenType::Keyword
} else if is_pascal_case(&word) {
TokenType::Type
} else if preceded_by_dot(&before) {
TokenType::Function
} else {
TokenType::Normal
};
spans.push(Span::styled(word, token_style(token_type)));
i = end;
continue;
}
let c = chars[i];
let tok = if c == '('
|| c == ')'
|| c == '{'
|| c == '}'
|| c == '['
|| c == ']'
|| c == ':'
|| c == ';'
|| c == ','
|| c == '.'
{
TokenType::Punctuation
} else {
TokenType::Normal
};
spans.push(Span::styled(c.to_string(), token_style(tok)));
i += 1;
}
if spans.is_empty() {
spans.push(Span::raw(""));
}
Line::from(spans)
}
pub(crate) fn highlight_code(content: &str, lang: &str) -> Vec<Line<'static>> {
let mut lines = Vec::new();
for line in content.lines() {
lines.push(highlight_line(line, lang));
}
lines
}