1use salsa::Setter;
8
9use dashmap::DashMap;
10use lsp_types_max::{
11 Diagnostic, DiagnosticSeverity, DidChangeTextDocumentParams, DidCloseTextDocumentParams,
12 DidOpenTextDocumentParams, DocumentUri, NumberOrString, PositionEncodingKind,
13};
14use parking_lot::Mutex;
15use tree_sitter::Parser;
16
17use crate::core::document::Document;
18
19#[salsa::input]
28pub struct SourceFile {
29 pub uri: String,
30 pub text: String,
31 pub encoding: PositionEncodingKind,
32}
33
34#[salsa::db]
44pub trait LspDb: salsa::Database {
45 fn language(&self) -> tree_sitter::Language;
46}
47
48#[derive(Debug, Clone, PartialEq, salsa::Update)]
58pub struct SalsaDiag {
59 pub start_line: u32,
60 pub start_char: u32,
61 pub end_line: u32,
62 pub end_char: u32,
63 pub message: String,
64}
65
66impl From<&SalsaDiag> for Diagnostic {
67 fn from(d: &SalsaDiag) -> Diagnostic {
68 Diagnostic {
69 range: lsp_types_max::Range {
70 start: lsp_types_max::Position {
71 line: d.start_line,
72 character: d.start_char,
73 },
74 end: lsp_types_max::Position {
75 line: d.end_line,
76 character: d.end_char,
77 },
78 },
79 severity: Some(DiagnosticSeverity::ERROR),
80 code: Some(NumberOrString::String("AST_ERROR".to_string())),
81 source: Some("lsp-max-ast".to_string()),
82 message: d.message.clone(),
83 ..Default::default()
84 }
85 }
86}
87
88fn parse_doc_for_language(
95 language: &tree_sitter::Language,
96 text: &str,
97 encoding: &PositionEncodingKind,
98) -> Option<Document> {
99 let mut parser = Parser::new();
100 parser.set_language(language).ok()?;
101 let tree = parser.parse(text.as_bytes(), None)?;
102 let enc_ref: Option<&PositionEncodingKind> = match encoding.as_str() {
103 "utf-8" => Some(&PositionEncodingKind::UTF8),
104 "utf-32" => Some(&PositionEncodingKind::UTF32),
105 _ => None,
106 };
107 Some(Document::new(text.to_owned(), tree, enc_ref))
108}
109
110#[salsa::tracked]
122pub fn ast_diagnostics(db: &dyn LspDb, source_file: SourceFile) -> Vec<SalsaDiag> {
123 let text = source_file.text(db);
124 let encoding = source_file.encoding(db);
125 let language = db.language();
126
127 let Some(doc) = parse_doc_for_language(&language, &text, &encoding) else {
128 return Vec::new();
129 };
130
131 let mut diags = Vec::new();
132 let mut queue = vec![doc.tree.root_node()];
133 while let Some(node) = queue.pop() {
134 if node.is_error() || node.is_missing() {
135 let range = doc.denormalize_range(&node.range()).unwrap_or_default();
136 diags.push(SalsaDiag {
137 start_line: range.start.line,
138 start_char: range.start.character,
139 end_line: range.end.line,
140 end_char: range.end.character,
141 message: "Syntax error detected by formal parser.".to_string(),
142 });
143 }
144 for i in 0..node.child_count() as u32 {
145 if let Some(child) = node.child(i) {
146 queue.push(child);
147 }
148 }
149 }
150 diags
151}
152
153pub fn parse_document(db: &dyn LspDb, source_file: SourceFile) -> Option<Document> {
156 let text = source_file.text(db);
157 let encoding = source_file.encoding(db);
158 parse_doc_for_language(&db.language(), &text, &encoding)
159}
160
161#[salsa::db]
170#[derive(Clone)]
171pub struct LspMaxDb {
172 storage: salsa::Storage<Self>,
173 language: tree_sitter::Language,
174}
175
176impl LspMaxDb {
177 pub fn new(language: tree_sitter::Language) -> Self {
178 Self {
179 storage: salsa::Storage::default(),
180 language,
181 }
182 }
183}
184
185#[salsa::db]
186impl salsa::Database for LspMaxDb {}
187
188#[salsa::db]
189impl LspDb for LspMaxDb {
190 fn language(&self) -> tree_sitter::Language {
191 self.language.clone()
192 }
193}
194
195pub struct SalsaLspAdapter {
216 db: Mutex<LspMaxDb>,
217 inputs: DashMap<DocumentUri, SourceFile>,
218 documents: DashMap<DocumentUri, Mutex<Document>>,
219}
220
221impl SalsaLspAdapter {
222 pub fn new(language: tree_sitter::Language) -> Self {
223 Self {
224 db: Mutex::new(LspMaxDb::new(language)),
225 inputs: DashMap::new(),
226 documents: DashMap::new(),
227 }
228 }
229
230 pub fn handle_did_open(&self, params: DidOpenTextDocumentParams) {
232 let uri = params.text_document.uri;
233 let text = params.text_document.text;
234 let db = self.db.lock();
235 let encoding = PositionEncodingKind::UTF16; let source = SourceFile::new(&*db, uri.to_string(), text.clone(), encoding.clone());
237 let language = db.language();
238 drop(db);
239
240 if let Some(doc) = parse_doc_for_language(&language, &text, &encoding) {
241 self.documents.insert(uri.clone(), Mutex::new(doc));
242 }
243 self.inputs.insert(uri, source);
244 }
245
246 pub fn handle_did_change(&self, params: DidChangeTextDocumentParams) {
255 let uri = params.text_document.uri;
256
257 let source = match self.inputs.get(&uri) {
258 Some(r) => *r,
259 None => return,
260 };
261
262 let mut db = self.db.lock();
263 let language = db.language();
264
265 let mut parser = Parser::new();
266 if parser.set_language(&language).is_err() {
267 return;
268 }
269
270 let new_text = match self.documents.get(&uri) {
271 Some(entry) => {
272 let mut doc = entry.value().lock();
273 if doc.update(&mut parser, ¶ms.content_changes).is_err() {
274 return;
275 }
276 doc.as_str().to_string()
277 }
278 None => {
279 let current_text: String = source.text(&*db).clone();
282 let encoding: PositionEncodingKind = source.encoding(&*db);
283 let enc_ref: Option<&PositionEncodingKind> = match encoding.as_str() {
284 "utf-8" => Some(&PositionEncodingKind::UTF8),
285 "utf-32" => Some(&PositionEncodingKind::UTF32),
286 _ => None,
287 };
288 let Some(tree) = parser.parse(current_text.as_bytes(), None) else {
289 return;
290 };
291 let mut doc = Document::new(current_text, tree, enc_ref);
292 if doc.update(&mut parser, ¶ms.content_changes).is_err() {
293 return;
294 }
295 let text = doc.as_str().to_string();
296 self.documents.insert(uri.clone(), Mutex::new(doc));
297 text
298 }
299 };
300
301 source.set_text(&mut *db).to(new_text);
302 }
303
304 pub fn handle_did_close(&self, params: DidCloseTextDocumentParams) {
306 let uri = params.text_document.uri;
307 self.inputs.remove(&uri);
308 self.documents.remove(&uri);
309 }
310
311 pub fn pull_diagnostics(&self, uri: &DocumentUri) -> Vec<Diagnostic> {
314 let source = match self.inputs.get(uri) {
315 Some(r) => *r,
316 None => return Vec::new(),
317 };
318 let db = self.db.lock();
319 ast_diagnostics(&*db, source)
320 .iter()
321 .map(Diagnostic::from)
322 .collect()
323 }
324
325 pub fn get_document<F, R>(&self, uri: &DocumentUri, f: F) -> Option<R>
331 where
332 F: FnOnce(&Document) -> R,
333 {
334 if let Some(entry) = self.documents.get(uri) {
335 let doc = entry.value().lock();
336 return Some(f(&doc));
337 }
338 let source = match self.inputs.get(uri) {
339 Some(r) => *r,
340 None => return None,
341 };
342 let db = self.db.lock();
343 parse_document(&*db, source).map(|doc| f(&doc))
344 }
345}
346
347#[cfg(test)]
352mod tests {
353 use super::*;
354 use lsp_types_max::{TextDocumentContentChangeEvent, VersionedTextDocumentIdentifier};
355
356 fn html_language() -> tree_sitter::Language {
357 tree_sitter_html::LANGUAGE.into()
358 }
359
360 #[test]
364 fn did_open_sets_salsa_source_file() {
365 let adapter = SalsaLspAdapter::new(html_language());
366 let uri: DocumentUri = "file:///test.html".parse().unwrap();
367
368 adapter.handle_did_open(DidOpenTextDocumentParams {
369 text_document: lsp_types_max::TextDocumentItem {
370 uri: uri.clone(),
371 language_id: "html".to_string(),
372 version: 1,
373 text: "<p>hello</p>".to_string(),
374 },
375 });
376
377 assert!(
379 adapter.inputs.contains_key(&uri),
380 "SourceFile not registered after did_open"
381 );
382
383 let source = *adapter.inputs.get(&uri).unwrap();
385 let db = adapter.db.lock();
386 assert_eq!(source.text(&*db), "<p>hello</p>");
387 }
388
389 #[test]
394 fn diagnostics_cached_when_file_unchanged() {
395 let adapter = SalsaLspAdapter::new(html_language());
396 let uri: DocumentUri = "file:///stable.html".parse().unwrap();
397
398 adapter.handle_did_open(DidOpenTextDocumentParams {
399 text_document: lsp_types_max::TextDocumentItem {
400 uri: uri.clone(),
401 language_id: "html".to_string(),
402 version: 1,
403 text: "<p>ok</p>".to_string(),
404 },
405 });
406
407 let d1 = adapter.pull_diagnostics(&uri);
408 let d2 = adapter.pull_diagnostics(&uri);
409 assert_eq!(d1, d2, "cached result must equal first result");
410 assert!(d1.is_empty(), "valid HTML should have no diagnostics");
411 }
412
413 #[test]
418 fn diagnostics_recomputed_when_file_changed() {
419 let adapter = SalsaLspAdapter::new(html_language());
420 let uri: DocumentUri = "file:///change.html".parse().unwrap();
421
422 adapter.handle_did_open(DidOpenTextDocumentParams {
423 text_document: lsp_types_max::TextDocumentItem {
424 uri: uri.clone(),
425 language_id: "html".to_string(),
426 version: 1,
427 text: "<p>hello</p>".to_string(),
428 },
429 });
430 assert!(
431 adapter.pull_diagnostics(&uri).is_empty(),
432 "should be clean initially"
433 );
434
435 adapter.handle_did_change(DidChangeTextDocumentParams {
437 text_document: VersionedTextDocumentIdentifier {
438 uri: uri.clone(),
439 version: 2,
440 },
441 content_changes: vec![TextDocumentContentChangeEvent {
442 range: None,
443 range_length: None,
444 text: "<<<INVALID>>>".to_string(),
445 }],
446 });
447
448 let diags = adapter.pull_diagnostics(&uri);
449 assert!(
450 !diags.is_empty(),
451 "broken HTML must produce AST_ERROR diagnostics; got none"
452 );
453 }
454
455 #[test]
460 fn did_change_invalidates_only_changed_uri() {
461 let adapter = SalsaLspAdapter::new(html_language());
462
463 let uri_a: DocumentUri = "file:///a.html".parse().unwrap();
464 let uri_b: DocumentUri = "file:///b.html".parse().unwrap();
465
466 adapter.handle_did_open(DidOpenTextDocumentParams {
467 text_document: lsp_types_max::TextDocumentItem {
468 uri: uri_a.clone(),
469 language_id: "html".to_string(),
470 version: 1,
471 text: "<p>A</p>".to_string(),
472 },
473 });
474 adapter.handle_did_open(DidOpenTextDocumentParams {
475 text_document: lsp_types_max::TextDocumentItem {
476 uri: uri_b.clone(),
477 language_id: "html".to_string(),
478 version: 1,
479 text: "<p>B</p>".to_string(),
480 },
481 });
482
483 let b_diags_before = adapter.pull_diagnostics(&uri_b);
484
485 adapter.handle_did_change(DidChangeTextDocumentParams {
487 text_document: VersionedTextDocumentIdentifier {
488 uri: uri_a.clone(),
489 version: 2,
490 },
491 content_changes: vec![TextDocumentContentChangeEvent {
492 range: None,
493 range_length: None,
494 text: "<<<BAD>>>".to_string(),
495 }],
496 });
497
498 let a_diags = adapter.pull_diagnostics(&uri_a);
499 let b_diags_after = adapter.pull_diagnostics(&uri_b);
500
501 assert!(
502 !a_diags.is_empty(),
503 "file A must have errors after corrupt change"
504 );
505 assert_eq!(b_diags_before, b_diags_after, "file B must be unaffected");
506 }
507
508 #[test]
515 fn stale_diagnostics_are_refused() {
516 let adapter = SalsaLspAdapter::new(html_language());
517 let uri: DocumentUri = "file:///ghost.html".parse().unwrap();
518
519 let diags = adapter.pull_diagnostics(&uri);
521 assert!(
522 diags.is_empty(),
523 "unknown URI must return no diagnostics; got {diags:?}"
524 );
525
526 adapter.handle_did_open(DidOpenTextDocumentParams {
528 text_document: lsp_types_max::TextDocumentItem {
529 uri: uri.clone(),
530 language_id: "html".to_string(),
531 version: 1,
532 text: "<p>ok</p>".to_string(),
533 },
534 });
535 adapter.handle_did_close(DidCloseTextDocumentParams {
536 text_document: lsp_types_max::TextDocumentIdentifier { uri: uri.clone() },
537 });
538
539 let diags_after_close = adapter.pull_diagnostics(&uri);
540 assert!(
541 diags_after_close.is_empty(),
542 "closed URI must return no diagnostics; got {diags_after_close:?}"
543 );
544 }
545
546 #[test]
560 fn did_change_reuses_previous_tree_incrementally() {
561 let adapter = SalsaLspAdapter::new(html_language());
562 let uri: DocumentUri = "file:///incremental.html".parse().unwrap();
563
564 let initial = "<div><p>one</p><p>two</p><p>three</p></div>";
565 adapter.handle_did_open(DidOpenTextDocumentParams {
566 text_document: lsp_types_max::TextDocumentItem {
567 uri: uri.clone(),
568 language_id: "html".to_string(),
569 version: 1,
570 text: initial.to_string(),
571 },
572 });
573
574 let old_tree = adapter
575 .get_document(&uri, |doc| doc.tree.clone())
576 .expect("document must be cached after did_open");
577
578 adapter.handle_did_change(DidChangeTextDocumentParams {
581 text_document: VersionedTextDocumentIdentifier {
582 uri: uri.clone(),
583 version: 2,
584 },
585 content_changes: vec![TextDocumentContentChangeEvent {
586 range: Some(lsp_types_max::Range {
587 start: lsp_types_max::Position {
588 line: 0,
589 character: 9,
590 },
591 end: lsp_types_max::Position {
592 line: 0,
593 character: 9,
594 },
595 }),
596 range_length: None,
597 text: "X".to_string(),
598 }],
599 });
600
601 let new_tree = adapter
602 .get_document(&uri, |doc| doc.tree.clone())
603 .expect("document must still be cached after did_change");
604
605 let changed = old_tree.changed_ranges(&new_tree).collect::<Vec<_>>();
606 assert!(
607 !changed.is_empty(),
608 "a real edit must report at least one changed range"
609 );
610 for range in &changed {
611 assert!(
612 range.end_byte <= initial.len() + 20,
613 "changed range {range:?} spans far more than the single-\
614 character edit site — looks like a full-document reparse, \
615 not an incremental one"
616 );
617 }
618 }
619}