use std::panic::AssertUnwindSafe;
use std::path::Path;
use lsp_types::{Position, SemanticToken, SemanticTokenType, SemanticTokens, SemanticTokensLegend};
use rowan::{TextRange, TextSize};
use crate::incremental::Analysis;
use crate::parser::parse;
use crate::syntax::{SyntaxKind, SyntaxNode, SyntaxToken};
use crate::text::{LineIndex, PositionEncoding};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum HighlightKind {
Keyword = 0,
Macro = 1,
String = 2,
Number = 3,
}
pub(crate) fn legend() -> SemanticTokensLegend {
SemanticTokensLegend {
token_types: vec![
SemanticTokenType::KEYWORD,
SemanticTokenType::MACRO,
SemanticTokenType::STRING,
SemanticTokenType::NUMBER,
],
token_modifiers: vec![],
}
}
pub fn compute_semantic_tokens(text: &str, encoding: PositionEncoding) -> SemanticTokens {
let root = parse(text).cst;
tokens_for_tree(&root, text, encoding)
}
pub(crate) fn semantic_tokens_via_db(
snapshot: &Analysis,
path: &Path,
text: &str,
encoding: PositionEncoding,
) -> SemanticTokens {
let cached = salsa::Cancelled::catch(AssertUnwindSafe(|| {
let file = snapshot.lookup_file(path)?;
if snapshot.file_text(file) != text {
return None;
}
let root = snapshot.parsed_tree(file);
Some(tokens_for_tree(&root, text, encoding))
}));
match cached {
Ok(Some(tokens)) => tokens,
Ok(None) | Err(_) => compute_semantic_tokens(text, encoding),
}
}
fn tokens_for_tree(root: &SyntaxNode, text: &str, encoding: PositionEncoding) -> SemanticTokens {
let mut spans: Vec<(TextRange, HighlightKind)> = Vec::new();
for token in root
.descendants_with_tokens()
.filter_map(|el| el.into_token())
{
let Some(kind) = classify(&token) else {
continue;
};
match spans.last_mut() {
Some((range, last)) if *last == kind && range.end() == token.text_range().start() => {
*range = TextRange::new(range.start(), token.text_range().end());
}
_ => spans.push((token.text_range(), kind)),
}
}
SemanticTokens {
result_id: None,
data: delta_encode(&spans, text, encoding),
}
}
fn classify(token: &SyntaxToken) -> Option<HighlightKind> {
match token.parent().map(|parent| parent.kind()) {
Some(SyntaxKind::MACRO_NAME) => classify_in_macro_name(token),
Some(SyntaxKind::STRING_LITERAL | SyntaxKind::CMD_LITERAL) => {
classify_in_string(token.kind())
}
_ => classify_by_kind(token.kind()),
}
}
fn classify_in_macro_name(token: &SyntaxToken) -> Option<HighlightKind> {
if token.kind() == SyntaxKind::AT {
return Some(HighlightKind::Macro);
}
if token.kind() != SyntaxKind::RPAREN && is_last_name_token(token) {
return Some(HighlightKind::Macro);
}
None
}
fn is_last_name_token(token: &SyntaxToken) -> bool {
let Some(parent) = token.parent() else {
return false;
};
parent
.children_with_tokens()
.filter_map(|el| el.into_token())
.filter(|t| !is_trivia(t.kind()))
.last()
.is_some_and(|last| last == *token)
}
fn classify_in_string(kind: SyntaxKind) -> Option<HighlightKind> {
match kind {
SyntaxKind::STRING_CONTENT
| SyntaxKind::STRING_DELIM_OPEN
| SyntaxKind::STRING_DELIM_CLOSE
| SyntaxKind::CMD_DELIM_OPEN
| SyntaxKind::CMD_DELIM_CLOSE => Some(HighlightKind::String),
SyntaxKind::STRING_PREFIX | SyntaxKind::STRING_SUFFIX => Some(HighlightKind::Macro),
_ => classify_by_kind(kind),
}
}
fn classify_by_kind(kind: SyntaxKind) -> Option<HighlightKind> {
if is_keyword(kind) {
return Some(HighlightKind::Keyword);
}
match kind {
SyntaxKind::CHAR => Some(HighlightKind::String),
SyntaxKind::INTEGER
| SyntaxKind::BIN_INT
| SyntaxKind::OCT_INT
| SyntaxKind::HEX_INT
| SyntaxKind::FLOAT
| SyntaxKind::FLOAT32 => Some(HighlightKind::Number),
_ => None,
}
}
fn is_keyword(kind: SyntaxKind) -> bool {
matches!(
kind,
SyntaxKind::FUNCTION_KW
| SyntaxKind::MACRO_KW
| SyntaxKind::END_KW
| SyntaxKind::IF_KW
| SyntaxKind::ELSEIF_KW
| SyntaxKind::ELSE_KW
| SyntaxKind::BEGIN_KW
| SyntaxKind::TRUE_KW
| SyntaxKind::FALSE_KW
| SyntaxKind::WHILE_KW
| SyntaxKind::FOR_KW
| SyntaxKind::LET_KW
| SyntaxKind::QUOTE_KW
| SyntaxKind::TRY_KW
| SyntaxKind::CATCH_KW
| SyntaxKind::FINALLY_KW
| SyntaxKind::STRUCT_KW
| SyntaxKind::MUTABLE_KW
| SyntaxKind::MODULE_KW
| SyntaxKind::BAREMODULE_KW
| SyntaxKind::DO_KW
| SyntaxKind::RETURN_KW
| SyntaxKind::BREAK_KW
| SyntaxKind::CONTINUE_KW
| SyntaxKind::CONST_KW
| SyntaxKind::GLOBAL_KW
| SyntaxKind::LOCAL_KW
| SyntaxKind::IMPORT_KW
| SyntaxKind::USING_KW
| SyntaxKind::EXPORT_KW
| SyntaxKind::WHERE_KW
)
}
fn is_trivia(kind: SyntaxKind) -> bool {
matches!(kind, SyntaxKind::WHITESPACE | SyntaxKind::NEWLINE)
}
fn delta_encode(
spans: &[(TextRange, HighlightKind)],
text: &str,
encoding: PositionEncoding,
) -> Vec<SemanticToken> {
let line_index = LineIndex::new(text);
let mut data = Vec::new();
let mut prev = Position::new(0, 0);
for &(range, kind) in spans {
for segment in split_at_line_breaks(range, text) {
let start = line_index.byte_to_position(segment.start().into(), encoding);
let end = line_index.byte_to_position(segment.end().into(), encoding);
debug_assert_eq!(start.line, end.line, "segments never span line breaks");
let delta_line = start.line - prev.line;
let delta_start = if delta_line == 0 {
start.character - prev.character
} else {
start.character
};
data.push(SemanticToken {
delta_line,
delta_start,
length: end.character - start.character,
token_type: kind as u32,
token_modifiers_bitset: 0,
});
prev = start;
}
}
data
}
fn split_at_line_breaks(range: TextRange, text: &str) -> Vec<TextRange> {
let base = usize::from(range.start());
let slice = &text[base..usize::from(range.end())];
let mut segments = Vec::new();
let mut push = |from: usize, to: usize| {
let to = if slice.as_bytes()[from..to].last() == Some(&b'\r') {
to - 1
} else {
to
};
if to > from {
segments.push(TextRange::new(
TextSize::new((base + from) as u32),
TextSize::new((base + to) as u32),
));
}
};
let mut start = 0;
for (i, byte) in slice.bytes().enumerate() {
if byte == b'\n' {
push(start, i);
start = i + 1;
}
}
push(start, slice.len());
segments
}
#[cfg(test)]
mod tests {
use super::*;
use crate::incremental::IncrementalDatabase;
#[test]
fn legend_order_matches_highlight_kind_discriminants() {
let legend = legend();
for (kind, token_type) in [
(HighlightKind::Keyword, SemanticTokenType::KEYWORD),
(HighlightKind::Macro, SemanticTokenType::MACRO),
(HighlightKind::String, SemanticTokenType::STRING),
(HighlightKind::Number, SemanticTokenType::NUMBER),
] {
assert_eq!(legend.token_types[kind as usize], token_type);
}
assert_eq!(legend.token_types.len(), 4, "every kind is in the legend");
assert!(legend.token_modifiers.is_empty());
}
#[test]
fn semantic_tokens_via_db_match_compute_and_fall_back() {
let path = Path::new("/work/a.jl");
let buffer = "function f(x)\n @show x + 1\nend\n";
let expected = compute_semantic_tokens(buffer, PositionEncoding::Utf8);
assert!(!expected.data.is_empty(), "fixture must yield tokens");
let mut db = IncrementalDatabase::default();
db.upsert_file(path, buffer.to_string());
assert_eq!(
semantic_tokens_via_db(&db.snapshot(), path, buffer, PositionEncoding::Utf8),
expected,
"cached-tree tokens must match the re-parse path"
);
let mut stale = IncrementalDatabase::default();
stale.upsert_file(path, "y = 1\n".to_string());
assert_eq!(
semantic_tokens_via_db(&stale.snapshot(), path, buffer, PositionEncoding::Utf8),
expected,
"version skew must fall back to the buffer text"
);
let empty = IncrementalDatabase::default();
assert_eq!(
semantic_tokens_via_db(&empty.snapshot(), path, buffer, PositionEncoding::Utf8),
expected,
"untracked path must fall back to the buffer text"
);
}
}