use salsa::Setter;
use dashmap::DashMap;
use lsp_types_max::{
Diagnostic, DiagnosticSeverity, DidChangeTextDocumentParams, DidCloseTextDocumentParams,
DidOpenTextDocumentParams, DocumentUri, NumberOrString, PositionEncodingKind,
};
use parking_lot::Mutex;
use tree_sitter::Parser;
use crate::core::document::Document;
#[salsa::input]
pub struct SourceFile {
pub uri: String,
pub text: String,
pub encoding: PositionEncodingKind,
}
#[salsa::db]
pub trait LspDb: salsa::Database {
fn language(&self) -> tree_sitter::Language;
}
#[derive(Debug, Clone, PartialEq, salsa::Update)]
pub struct SalsaDiag {
pub start_line: u32,
pub start_char: u32,
pub end_line: u32,
pub end_char: u32,
pub message: String,
}
impl From<&SalsaDiag> for Diagnostic {
fn from(d: &SalsaDiag) -> Diagnostic {
Diagnostic {
range: lsp_types_max::Range {
start: lsp_types_max::Position {
line: d.start_line,
character: d.start_char,
},
end: lsp_types_max::Position {
line: d.end_line,
character: d.end_char,
},
},
severity: Some(DiagnosticSeverity::ERROR),
code: Some(NumberOrString::String("AST_ERROR".to_string())),
source: Some("lsp-max-ast".to_string()),
message: d.message.clone(),
..Default::default()
}
}
}
fn parse_doc_for_language(
language: &tree_sitter::Language,
text: &str,
encoding: &PositionEncodingKind,
) -> Option<Document> {
let mut parser = Parser::new();
parser.set_language(language).ok()?;
let tree = parser.parse(text.as_bytes(), None)?;
let enc_ref: Option<&PositionEncodingKind> = match encoding.as_str() {
"utf-8" => Some(&PositionEncodingKind::UTF8),
"utf-32" => Some(&PositionEncodingKind::UTF32),
_ => None,
};
Some(Document::new(text.to_owned(), tree, enc_ref))
}
#[salsa::tracked]
pub fn ast_diagnostics(db: &dyn LspDb, source_file: SourceFile) -> Vec<SalsaDiag> {
let text = source_file.text(db);
let encoding = source_file.encoding(db);
let language = db.language();
let Some(doc) = parse_doc_for_language(&language, &text, &encoding) else {
return Vec::new();
};
let mut diags = Vec::new();
let mut queue = vec![doc.tree.root_node()];
while let Some(node) = queue.pop() {
if node.is_error() || node.is_missing() {
let range = doc.denormalize_range(&node.range()).unwrap_or_default();
diags.push(SalsaDiag {
start_line: range.start.line,
start_char: range.start.character,
end_line: range.end.line,
end_char: range.end.character,
message: "Syntax error detected by formal parser.".to_string(),
});
}
for i in 0..node.child_count() as u32 {
if let Some(child) = node.child(i) {
queue.push(child);
}
}
}
diags
}
pub fn parse_document(db: &dyn LspDb, source_file: SourceFile) -> Option<Document> {
let text = source_file.text(db);
let encoding = source_file.encoding(db);
parse_doc_for_language(&db.language(), &text, &encoding)
}
#[salsa::db]
#[derive(Clone)]
pub struct LspMaxDb {
storage: salsa::Storage<Self>,
language: tree_sitter::Language,
}
impl LspMaxDb {
pub fn new(language: tree_sitter::Language) -> Self {
Self {
storage: salsa::Storage::default(),
language,
}
}
}
#[salsa::db]
impl salsa::Database for LspMaxDb {}
#[salsa::db]
impl LspDb for LspMaxDb {
fn language(&self) -> tree_sitter::Language {
self.language.clone()
}
}
pub struct SalsaLspAdapter {
db: Mutex<LspMaxDb>,
inputs: DashMap<DocumentUri, SourceFile>,
documents: DashMap<DocumentUri, Mutex<Document>>,
}
impl SalsaLspAdapter {
pub fn new(language: tree_sitter::Language) -> Self {
Self {
db: Mutex::new(LspMaxDb::new(language)),
inputs: DashMap::new(),
documents: DashMap::new(),
}
}
pub fn handle_did_open(&self, params: DidOpenTextDocumentParams) {
let uri = params.text_document.uri;
let text = params.text_document.text;
let db = self.db.lock();
let encoding = PositionEncodingKind::UTF16; let source = SourceFile::new(&*db, uri.to_string(), text.clone(), encoding.clone());
let language = db.language();
drop(db);
if let Some(doc) = parse_doc_for_language(&language, &text, &encoding) {
self.documents.insert(uri.clone(), Mutex::new(doc));
}
self.inputs.insert(uri, source);
}
pub fn handle_did_change(&self, params: DidChangeTextDocumentParams) {
let uri = params.text_document.uri;
let source = match self.inputs.get(&uri) {
Some(r) => *r,
None => return,
};
let mut db = self.db.lock();
let language = db.language();
let mut parser = Parser::new();
if parser.set_language(&language).is_err() {
return;
}
let new_text = match self.documents.get(&uri) {
Some(entry) => {
let mut doc = entry.value().lock();
if doc.update(&mut parser, ¶ms.content_changes).is_err() {
return;
}
doc.as_str().to_string()
}
None => {
let current_text: String = source.text(&*db).clone();
let encoding: PositionEncodingKind = source.encoding(&*db);
let enc_ref: Option<&PositionEncodingKind> = match encoding.as_str() {
"utf-8" => Some(&PositionEncodingKind::UTF8),
"utf-32" => Some(&PositionEncodingKind::UTF32),
_ => None,
};
let Some(tree) = parser.parse(current_text.as_bytes(), None) else {
return;
};
let mut doc = Document::new(current_text, tree, enc_ref);
if doc.update(&mut parser, ¶ms.content_changes).is_err() {
return;
}
let text = doc.as_str().to_string();
self.documents.insert(uri.clone(), Mutex::new(doc));
text
}
};
source.set_text(&mut *db).to(new_text);
}
pub fn handle_did_close(&self, params: DidCloseTextDocumentParams) {
let uri = params.text_document.uri;
self.inputs.remove(&uri);
self.documents.remove(&uri);
}
pub fn pull_diagnostics(&self, uri: &DocumentUri) -> Vec<Diagnostic> {
let source = match self.inputs.get(uri) {
Some(r) => *r,
None => return Vec::new(),
};
let db = self.db.lock();
ast_diagnostics(&*db, source)
.iter()
.map(Diagnostic::from)
.collect()
}
pub fn get_document<F, R>(&self, uri: &DocumentUri, f: F) -> Option<R>
where
F: FnOnce(&Document) -> R,
{
if let Some(entry) = self.documents.get(uri) {
let doc = entry.value().lock();
return Some(f(&doc));
}
let source = match self.inputs.get(uri) {
Some(r) => *r,
None => return None,
};
let db = self.db.lock();
parse_document(&*db, source).map(|doc| f(&doc))
}
}
#[cfg(test)]
mod tests {
use super::*;
use lsp_types_max::{TextDocumentContentChangeEvent, VersionedTextDocumentIdentifier};
fn html_language() -> tree_sitter::Language {
tree_sitter_html::LANGUAGE.into()
}
#[test]
fn did_open_sets_salsa_source_file() {
let adapter = SalsaLspAdapter::new(html_language());
let uri: DocumentUri = "file:///test.html".parse().unwrap();
adapter.handle_did_open(DidOpenTextDocumentParams {
text_document: lsp_types_max::TextDocumentItem {
uri: uri.clone(),
language_id: "html".to_string(),
version: 1,
text: "<p>hello</p>".to_string(),
},
});
assert!(
adapter.inputs.contains_key(&uri),
"SourceFile not registered after did_open"
);
let source = *adapter.inputs.get(&uri).unwrap();
let db = adapter.db.lock();
assert_eq!(source.text(&*db), "<p>hello</p>");
}
#[test]
fn diagnostics_cached_when_file_unchanged() {
let adapter = SalsaLspAdapter::new(html_language());
let uri: DocumentUri = "file:///stable.html".parse().unwrap();
adapter.handle_did_open(DidOpenTextDocumentParams {
text_document: lsp_types_max::TextDocumentItem {
uri: uri.clone(),
language_id: "html".to_string(),
version: 1,
text: "<p>ok</p>".to_string(),
},
});
let d1 = adapter.pull_diagnostics(&uri);
let d2 = adapter.pull_diagnostics(&uri);
assert_eq!(d1, d2, "cached result must equal first result");
assert!(d1.is_empty(), "valid HTML should have no diagnostics");
}
#[test]
fn diagnostics_recomputed_when_file_changed() {
let adapter = SalsaLspAdapter::new(html_language());
let uri: DocumentUri = "file:///change.html".parse().unwrap();
adapter.handle_did_open(DidOpenTextDocumentParams {
text_document: lsp_types_max::TextDocumentItem {
uri: uri.clone(),
language_id: "html".to_string(),
version: 1,
text: "<p>hello</p>".to_string(),
},
});
assert!(
adapter.pull_diagnostics(&uri).is_empty(),
"should be clean initially"
);
adapter.handle_did_change(DidChangeTextDocumentParams {
text_document: VersionedTextDocumentIdentifier {
uri: uri.clone(),
version: 2,
},
content_changes: vec![TextDocumentContentChangeEvent {
range: None,
range_length: None,
text: "<<<INVALID>>>".to_string(),
}],
});
let diags = adapter.pull_diagnostics(&uri);
assert!(
!diags.is_empty(),
"broken HTML must produce AST_ERROR diagnostics; got none"
);
}
#[test]
fn did_change_invalidates_only_changed_uri() {
let adapter = SalsaLspAdapter::new(html_language());
let uri_a: DocumentUri = "file:///a.html".parse().unwrap();
let uri_b: DocumentUri = "file:///b.html".parse().unwrap();
adapter.handle_did_open(DidOpenTextDocumentParams {
text_document: lsp_types_max::TextDocumentItem {
uri: uri_a.clone(),
language_id: "html".to_string(),
version: 1,
text: "<p>A</p>".to_string(),
},
});
adapter.handle_did_open(DidOpenTextDocumentParams {
text_document: lsp_types_max::TextDocumentItem {
uri: uri_b.clone(),
language_id: "html".to_string(),
version: 1,
text: "<p>B</p>".to_string(),
},
});
let b_diags_before = adapter.pull_diagnostics(&uri_b);
adapter.handle_did_change(DidChangeTextDocumentParams {
text_document: VersionedTextDocumentIdentifier {
uri: uri_a.clone(),
version: 2,
},
content_changes: vec![TextDocumentContentChangeEvent {
range: None,
range_length: None,
text: "<<<BAD>>>".to_string(),
}],
});
let a_diags = adapter.pull_diagnostics(&uri_a);
let b_diags_after = adapter.pull_diagnostics(&uri_b);
assert!(
!a_diags.is_empty(),
"file A must have errors after corrupt change"
);
assert_eq!(b_diags_before, b_diags_after, "file B must be unaffected");
}
#[test]
fn stale_diagnostics_are_refused() {
let adapter = SalsaLspAdapter::new(html_language());
let uri: DocumentUri = "file:///ghost.html".parse().unwrap();
let diags = adapter.pull_diagnostics(&uri);
assert!(
diags.is_empty(),
"unknown URI must return no diagnostics; got {diags:?}"
);
adapter.handle_did_open(DidOpenTextDocumentParams {
text_document: lsp_types_max::TextDocumentItem {
uri: uri.clone(),
language_id: "html".to_string(),
version: 1,
text: "<p>ok</p>".to_string(),
},
});
adapter.handle_did_close(DidCloseTextDocumentParams {
text_document: lsp_types_max::TextDocumentIdentifier { uri: uri.clone() },
});
let diags_after_close = adapter.pull_diagnostics(&uri);
assert!(
diags_after_close.is_empty(),
"closed URI must return no diagnostics; got {diags_after_close:?}"
);
}
#[test]
fn did_change_reuses_previous_tree_incrementally() {
let adapter = SalsaLspAdapter::new(html_language());
let uri: DocumentUri = "file:///incremental.html".parse().unwrap();
let initial = "<div><p>one</p><p>two</p><p>three</p></div>";
adapter.handle_did_open(DidOpenTextDocumentParams {
text_document: lsp_types_max::TextDocumentItem {
uri: uri.clone(),
language_id: "html".to_string(),
version: 1,
text: initial.to_string(),
},
});
let old_tree = adapter
.get_document(&uri, |doc| doc.tree.clone())
.expect("document must be cached after did_open");
adapter.handle_did_change(DidChangeTextDocumentParams {
text_document: VersionedTextDocumentIdentifier {
uri: uri.clone(),
version: 2,
},
content_changes: vec![TextDocumentContentChangeEvent {
range: Some(lsp_types_max::Range {
start: lsp_types_max::Position {
line: 0,
character: 9,
},
end: lsp_types_max::Position {
line: 0,
character: 9,
},
}),
range_length: None,
text: "X".to_string(),
}],
});
let new_tree = adapter
.get_document(&uri, |doc| doc.tree.clone())
.expect("document must still be cached after did_change");
let changed = old_tree.changed_ranges(&new_tree).collect::<Vec<_>>();
assert!(
!changed.is_empty(),
"a real edit must report at least one changed range"
);
for range in &changed {
assert!(
range.end_byte <= initial.len() + 20,
"changed range {range:?} spans far more than the single-\
character edit site — looks like a full-document reparse, \
not an incremental one"
);
}
}
}