use std::ops::Range;
use tree_sitter::{Language, QueryError};
use tree_sitter_highlight::{HighlightConfiguration, HighlightEvent, Highlighter};
use super::highlight::{coalesce, DocumentHighlighter, TokenKind};
pub struct TreeSitterHighlighter {
config: HighlightConfiguration,
parser: Highlighter,
kinds: Vec<Option<TokenKind>>,
lines: Vec<Vec<(Range<usize>, TokenKind)>>,
}
impl TreeSitterHighlighter {
pub fn new(language: Language, highlights_query: &str) -> Result<Self, QueryError> {
let mut config =
HighlightConfiguration::new(language, "source", highlights_query, "", "")?;
let names: Vec<String> = config
.query
.capture_names()
.iter()
.map(|n| n.to_string())
.collect();
config.configure(&names);
let kinds = names.iter().map(|n| kind_for(n)).collect();
Ok(Self {
config,
parser: Highlighter::new(),
kinds,
lines: Vec::new(),
})
}
}
impl DocumentHighlighter for TreeSitterHighlighter {
fn update(&mut self, text: &str) {
let starts = line_starts(text);
let mut lines: Vec<Vec<(Range<usize>, TokenKind)>> = vec![Vec::new(); starts.len()];
let Ok(events) = self
.parser
.highlight(&self.config, text.as_bytes(), None, |_| None)
else {
self.lines = lines;
return;
};
let mut stack: Vec<Option<TokenKind>> = Vec::new();
let mut cur = 0;
for event in events {
let Ok(event) = event else { break };
match event {
HighlightEvent::HighlightStart(h) => {
stack.push(self.kinds.get(h.0).copied().flatten());
}
HighlightEvent::HighlightEnd => {
stack.pop();
}
HighlightEvent::Source { start, end } => {
let Some(kind) = stack.iter().rev().find_map(|k| *k) else {
continue;
};
let mut s = start;
while s < end {
while cur + 1 < starts.len() && starts[cur + 1] <= s {
cur += 1;
}
let line_start = starts[cur];
let line_end = starts.get(cur + 1).map_or(text.len(), |n| n - 1);
let e = end.min(line_end);
if e > s {
lines[cur].push((s - line_start..e - line_start, kind));
}
s = if end > line_end { line_end + 1 } else { end };
}
}
}
}
self.lines = lines.into_iter().map(coalesce).collect();
}
fn tokens(&self, line: usize) -> &[(Range<usize>, TokenKind)] {
self.lines.get(line).map(Vec::as_slice).unwrap_or(&[])
}
}
fn line_starts(text: &str) -> Vec<usize> {
let mut starts = vec![0];
for (i, b) in text.bytes().enumerate() {
if b == b'\n' {
starts.push(i + 1);
}
}
starts
}
fn kind_for(name: &str) -> Option<TokenKind> {
match name.split('.').next().unwrap_or(name) {
"keyword" | "boolean" | "conditional" | "repeat" | "include" | "exception" => {
Some(TokenKind::Keyword)
}
"string" | "character" | "escape" => Some(TokenKind::StringLit),
"number" | "float" | "constant" => Some(TokenKind::Number),
"comment" => Some(TokenKind::Comment),
"type" | "constructor" | "tag" | "namespace" | "module" => Some(TokenKind::Type),
"function" | "method" | "macro" | "attribute" => Some(TokenKind::Function),
"operator" | "punctuation" => Some(TokenKind::Punct),
"variable" | "parameter" | "property" | "field" | "label" => Some(TokenKind::Ident),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn rust() -> TreeSitterHighlighter {
TreeSitterHighlighter::new(
tree_sitter_rust::LANGUAGE.into(),
tree_sitter_rust::HIGHLIGHTS_QUERY,
)
.expect("query compiles against the grammar")
}
fn kind_of(hl: &TreeSitterHighlighter, source: &str, line: usize, word: &str) -> TokenKind {
let text = source.split('\n').nth(line).unwrap();
hl.tokens(line)
.iter()
.find(|(r, _)| &text[r.clone()] == word)
.map(|(_, k)| *k)
.unwrap_or_else(|| panic!("token {word:?} not found in line {line}: {text:?}"))
}
#[test]
fn rust_source_gets_tree_sitter_kinds() {
let mut hl = rust();
let source = "fn main() {\n let s = \"hi\";\n println!(\"{s}\");\n}";
hl.update(source);
assert_eq!(kind_of(&hl, source, 0, "fn"), TokenKind::Keyword);
assert_eq!(kind_of(&hl, source, 0, "main"), TokenKind::Function);
assert_eq!(kind_of(&hl, source, 1, "let"), TokenKind::Keyword);
assert_eq!(kind_of(&hl, source, 1, "\"hi\""), TokenKind::StringLit);
assert_eq!(kind_of(&hl, source, 2, "println!"), TokenKind::Function);
}
#[test]
fn multiline_comment_covers_every_line() {
let mut hl = rust();
let source = "/* one\ntwo\nthree */\nfn f() {}";
hl.update(source);
for line in 0..3 {
let text = source.split('\n').nth(line).unwrap();
assert_eq!(
hl.tokens(line),
&[(0..text.len(), TokenKind::Comment)],
"line {line}"
);
}
assert_eq!(kind_of(&hl, source, 3, "fn"), TokenKind::Keyword);
}
#[test]
fn ranges_are_ascending_in_bounds_and_char_aligned() {
let mut hl = rust();
let source = "let s = \"héllo 日本語\"; // café";
hl.update(source);
let mut at = 0;
for (range, _) in hl.tokens(0) {
assert!(range.start >= at, "overlapping range");
assert!(range.end <= source.len());
assert!(source.is_char_boundary(range.start));
assert!(source.is_char_boundary(range.end));
at = range.end;
}
assert_eq!(kind_of(&hl, source, 0, "// café"), TokenKind::Comment);
}
#[test]
fn update_replaces_the_previous_parse() {
let mut hl = rust();
hl.update("fn a() {}\nfn b() {}");
assert!(!hl.tokens(1).is_empty());
hl.update("fn a() {}");
assert!(hl.tokens(1).is_empty());
assert!(hl.tokens(99).is_empty());
}
#[test]
fn empty_document_is_fine() {
let mut hl = rust();
hl.update("");
assert!(hl.tokens(0).is_empty());
}
#[test]
fn capture_names_map_by_root_segment() {
assert_eq!(kind_for("keyword"), Some(TokenKind::Keyword));
assert_eq!(kind_for("function.method"), Some(TokenKind::Function));
assert_eq!(kind_for("function.macro"), Some(TokenKind::Function));
assert_eq!(kind_for("punctuation.bracket"), Some(TokenKind::Punct));
assert_eq!(kind_for("constant.builtin"), Some(TokenKind::Number));
assert_eq!(kind_for("type.builtin"), Some(TokenKind::Type));
assert_eq!(kind_for("string.special"), Some(TokenKind::StringLit));
assert_eq!(kind_for("no.such.capture"), None);
}
}