Skip to main content

lsp_max_ast/
db.rs

1//! Salsa 0.26 incremental computation database for `lsp-max-ast`.
2//!
3//! This module wires the Salsa incremental engine so that
4//! `ast_diagnostics` is only re-executed when the underlying
5//! `SourceFile` text actually changes.
6
7use 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// ---------------------------------------------------------------------------
20// Input
21// ---------------------------------------------------------------------------
22
23/// A source file tracked by the Salsa incremental engine.
24///
25/// Each field change bumps the revision, causing dependent tracked
26/// functions to be re-evaluated on next access.
27#[salsa::input]
28pub struct SourceFile {
29    pub uri: String,
30    pub text: String,
31    pub encoding: PositionEncodingKind,
32}
33
34// ---------------------------------------------------------------------------
35// Trait database
36// ---------------------------------------------------------------------------
37
38/// Trait implemented by all Salsa databases that host the AST layer.
39///
40/// `language()` returns the tree-sitter grammar to be used for parsing.
41/// It is an untracked field on the database; callers must not call this
42/// inside a tracked query if they need deterministic re-execution.
43#[salsa::db]
44pub trait LspDb: salsa::Database {
45    fn language(&self) -> tree_sitter::Language;
46}
47
48// ---------------------------------------------------------------------------
49// Serialisable diagnostic used inside Salsa tracked queries
50// ---------------------------------------------------------------------------
51
52/// A lightweight diagnostic record that Salsa can compare and memoize.
53///
54/// `Vec<SalsaDiag>` is the return type of `ast_diagnostics`, which is the
55/// tracked function. This is a separate type from `lsp_types_max::Diagnostic`
56/// because `Diagnostic` does not implement `salsa::Update`.
57#[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
88// ---------------------------------------------------------------------------
89// Plain (non-tracked) parse helper
90// ---------------------------------------------------------------------------
91
92/// Parse a source text and return a `Document`.  Not tracked by Salsa directly;
93/// called from within tracked functions.
94fn 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// ---------------------------------------------------------------------------
111// Tracked queries
112// ---------------------------------------------------------------------------
113
114/// Parse the document text for a `SourceFile` and collect all AST errors as
115/// `SalsaDiag` records.
116///
117/// This is the only `#[salsa::tracked]` query in this crate.  It is memoised:
118/// Salsa only re-executes it when `source_file.text` or `source_file.encoding`
119/// changes.  `Vec<SalsaDiag>` satisfies `salsa::Update` via the blanket `Vec`
120/// impl (which requires `T: Update`; `SalsaDiag` derives `Update`).
121#[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
153/// Parse a `SourceFile` and return the document.  Not tracked by Salsa;
154/// callers that need memoized access should use `ast_diagnostics`.
155pub 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// ---------------------------------------------------------------------------
162// Concrete database
163// ---------------------------------------------------------------------------
164
165/// Concrete Salsa database for the `lsp-max-ast` crate.
166///
167/// The `language` field is stored as plain data (untracked in Salsa terms)
168/// because the grammar is fixed for the lifetime of a server session.
169#[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
195// ---------------------------------------------------------------------------
196// SalsaLspAdapter
197// ---------------------------------------------------------------------------
198
199/// An LSP adapter backed by the Salsa incremental engine.
200///
201/// Wraps a `LspMaxDb` and a `DashMap` that maps each open URI to its
202/// `SourceFile` input handle.  On `handle_did_change`, only the `text`
203/// field is updated; Salsa automatically invalidates the memoised
204/// `ast_diagnostics` result.
205///
206/// A separate `documents` cache holds the live, tree-sitter-incremental
207/// `Document` (text + `Tree`) per URI. `SourceFile` cannot hold the `Tree`
208/// itself (it isn't `salsa::Update`), so without this cache there would be
209/// nowhere to keep the previous tree between edits — every
210/// `handle_did_change` would have to reparse the whole document from
211/// scratch before applying the incremental edit, defeating the point of
212/// `Tree::edit`. `ast_diagnostics`'s Salsa-level memoization (skip
213/// recompute when `text` is unchanged) is a separate, still-valid
214/// optimization layer and is untouched by this cache.
215pub 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    /// Register a newly-opened document in the incremental database.
231    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; // LSP default
236        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    /// Apply incremental edits and bump the `text` input in Salsa.
247    ///
248    /// Applies the LSP content changes to the cached `Document` in place —
249    /// a real `Tree::edit` against the *previous* tree followed by an
250    /// incremental reparse — then calls `set_text` to inform Salsa that the
251    /// text has changed. Falls back to a from-scratch parse only if no
252    /// cached `Document` exists yet for this URI (e.g. a `didChange` that
253    /// raced ahead of `didOpen`).
254    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, &params.content_changes).is_err() {
274                    return;
275                }
276                doc.as_str().to_string()
277            }
278            None => {
279                // No cached Document yet — fall back to a from-scratch parse
280                // of the current Salsa text, same as before this cache existed.
281                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, &params.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    /// Remove a closed document from the incremental database.
305    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    /// Pull diagnostics for a URI as `lsp_types_max::Diagnostic`, using
312    /// the Salsa cache where possible.
313    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    /// Read-only access to the parsed `Document` for a URI.
326    ///
327    /// Reads the incrementally-maintained `documents` cache directly rather
328    /// than reparsing from scratch; falls back to a fresh parse of the
329    /// current Salsa text only if no cached `Document` exists yet.
330    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// ---------------------------------------------------------------------------
348// Tests
349// ---------------------------------------------------------------------------
350
351#[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    // ------------------------------------------------------------------
361    // TRUE: did_open_sets_salsa_source_file
362    // ------------------------------------------------------------------
363    #[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        // After open, the URI must be tracked.
378        assert!(
379            adapter.inputs.contains_key(&uri),
380            "SourceFile not registered after did_open"
381        );
382
383        // Text is readable from the db.
384        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    // ------------------------------------------------------------------
390    // TRUE: diagnostics_cached_when_file_unchanged
391    //       Call pull_diagnostics twice; expect same result.
392    // ------------------------------------------------------------------
393    #[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    // ------------------------------------------------------------------
414    // TRUE: diagnostics_recomputed_when_file_changed
415    //       Introduce a syntax error; verify a new diagnostic appears.
416    // ------------------------------------------------------------------
417    #[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        // Replace entire document with broken HTML.
436        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    // ------------------------------------------------------------------
456    // TRUE + COUNTERFACTUAL: did_change_invalidates_only_changed_uri
457    //       Change file A; verify file B diagnostics are unaffected.
458    // ------------------------------------------------------------------
459    #[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        // Corrupt file A only.
486        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    // ------------------------------------------------------------------
509    // FALSE (stale diagnostics are refused):
510    //   If the URI is not tracked (was never opened or was closed),
511    //   pull_diagnostics must return empty — the old stale result must
512    //   NOT be returned.
513    // ------------------------------------------------------------------
514    #[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        // Never opened — pull must return empty.
520        let diags = adapter.pull_diagnostics(&uri);
521        assert!(
522            diags.is_empty(),
523            "unknown URI must return no diagnostics; got {diags:?}"
524        );
525
526        // Open, then close.
527        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    // ------------------------------------------------------------------
547    // TRUE: did_change_reuses_previous_tree_incrementally
548    //       Regression test for the bug where handle_did_change reparsed
549    //       the whole pre-edit document from scratch (`parser.parse(text,
550    //       None)`) before applying the incremental edit, defeating
551    //       `Tree::edit`'s entire purpose. A single-character, ranged edit
552    //       on a document large enough to have multiple top-level elements
553    //       should leave the tree's `changed_ranges` local to the edit
554    //       site, not the whole document — this can only happen if the
555    //       edit really was applied against the *previous* tree (which
556    //       `get_document` now serves from the `documents` cache) rather
557    //       than against a throwaway tree reparsed from `None`.
558    // ------------------------------------------------------------------
559    #[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        // Insert a single character inside the first <p>, well away from the
579        // second and third elements.
580        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}