use regex::Regex;
use super::{Highlight, HighlightKind, Language};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum BlockState {
#[default]
Normal,
InBlockComment(u16),
}
pub struct Highlighter {
pub language: &'static Language,
regex: Regex,
kinds: Vec<Rule>,
}
#[derive(Debug, Clone, Copy)]
enum Rule {
Fixed(HighlightKind),
Identifier,
Call,
BlockStart,
}
impl Highlighter {
#[must_use]
pub fn new(language: &&'static Language) -> Self {
let language: &'static Language = language;
let mut alternatives = Vec::new();
let mut kinds = Vec::new();
if let Some((open, _)) = language.block_comment {
alternatives.push(regex::escape(open));
kinds.push(Rule::BlockStart);
}
for (pattern, kind) in language.extra_rules {
alternatives.push((*pattern).to_string());
kinds.push(Rule::Fixed(*kind));
}
if let Some(prefix) = language.line_comment {
alternatives.push(format!("{}.*", regex::escape(prefix)));
kinds.push(Rule::Fixed(HighlightKind::Comment));
}
if !language.prose {
alternatives.push(r#""(?:[^"\\]|\\.)*"?"#.to_string());
kinds.push(Rule::Fixed(HighlightKind::String));
alternatives.push(r"'(?:[^'\\]|\\.)'".to_string());
kinds.push(Rule::Fixed(HighlightKind::String));
alternatives.push(NUMBER.to_string());
kinds.push(Rule::Fixed(HighlightKind::Number));
if language.macro_suffix {
alternatives.push(r"\b[A-Za-z_]\w*!".to_string());
kinds.push(Rule::Fixed(HighlightKind::Macro));
}
alternatives.push(r"\b[A-Za-z_]\w*\s*\(".to_string());
kinds.push(Rule::Call);
alternatives.push(r"\b[A-Za-z_]\w*".to_string());
kinds.push(Rule::Identifier);
alternatives.push(r"[-+*/%=<>!&|^~?:@]+".to_string());
kinds.push(Rule::Fixed(HighlightKind::Operator));
alternatives.push(r"[{}()\[\];,.]".to_string());
kinds.push(Rule::Fixed(HighlightKind::Punctuation));
}
let source = alternatives
.iter()
.map(|alternative| format!("({alternative})"))
.collect::<Vec<_>>()
.join("|");
Self {
language,
regex: Regex::new(&source).expect("built-in language patterns are valid"),
kinds,
}
}
#[must_use]
pub fn highlight_line(&self, line: &str, state: BlockState) -> (Vec<Highlight>, BlockState) {
let mut spans: Vec<(usize, usize, HighlightKind)> = Vec::new();
let mut state = state;
let mut position = 0;
if let BlockState::InBlockComment(depth) = state {
let (end, next) = self.scan_block_comment(line, 0, depth);
spans.push((0, end, HighlightKind::Comment));
state = next;
position = end;
if matches!(state, BlockState::InBlockComment(_)) {
return (self.to_char_spans(line, spans), state);
}
}
while position <= line.len() {
let Some(captures) = self.regex.captures_at(line, position) else {
break;
};
let matched = captures.get(0).expect("group 0 always participates");
let rule = captures
.iter()
.enumerate()
.skip(1)
.find_map(|(index, group)| group.map(|_| self.kinds[index - 1]));
match rule {
Some(Rule::BlockStart) => {
let (end, next) = self.scan_block_comment(line, matched.end(), 1);
spans.push((matched.start(), end, HighlightKind::Comment));
state = next;
position = end;
if matches!(state, BlockState::InBlockComment(_)) {
break;
}
}
Some(Rule::Identifier) => {
if let Some(kind) = self.classify(matched.as_str()) {
spans.push((matched.start(), matched.end(), kind));
}
position = matched.end();
}
Some(Rule::Call) => {
let name = matched.as_str().trim_end_matches('(').trim_end();
let end = matched.start() + name.len();
let kind = self.classify(name).unwrap_or(HighlightKind::Function);
spans.push((matched.start(), end, kind));
position = end;
}
Some(Rule::Fixed(kind)) => {
spans.push((matched.start(), matched.end(), kind));
position = matched.end();
}
None => break,
}
if matched.start() == matched.end() {
position = matched.end() + 1;
}
}
(self.to_char_spans(line, spans), state)
}
fn classify(&self, word: &str) -> Option<HighlightKind> {
if self.language.keywords.contains(&word) {
return Some(HighlightKind::Keyword);
}
if self.language.types.contains(&word) {
return Some(HighlightKind::Type);
}
if self.language.constants.contains(&word) {
return Some(HighlightKind::Constant);
}
if word.len() > 1
&& word
.chars()
.all(|ch| ch.is_ascii_uppercase() || ch == '_' || ch.is_ascii_digit())
&& word.chars().any(|ch| ch.is_ascii_uppercase())
{
return Some(HighlightKind::Constant);
}
if self.language.capitalised_types && word.starts_with(|ch: char| ch.is_uppercase()) {
return Some(HighlightKind::Type);
}
None
}
fn scan_block_comment(&self, line: &str, from: usize, depth: u16) -> (usize, BlockState) {
let Some((open, close)) = self.language.block_comment else {
return (line.len(), BlockState::Normal);
};
let mut depth = depth;
let mut position = from;
while position < line.len() {
let rest = &line[position..];
let next_close = rest.find(close);
let next_open = self
.language
.nested_block_comments
.then(|| rest.find(open))
.flatten();
match (next_open, next_close) {
(Some(open_at), Some(close_at)) if open_at < close_at => {
depth += 1;
position += open_at + open.len();
}
(Some(open_at), None) => {
depth += 1;
position += open_at + open.len();
}
(_, Some(close_at)) => {
position += close_at + close.len();
depth -= 1;
if depth == 0 {
return (position, BlockState::Normal);
}
}
(None, None) => break,
}
}
(line.len(), BlockState::InBlockComment(depth))
}
fn to_char_spans(
&self,
line: &str,
spans: Vec<(usize, usize, HighlightKind)>,
) -> Vec<Highlight> {
if line.is_ascii() {
return spans
.into_iter()
.map(|(start, end, kind)| Highlight { start, end, kind })
.collect();
}
let mut byte_to_char = vec![0usize; line.len() + 1];
for (char_index, (byte_index, _)) in line.char_indices().enumerate() {
byte_to_char[byte_index] = char_index;
}
let mut last = 0;
for entry in &mut byte_to_char {
if *entry == 0 && last != 0 {
*entry = last;
} else {
last = *entry;
}
}
byte_to_char[line.len()] = line.chars().count();
spans
.into_iter()
.map(|(start, end, kind)| Highlight {
start: byte_to_char[start],
end: byte_to_char[end],
kind,
})
.collect()
}
}
const NUMBER: &str = r"\b(?:0[xX][0-9a-fA-F_]+|0[bB][01_]+|0[oO][0-7_]+|\d[\d_]*(?:\.\d[\d_]*)?(?:[eE][+-]?\d+)?)[A-Za-z_0-9]*";
#[cfg(test)]
mod tests {
use super::*;
use crate::syntax::by_name;
fn sketch(language: &str, line: &str) -> String {
let highlighter = by_name(language).expect("known language");
let (spans, _) = highlighter.highlight_line(line, BlockState::Normal);
paint(line, &spans)
}
fn paint(line: &str, spans: &[Highlight]) -> String {
let mut out = vec![b'.'; line.chars().count()];
for span in spans {
let letter = match span.kind {
HighlightKind::Keyword => b'k',
HighlightKind::Type => b't',
HighlightKind::Function => b'f',
HighlightKind::String => b's',
HighlightKind::Number => b'n',
HighlightKind::Comment => b'c',
HighlightKind::Constant => b'C',
HighlightKind::Operator => b'o',
HighlightKind::Punctuation => b'p',
HighlightKind::Attribute => b'a',
HighlightKind::Macro => b'm',
HighlightKind::Heading => b'H',
HighlightKind::Emphasis => b'E',
HighlightKind::Link => b'L',
};
for cell in out.iter_mut().take(span.end).skip(span.start) {
*cell = letter;
}
}
String::from_utf8(out).expect("ascii sketch")
}
#[test]
fn rust_keywords_types_and_literals() {
assert_eq!(sketch("rust", "let x: u32 = 1;"), "kkk..o.ttt.o.np");
}
#[test]
fn a_comment_marker_inside_a_string_is_not_a_comment() {
assert_eq!(sketch("rust", r#"let s = "// not";"#), "kkk...o.ssssssssp");
}
#[test]
fn a_string_marker_inside_a_comment_is_not_a_string() {
assert_eq!(sketch("rust", r#"// a "quote""#), "cccccccccccc");
}
#[test]
fn rust_lifetimes_are_not_character_literals() {
assert_eq!(
sketch("rust", "fn f<'a>(c: char) {}"),
"kk..ottop.o.ttttp.pp"
);
}
#[test]
fn a_character_literal_still_wins_over_a_lifetime() {
assert_eq!(sketch("rust", "let c = 'x';"), "kkk...o.sssp");
}
#[test]
fn function_calls_are_highlighted_but_keywords_before_a_bracket_are_not() {
assert_eq!(sketch("rust", "if foo() {}"), "kk.fffpp.pp");
}
#[test]
fn block_comments_carry_over_to_the_next_line() {
let highlighter = by_name("c").expect("known language");
let (spans, state) = highlighter.highlight_line("int a; /* start", BlockState::Normal);
assert_eq!(paint("int a; /* start", &spans), "ttt..p.cccccccc");
assert_eq!(state, BlockState::InBlockComment(1));
let (spans, state) = highlighter.highlight_line("still */ int b;", state);
assert_eq!(paint("still */ int b;", &spans), "cccccccc.ttt..p");
assert_eq!(state, BlockState::Normal);
}
#[test]
fn rust_block_comments_nest() {
let highlighter = by_name("rust").expect("known language");
let (_, state) = highlighter.highlight_line("/* a /* b", BlockState::Normal);
assert_eq!(state, BlockState::InBlockComment(2));
let (_, state) = highlighter.highlight_line("*/", state);
assert_eq!(state, BlockState::InBlockComment(1));
let (_, state) = highlighter.highlight_line("*/", state);
assert_eq!(state, BlockState::Normal);
}
#[test]
fn c_preprocessor_includes_swallow_their_angle_brackets() {
assert_eq!(sketch("c", "#include <stdio.h>"), "aaaaaaaaaaaaaaaaaa");
}
#[test]
fn cpp_template_arguments_are_not_mistaken_for_an_include() {
assert_eq!(sketch("cpp", "vector<int> v;"), "ttttttottto..p");
}
#[test]
fn zig_builtins_are_macros() {
assert_eq!(
sketch("zig", "const s = @import(\"std\");"),
"kkkkk...o.mmmmmmmpssssspp"
);
}
#[test]
fn python_decorators_and_docstrings() {
assert_eq!(sketch("python", "@cache"), "aaaaaa");
let highlighter = by_name("python").expect("known language");
let (_, state) = highlighter.highlight_line(r#"""" doc"#, BlockState::Normal);
assert_eq!(state, BlockState::InBlockComment(1));
}
#[test]
fn markdown_headings_and_links() {
assert_eq!(sketch("markdown", "# Title"), "HHHHHHH");
assert_eq!(sketch("markdown", "see [x](y)"), "....LLLLLL");
assert_eq!(sketch("markdown", "a **bold** b"), "..EEEEEEEE..");
}
#[test]
fn non_ascii_lines_report_character_offsets() {
let highlighter = by_name("rust").expect("known language");
let (spans, _) = highlighter.highlight_line("// ağaç", BlockState::Normal);
assert_eq!(spans[0].start, 0);
assert_eq!(spans[0].end, 7);
}
}