use std::collections::HashMap;
use std::sync::{Arc, Mutex, OnceLock};
use arborium::advanced::{CompiledGrammar, GrammarConfig, ParseContext, Span};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TokenKind {
Keyword,
Function,
Type,
Number,
String,
Comment,
Variable,
Constant,
Operator,
}
impl TokenKind {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Keyword => "keyword",
Self::Function => "function",
Self::Type => "type",
Self::Number => "number",
Self::String => "string",
Self::Comment => "comment",
Self::Variable => "variable",
Self::Constant => "constant",
Self::Operator => "operator",
}
}
#[must_use]
pub fn from_capture(capture: &str) -> Option<Self> {
let root = capture.split('.').next().unwrap_or(capture);
Some(match root {
"keyword" | "conditional" | "repeat" | "include" | "storageclass" => Self::Keyword,
"function" | "method" => Self::Function,
"type" | "class" | "constructor" => Self::Type,
"number" | "float" => Self::Number,
"string" | "character" => Self::String,
"comment" => Self::Comment,
"variable" | "parameter" | "property" | "field" => Self::Variable,
"constant" | "boolean" => Self::Constant,
"operator" | "punctuation" => Self::Operator,
_ => return None,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Token {
pub start_utf16: u32,
pub end_utf16: u32,
pub kind: &'static str,
}
#[must_use]
pub fn is_supported_language(language: &str) -> bool {
normalize_language(language).is_some()
}
fn normalize_language(language: &str) -> Option<&'static str> {
let lang = language.trim().to_ascii_lowercase();
match lang.as_str() {
"rust" | "rs" => Some("rust"),
"typescript" | "ts" => Some("typescript"),
"javascript" | "js" | "jsx" | "tsx" => Some("javascript"),
"python" | "py" => Some("python"),
"json" => Some("json"),
"bash" | "sh" | "shell" | "zsh" => Some("bash"),
"html" | "htm" => Some("html"),
"css" => Some("css"),
"markdown" | "md" => Some("markdown"),
"sql" | "postgres" | "postgresql" | "psql" | "mysql" | "sqlite" => Some("sql"),
_ => None,
}
}
#[must_use]
pub fn highlight(code: &str, language: &str) -> Vec<Token> {
if code.is_empty() {
return Vec::new();
}
if code.len() > MAX_HIGHLIGHT_BYTES {
return Vec::new();
}
let Some(lang_name) = normalize_language(language) else {
return Vec::new();
};
let Ok(grammar) = get_or_compile_grammar(lang_name) else {
return Vec::new();
};
let Ok(mut ctx) = ParseContext::for_grammar(&grammar) else {
return Vec::new();
};
let result = grammar.parse(&mut ctx, code);
byte_spans_to_utf16_tokens(code, &result.spans)
}
const MAX_HIGHLIGHT_BYTES: usize = 128 * 1024;
fn grammar_cache() -> &'static Mutex<HashMap<&'static str, Arc<CompiledGrammar>>> {
static CACHE: OnceLock<Mutex<HashMap<&'static str, Arc<CompiledGrammar>>>> = OnceLock::new();
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}
fn get_or_compile_grammar(
lang_name: &'static str,
) -> Result<Arc<CompiledGrammar>, arborium::advanced::GrammarError> {
{
let guard = grammar_cache().lock().expect("grammar cache poisoned");
if let Some(existing) = guard.get(lang_name) {
return Ok(Arc::clone(existing));
}
}
let compiled = compile_grammar(lang_name)?;
let mut guard = grammar_cache().lock().expect("grammar cache poisoned");
Ok(Arc::clone(guard.entry(lang_name).or_insert(compiled)))
}
fn compile_grammar(
lang_name: &'static str,
) -> Result<Arc<CompiledGrammar>, arborium::advanced::GrammarError> {
let (language, highlights_query, injections_query, locals_query): (
arborium::tree_sitter::Language,
&str,
&str,
&str,
) = match lang_name {
"rust" => (
arborium::lang_rust::language().into(),
arborium::lang_rust::HIGHLIGHTS_QUERY,
arborium::lang_rust::INJECTIONS_QUERY,
arborium::lang_rust::LOCALS_QUERY,
),
"typescript" => (
arborium::lang_typescript::language().into(),
&arborium::lang_typescript::HIGHLIGHTS_QUERY,
arborium::lang_typescript::INJECTIONS_QUERY,
arborium::lang_typescript::LOCALS_QUERY,
),
"javascript" => (
arborium::lang_javascript::language().into(),
arborium::lang_javascript::HIGHLIGHTS_QUERY,
arborium::lang_javascript::INJECTIONS_QUERY,
arborium::lang_javascript::LOCALS_QUERY,
),
"python" => (
arborium::lang_python::language().into(),
arborium::lang_python::HIGHLIGHTS_QUERY,
arborium::lang_python::INJECTIONS_QUERY,
arborium::lang_python::LOCALS_QUERY,
),
"json" => (
arborium::lang_json::language().into(),
arborium::lang_json::HIGHLIGHTS_QUERY,
arborium::lang_json::INJECTIONS_QUERY,
arborium::lang_json::LOCALS_QUERY,
),
"bash" => (
arborium::lang_bash::language().into(),
arborium::lang_bash::HIGHLIGHTS_QUERY,
arborium::lang_bash::INJECTIONS_QUERY,
arborium::lang_bash::LOCALS_QUERY,
),
"html" => (
arborium::lang_html::language().into(),
arborium::lang_html::HIGHLIGHTS_QUERY,
arborium::lang_html::INJECTIONS_QUERY,
arborium::lang_html::LOCALS_QUERY,
),
"css" => (
arborium::lang_css::language().into(),
arborium::lang_css::HIGHLIGHTS_QUERY,
arborium::lang_css::INJECTIONS_QUERY,
arborium::lang_css::LOCALS_QUERY,
),
"markdown" => (
arborium::lang_markdown::language().into(),
arborium::lang_markdown::HIGHLIGHTS_QUERY,
arborium::lang_markdown::INJECTIONS_QUERY,
arborium::lang_markdown::LOCALS_QUERY,
),
"sql" => (
arborium::lang_sql::language().into(),
arborium::lang_sql::HIGHLIGHTS_QUERY,
arborium::lang_sql::INJECTIONS_QUERY,
arborium::lang_sql::LOCALS_QUERY,
),
_ => {
debug_assert!(false, "unreachable lang_name: {lang_name}");
return Err(arborium::advanced::GrammarError::LanguageError);
}
};
let config = GrammarConfig {
language,
highlights_query,
injections_query,
locals_query,
};
CompiledGrammar::new(config).map(Arc::new)
}
fn byte_spans_to_utf16_tokens(code: &str, spans: &[Span]) -> Vec<Token> {
let mut best: Vec<&Span> = Vec::with_capacity(spans.len());
for span in spans {
if span.end <= span.start {
continue;
}
match best
.iter()
.position(|s| s.start == span.start && s.end == span.end)
{
Some(idx) => {
if span.pattern_index > best[idx].pattern_index {
best[idx] = span;
}
}
None => best.push(span),
}
}
best.sort_by_key(|s| (s.start, s.end));
let byte_to_utf16 = build_byte_to_utf16_map(code);
let mut out: Vec<Token> = Vec::with_capacity(best.len());
let mut prev_end_byte: u32 = 0;
for span in best {
let Some(kind) = TokenKind::from_capture(&span.capture) else {
continue;
};
if span.end <= prev_end_byte {
continue;
}
let start_byte = span.start.max(prev_end_byte);
let start_utf16 = byte_offset_to_utf16(&byte_to_utf16, start_byte);
let end_utf16 = byte_offset_to_utf16(&byte_to_utf16, span.end);
if end_utf16 <= start_utf16 {
continue;
}
out.push(Token {
start_utf16,
end_utf16,
kind: kind.as_str(),
});
prev_end_byte = span.end;
}
out
}
fn build_byte_to_utf16_map(code: &str) -> Vec<u32> {
let mut map = Vec::with_capacity(code.len() + 1);
let mut utf16 = 0u32;
let mut last_byte = 0usize;
for (byte_idx, ch) in code.char_indices() {
while map.len() < byte_idx {
map.push(utf16);
}
map.push(utf16);
utf16 += u32::try_from(ch.len_utf16()).unwrap_or(0);
last_byte = byte_idx + ch.len_utf8();
}
while map.len() <= code.len() {
map.push(utf16);
}
debug_assert!(last_byte <= code.len());
map
}
fn byte_offset_to_utf16(map: &[u32], byte_offset: u32) -> u32 {
let idx = (byte_offset as usize).min(map.len().saturating_sub(1));
map.get(idx).copied().unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_supported_language_accepts_aliases() {
assert!(is_supported_language("rust"));
assert!(is_supported_language("rs"));
assert!(is_supported_language("RUST")); assert!(is_supported_language("ts"));
assert!(is_supported_language("typescript"));
assert!(is_supported_language("js"));
assert!(is_supported_language("javascript"));
assert!(is_supported_language("tsx"));
assert!(is_supported_language("jsx"));
assert!(is_supported_language("python"));
assert!(is_supported_language("py"));
assert!(is_supported_language("json"));
assert!(is_supported_language("bash"));
assert!(is_supported_language("sh"));
assert!(is_supported_language("shell"));
assert!(is_supported_language("zsh"));
assert!(is_supported_language("html"));
assert!(is_supported_language("htm"));
assert!(is_supported_language("css"));
assert!(is_supported_language("markdown"));
assert!(is_supported_language("md"));
assert!(is_supported_language("sql"));
assert!(is_supported_language("postgres"));
assert!(is_supported_language("mysql"));
assert!(is_supported_language("sqlite"));
assert!(!is_supported_language("go"));
assert!(!is_supported_language("c"));
assert!(!is_supported_language(""));
}
#[test]
fn highlight_empty_code_returns_empty() {
assert_eq!(highlight("", "rust"), Vec::<Token>::new());
}
#[test]
fn highlight_unsupported_language_returns_empty() {
assert_eq!(
highlight("package main\nfunc main() {}", "go"),
Vec::<Token>::new()
);
}
#[test]
fn highlight_rust_fn_declaration_includes_keyword_and_function() {
let tokens = highlight("fn main() {}", "rust");
assert!(
tokens.iter().any(|t| t.kind == "keyword"),
"expected a keyword token, got {tokens:?}"
);
assert!(
tokens.iter().any(|t| t.kind == "function"),
"expected a function token, got {tokens:?}"
);
}
#[test]
fn highlight_rust_string_literal_tagged_as_string() {
let tokens = highlight(r#"let s = "hello";"#, "rust");
assert!(
tokens.iter().any(|t| t.kind == "string"),
"expected a string token, got {tokens:?}"
);
assert!(
tokens.iter().any(|t| t.kind == "keyword"),
"expected a keyword token for `let`, got {tokens:?}"
);
}
#[test]
fn highlight_rust_comment_tagged_as_comment() {
let tokens = highlight("// hello\nfn a() {}", "rust");
assert!(
tokens.iter().any(|t| t.kind == "comment"),
"expected a comment token, got {tokens:?}"
);
}
#[test]
fn highlight_typescript_async_function() {
let tokens = highlight("async function fetch() { return 1; }", "ts");
assert!(
tokens.iter().any(|t| t.kind == "keyword"),
"expected a keyword token, got {tokens:?}"
);
assert!(
tokens.iter().any(|t| t.kind == "function"),
"expected a function token, got {tokens:?}"
);
}
#[test]
fn highlight_javascript_const_and_number() {
let tokens = highlight("const x = 42;", "js");
assert!(tokens.iter().any(|t| t.kind == "keyword"));
assert!(tokens.iter().any(|t| t.kind == "number"));
}
#[test]
fn partial_overlap_clamps_start_instead_of_dropping() {
let code = "abcdefghij"; let spans = vec![
Span {
start: 0,
end: 4,
capture: "keyword".to_string(),
pattern_index: 0,
},
Span {
start: 2,
end: 7,
capture: "string".to_string(),
pattern_index: 1,
},
];
let out = byte_spans_to_utf16_tokens(code, &spans);
assert_eq!(
out,
vec![
Token {
start_utf16: 0,
end_utf16: 4,
kind: "keyword",
},
Token {
start_utf16: 4,
end_utf16: 7,
kind: "string",
},
],
);
}
#[test]
fn full_nesting_still_drops_inner_span() {
let code = "abcdefghij";
let spans = vec![
Span {
start: 0,
end: 10,
capture: "function".to_string(),
pattern_index: 0,
},
Span {
start: 3,
end: 6,
capture: "keyword".to_string(),
pattern_index: 1,
},
];
let out = byte_spans_to_utf16_tokens(code, &spans);
assert_eq!(out.len(), 1, "nested child should be dropped: {out:?}");
assert_eq!(out[0].kind, "function");
}
#[test]
fn highlight_tokens_are_sorted_and_non_overlapping() {
let tokens = highlight("fn a() { let x = 42; }", "rust");
let mut prev_end = 0u32;
for t in &tokens {
assert!(
t.start_utf16 >= prev_end,
"tokens overlap: prev_end={prev_end} start={} in {tokens:?}",
t.start_utf16
);
assert!(t.end_utf16 > t.start_utf16, "zero-width token: {t:?}");
prev_end = t.end_utf16;
}
}
#[test]
fn byte_offsets_are_converted_to_utf16_for_non_ascii() {
let source = "// 한글 🚀\nfn main() {}";
let tokens = highlight(source, "rust");
let total_utf16 =
u32::try_from(source.encode_utf16().count()).expect("test source fits in u32");
for t in &tokens {
assert!(
t.end_utf16 <= total_utf16,
"token end {} exceeds total utf16 {total_utf16}: {t:?}",
t.end_utf16
);
}
}
#[test]
fn token_kind_from_capture_maps_dotted_captures() {
assert_eq!(
TokenKind::from_capture("keyword.function"),
Some(TokenKind::Keyword)
);
assert_eq!(
TokenKind::from_capture("string.special.symbol"),
Some(TokenKind::String)
);
assert_eq!(
TokenKind::from_capture("function.call"),
Some(TokenKind::Function)
);
assert_eq!(TokenKind::from_capture("bogus.capture"), None);
}
#[test]
fn highlight_python_def_and_string() {
let tokens = highlight("def greet(name):\n return \"hi\"", "python");
assert!(
tokens.iter().any(|t| t.kind == "keyword"),
"expected a keyword token for `def`/`return`, got {tokens:?}"
);
assert!(
tokens.iter().any(|t| t.kind == "string"),
"expected a string token, got {tokens:?}"
);
}
#[test]
fn highlight_json_basic_shape() {
let tokens = highlight(r#"{"name": "alice", "age": 30}"#, "json");
assert!(
tokens.iter().any(|t| t.kind == "string"),
"expected string tokens, got {tokens:?}"
);
assert!(
tokens.iter().any(|t| t.kind == "number"),
"expected a number token for `30`, got {tokens:?}"
);
}
#[test]
fn highlight_bash_comment_and_keyword() {
let tokens = highlight("#!/usr/bin/env bash\nif true; then echo hi; fi", "bash");
assert!(
tokens.iter().any(|t| t.kind == "comment"),
"expected a comment token for shebang, got {tokens:?}"
);
assert!(
tokens.iter().any(|t| t.kind == "keyword"),
"expected a keyword token for `if`/`then`/`fi`, got {tokens:?}"
);
}
#[test]
fn highlight_bash_accepts_sh_alias() {
let tokens = highlight("echo hello", "sh");
assert!(
!tokens.is_empty(),
"expected `sh` alias to route to bash grammar and produce tokens"
);
}
#[test]
fn highlight_html_tag_and_attribute() {
let tokens = highlight(r#"<div class="box">hello</div>"#, "html");
assert!(!tokens.is_empty(), "expected html tokens, got {tokens:?}");
}
#[test]
fn highlight_css_selector_and_property() {
let tokens = highlight(".card { color: red; font-size: 14px; }", "css");
assert!(!tokens.is_empty(), "expected css tokens, got {tokens:?}");
assert!(
tokens.iter().any(|t| t.kind == "number"),
"expected a number token, got {tokens:?}"
);
}
#[test]
fn highlight_markdown_produces_tokens() {
let tokens = highlight("# Title\n\nSome **bold** text.\n", "markdown");
assert!(
!tokens.is_empty(),
"expected markdown tokens, got {tokens:?}"
);
}
#[test]
fn highlight_sql_select_keyword() {
let tokens = highlight("SELECT id, name FROM users WHERE age > 18;", "sql");
assert!(
tokens.iter().any(|t| t.kind == "keyword"),
"expected a keyword token for `SELECT`/`FROM`/`WHERE`, got {tokens:?}"
);
assert!(
tokens.iter().any(|t| t.kind == "type"),
"expected a type token for the table name `users`, got {tokens:?}"
);
}
#[test]
fn highlight_sql_alias_routes_to_sql_grammar() {
for alias in ["postgres", "postgresql", "mysql", "sqlite"] {
let tokens = highlight("SELECT 1;", alias);
assert!(
!tokens.is_empty(),
"expected `{alias}` alias to produce tokens"
);
}
}
}