use crate::theme::ThemeColors;
#[derive(Debug, Clone, Default)]
pub struct Highlighter {
in_block_comment: bool,
profile: Profile,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
enum Profile {
#[default]
Plain,
SlashSlash, Hash, Json,
Sql,
}
impl Profile {
fn for_language(language: &str) -> Self {
match language {
"rust" | "rs" | "javascript" | "js" | "jsx" | "typescript" | "ts" | "tsx" | "go"
| "golang" | "c" | "cpp" | "c++" | "h" | "hpp" | "java" | "csharp" | "cs" | "swift"
| "kotlin" | "scala" | "dart" | "php" => Self::SlashSlash,
"python" | "py" | "ruby" | "rb" | "bash" | "sh" | "shell" | "zsh" | "fish" | "yaml"
| "yml" | "toml" | "ini" | "r" | "perl" | "pl" | "makefile" | "dockerfile" => {
Self::Hash
}
"json" | "jsonc" => Self::Json,
"sql" => Self::Sql,
_ => Self::Plain,
}
}
fn line_comment(self) -> Option<&'static str> {
match self {
Self::SlashSlash => Some("//"),
Self::Hash => Some("#"),
Self::Sql => Some("--"),
Self::Json | Self::Plain => None,
}
}
fn supports_block_comments(self) -> bool {
matches!(self, Self::SlashSlash | Self::Sql)
}
fn hash_is_comment(self) -> bool {
self == Self::Hash
}
}
fn keywords(language: &str) -> &'static [&'static str] {
match language {
"rust" | "rs" => &[
"as", "async", "await", "break", "const", "continue", "crate", "dyn", "else", "enum",
"extern", "false", "fn", "for", "if", "impl", "in", "let", "loop", "match", "mod",
"move", "mut", "pub", "ref", "return", "self", "Self", "static", "struct", "super",
"trait", "true", "type", "unsafe", "use", "where", "while", "crate",
],
"python" | "py" => &[
"and", "as", "assert", "async", "await", "break", "class", "continue", "def", "del",
"elif", "else", "except", "False", "finally", "for", "from", "global", "if", "import",
"in", "is", "lambda", "None", "not", "or", "pass", "raise", "return", "True", "try",
"while", "with", "yield",
],
"javascript" | "js" | "jsx" | "typescript" | "ts" | "tsx" => &[
"async", "await", "break", "case", "catch", "class", "const", "continue", "default",
"delete", "do", "else", "export", "extends", "false", "finally", "for", "function",
"if", "import", "in", "instanceof", "let", "new", "null", "return", "super", "switch",
"this", "throw", "true", "try", "typeof", "undefined", "var", "void", "while", "yield",
"interface", "type", "enum", "implements", "private", "public", "readonly",
],
"go" | "golang" => &[
"break", "case", "chan", "const", "continue", "default", "defer", "else", "fallthrough",
"for", "func", "go", "goto", "if", "import", "interface", "map", "package", "range",
"return", "select", "struct", "switch", "type", "var", "nil", "true", "false",
],
"bash" | "sh" | "shell" | "zsh" | "fish" => &[
"if", "then", "else", "elif", "fi", "for", "while", "do", "done", "case", "esac",
"function", "return", "export", "local", "in",
],
"c" | "cpp" | "c++" | "h" | "hpp" | "java" | "csharp" | "cs" => &[
"auto", "bool", "break", "case", "catch", "char", "class", "const", "continue",
"default", "do", "double", "else", "enum", "extern", "false", "float", "for", "if",
"int", "long", "namespace", "new", "private", "protected", "public", "return", "short",
"static", "struct", "switch", "template", "this", "throw", "true", "try", "typedef",
"void", "while", "using", "override", "virtual",
],
_ => &[
"true", "false", "null", "if", "else", "for", "while", "return", "function", "class",
],
}
}
impl Highlighter {
pub fn new(language: &str) -> Self {
let lang = language.trim().to_ascii_lowercase();
let profile = if lang == "jsonc" {
Profile::SlashSlash
} else {
Profile::for_language(&lang)
};
Self {
in_block_comment: false,
profile,
}
}
pub fn highlight(&mut self, line: &str, colors: &ThemeColors) -> String {
let base = colors.md_code_block;
let comment = colors.muted;
let string = colors.success;
let number = colors.info;
let keyword = colors.accent;
if self.profile == Profile::Plain {
return base.fg(line);
}
let keywords = keywords_from_profile(self.profile);
let mut out = String::new();
let chars: Vec<char> = line.chars().collect();
let mut i = 0usize;
let mut plain_start = 0usize;
macro_rules! flush {
() => {
if i > plain_start {
let text: String = chars[plain_start..i].iter().collect();
out.push_str(&base.fg(&text));
}
};
}
while i < chars.len() {
if self.in_block_comment {
let close = if self.profile == Profile::Sql { "*/" } else { "*/" };
if let Some(off) = find_seq(&chars, i, close) {
let end = off + 2;
let text: String = chars[i..end].iter().collect();
out.push_str(&comment.fg(&text));
i = end;
self.in_block_comment = false;
plain_start = i;
continue;
} else {
let text: String = chars[i..].iter().collect();
out.push_str(&comment.fg(&text));
return finish(&out);
}
}
let rest: String = chars[i..].iter().collect();
let c = chars[i];
if let Some(marker) = self.profile.line_comment() {
if rest.starts_with(marker) {
flush!();
out.push_str(&comment.fg(&rest));
return finish(&out);
}
}
if self.profile.hash_is_comment()
&& c == '#'
&& (i == 0 || chars[i - 1].is_whitespace())
{
flush!();
out.push_str(&comment.fg(&rest));
return finish(&out);
}
if self.profile.supports_block_comments() && rest.starts_with("/*") {
flush!();
if let Some(off) = find_seq(&chars, i + 2, "*/") {
let end = off + 2;
let text: String = chars[i..end].iter().collect();
out.push_str(&comment.fg(&text));
i = end;
plain_start = i;
continue;
} else {
let text: String = chars[i..].iter().collect();
out.push_str(&comment.fg(&text));
self.in_block_comment = true;
return finish(&out);
}
}
if c == '"' || c == '\'' || c == '`' {
flush!();
let quote = c;
let mut j = i + 1;
let mut escaped = false;
while j < chars.len() {
let cj = chars[j];
if escaped {
escaped = false;
} else if cj == '\\' {
escaped = true;
} else if cj == quote {
j += 1;
break;
}
j += 1;
}
let text: String = chars[i..j.min(chars.len())].iter().collect();
out.push_str(&string.fg(&text));
i = j.min(chars.len());
plain_start = i;
continue;
}
if c.is_ascii_digit() && (i == 0 || !is_ident_char(chars[i - 1])) {
let mut j = i;
while j < chars.len() && (chars[j].is_ascii_alphanumeric() || chars[j] == '.' || chars[j] == '_') {
j += 1;
}
flush!();
let text: String = chars[i..j].iter().collect();
out.push_str(&number.fg(&text));
i = j;
plain_start = i;
continue;
}
if is_ident_start(c) {
let mut j = i;
while j < chars.len() && is_ident_char(chars[j]) {
j += 1;
}
let word: String = chars[i..j].iter().collect();
if keywords.contains(&word.as_str()) {
flush!();
out.push_str(&keyword.fg(&word));
i = j;
plain_start = i;
continue;
}
i = j;
continue;
}
i += 1;
}
flush!();
finish(&out)
}
}
fn keywords_from_profile(profile: Profile) -> &'static [&'static str] {
match profile {
Profile::SlashSlash => keywords("rust"),
Profile::Hash => keywords("python"),
Profile::Json => &["true", "false", "null"],
Profile::Sql => &[
"SELECT", "FROM", "WHERE", "INSERT", "UPDATE", "DELETE", "CREATE", "TABLE", "JOIN",
"ON", "GROUP", "BY", "ORDER", "LIMIT", "AND", "OR", "NOT", "NULL", "AS",
],
Profile::Plain => &[],
}
}
fn find_seq(chars: &[char], from: usize, seq: &str) -> Option<usize> {
let target: Vec<char> = seq.chars().collect();
if target.is_empty() || chars.len() < target.len() {
return None;
}
let start = from.min(chars.len());
for i in start..=chars.len() - target.len() {
if chars[i..i + target.len()] == target[..] {
return Some(i);
}
}
None
}
fn is_ident_start(c: char) -> bool {
c.is_alphabetic() || c == '_' || c == '$'
}
fn is_ident_char(c: char) -> bool {
c.is_alphanumeric() || c == '_' || c == '$'
}
fn finish(out: &str) -> String {
if out.is_empty() {
String::new()
} else {
format!("{out}\x1b[0m")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn plain_language_is_passthrough() {
let colors = ThemeColors::default();
let mut h = Highlighter::new("text");
let out = h.highlight("hello world", &colors);
assert!(out.contains("hello world"));
}
#[test]
fn rust_keyword_is_highlighted() {
let colors = ThemeColors::default();
let mut h = Highlighter::new("rust");
let out = h.highlight("fn main() { let x = 1; }", &colors);
assert!(out.contains("fn"));
assert!(out.contains("let"));
assert!(out.contains('\x1b'));
}
#[test]
fn line_comment_is_full_line() {
let colors = ThemeColors::default();
let mut h = Highlighter::new("rust");
let out = h.highlight("// a comment with fn keyword", &colors);
assert_eq!(out.matches(colors.accent.to_fg().as_str()).count(), 0);
}
#[test]
fn block_comment_spans_lines() {
let colors = ThemeColors::default();
let mut h = Highlighter::new("rust");
let first = h.highlight("/* start", &colors);
assert!(first.contains("start"));
assert!(h.in_block_comment);
let second = h.highlight("still comment */ fn x()", &colors);
assert!(!h.in_block_comment);
assert!(second.contains("fn"));
}
#[test]
fn string_literal_swallows_keyword() {
let colors = ThemeColors::default();
let mut h = Highlighter::new("python");
let out = h.highlight("s = \"if for while\"", &colors);
assert!(out.contains("if for while"));
}
#[test]
fn json_literals() {
let colors = ThemeColors::default();
let mut h = Highlighter::new("json");
let out = h.highlight("{\"a\": true, \"b\": null}", &colors);
assert!(out.contains("true"));
}
}