Skip to main content

php_lsp/backend/
mod.rs

1use std::path::PathBuf;
2use std::sync::Arc;
3
4#[allow(unused_imports)]
5use self::helpers::*;
6
7use arc_swap::ArcSwap;
8
9/// Sent to the client once Phase 3 (reference index build) finishes.
10/// Allows tests and tooling to wait for the codebase fast path to be active.
11enum IndexReadyNotification {}
12impl tower_lsp::lsp_types::notification::Notification for IndexReadyNotification {
13    type Params = ();
14    const METHOD: &'static str = "$/php-lsp/indexReady";
15}
16use tower_lsp::Client;
17use tower_lsp::lsp_types::*;
18
19use crate::document::ast::ParsedDoc;
20use crate::document::document_store::DocumentStore;
21use crate::document::open_files::OpenFiles;
22use crate::lang::autoload::Psr4Map;
23use crate::lang::config::LspConfig;
24use crate::lang::phpstorm_meta::PhpStormMeta;
25use crate::text::fqn_short_name;
26
27use crate::navigation::references::find_constructor_references;
28
29use crate::analysis::diagnostics::merge_file_diagnostics;
30use crate::document::open_files::compute_open_file_diagnostics;
31
32pub struct Backend {
33    client: Client,
34    docs: Arc<DocumentStore>,
35    /// Open-file state: text, version token, parse diagnostics.
36    /// Files that are only background-indexed (never opened in the editor)
37    /// do not appear here; they live only in `DocumentStore`'s salsa layer.
38    open_files: OpenFiles,
39    root_paths: Arc<ArcSwap<Vec<PathBuf>>>,
40    psr4: Arc<ArcSwap<Psr4Map>>,
41    meta: Arc<ArcSwap<PhpStormMeta>>,
42    config: Arc<ArcSwap<LspConfig>>,
43}
44
45impl Backend {
46    pub fn new(client: Client) -> Self {
47        // No imperative Codebase field anymore — `self.codebase()` below
48        // delegates to the salsa-memoized `codebase` query, which composes
49        // bundled stubs + every file's StubSlice and returns a fresh
50        // `Arc<Codebase>` (or the memoized one when inputs are unchanged).
51        let docs = Arc::new(DocumentStore::new());
52        let psr4 = docs.psr4_arc();
53        Backend {
54            client,
55            docs,
56            open_files: OpenFiles::new(),
57            root_paths: Arc::new(ArcSwap::from_pointee(Vec::new())),
58            psr4,
59            meta: Arc::new(ArcSwap::from_pointee(PhpStormMeta::default())),
60            config: Arc::new(ArcSwap::from_pointee(LspConfig::default())),
61        }
62    }
63
64    fn set_open_text(&self, uri: Url, text: String) -> u64 {
65        self.open_files.set_open_text(&self.docs, uri, text)
66    }
67
68    fn close_open_file(&self, uri: &Url) {
69        self.open_files.close(&self.docs, uri);
70    }
71
72    /// Background-index a file from disk, but only if it isn't currently
73    /// open in the editor — the editor's buffer is authoritative while a
74    /// file is open, and we must not overwrite it with disk contents.
75    fn ingest_if_not_open(&self, uri: Url, text: &str) {
76        if !self.open_files.contains(&uri) {
77            self.docs.ingest(uri, text);
78        }
79    }
80
81    /// Variant of [`ingest_if_not_open`] that reuses an already-parsed doc.
82    fn ingest_from_doc_if_not_open(&self, uri: Url, doc: &ParsedDoc) {
83        if !self.open_files.contains(&uri) {
84            self.docs.ingest_from_doc(uri, doc);
85        }
86    }
87
88    fn get_open_text(&self, uri: &Url) -> Option<String> {
89        self.open_files.text(uri)
90    }
91
92    fn set_parse_diagnostics(&self, uri: &Url, diagnostics: Vec<Diagnostic>) {
93        self.open_files.set_parse_diagnostics(uri, diagnostics);
94    }
95
96    fn get_parse_diagnostics(&self, uri: &Url) -> Option<Vec<Diagnostic>> {
97        self.open_files.parse_diagnostics(uri)
98    }
99
100    fn all_open_files_with_diagnostics(&self) -> Vec<(Url, Vec<Diagnostic>, Option<i64>)> {
101        self.open_files.all_with_diagnostics()
102    }
103
104    fn open_urls(&self) -> Vec<Url> {
105        self.open_files.urls()
106    }
107
108    fn get_doc(&self, uri: &Url) -> Option<Arc<ParsedDoc>> {
109        self.open_files.get_doc(&self.docs, uri)
110    }
111
112    /// Current MirDb snapshot for the workspace, owned by the
113    /// `AnalysisSession`. Cheap clone (Arc-wrapped internals).
114    fn codebase(&self) -> mir_analyzer::db::MirDbStorage {
115        let php_version = self.docs.workspace_php_version();
116        let session = self.docs.analysis_session(php_version);
117        session.snapshot_db()
118    }
119
120    /// `use Foo as Bar;` map for a single file, read directly from the AST.
121    fn file_imports(&self, uri: &Url) -> std::collections::HashMap<String, String> {
122        self.docs
123            .get_doc_salsa(uri)
124            .map(|doc| crate::references::collect_file_imports(&doc))
125            .unwrap_or_default()
126    }
127
128    /// Reference call sites for a class's `__construct`.
129    ///
130    /// The constructor's call sites are `new OwningClass(...)`, not
131    /// `->__construct()`, so name-only matching would return every class's
132    /// constructor declaration. We search for `new` expressions only, scoped to
133    /// the owning class.
134    ///
135    /// `class_name` is the FQN when the constructor is inside a namespace
136    /// (e.g. `"Shop\\Order"`). The AST walker searches for the *short* name
137    /// (`"Order"`) since that's what appears at call sites, while the FQN is
138    /// used only to scope the search and prevent collisions between two classes
139    /// with the same short name in different namespaces.
140    fn construct_references(
141        &self,
142        uri: &Url,
143        source: &str,
144        position: Position,
145        class_name: &str,
146        include_declaration: bool,
147    ) -> Vec<Location> {
148        let short_name = fqn_short_name(class_name).to_owned();
149        let class_fqn = class_name.contains('\\').then_some(class_name);
150        // Prefilter to files mentioning the short class name before parsing.
151        let candidate_docs = self.docs.candidate_docs_for(&short_name);
152        // `find_constructor_references` walks `new` expressions directly —
153        // bypasses the codebase/salsa index whose `ClassReference` key is too
154        // broad (covers type hints, `instanceof`, `extends`, `implements`).
155        let mut locations = find_constructor_references(&short_name, &candidate_docs, class_fqn);
156        // The cursor is already on the `__construct` name, so derive the span
157        // from the identifier under the cursor rather than re-searching via
158        // str_offset (which finds the first occurrence in the file and would
159        // point at the wrong constructor in files with more than one class).
160        if include_declaration && let Some(range) = crate::text::word_range_at(source, position) {
161            locations.push(Location {
162                uri: uri.clone(),
163                range,
164            });
165        }
166        locations
167    }
168
169    /// Resolve the FQN of the symbol at the cursor so reference lookups can match
170    /// by exact FQN instead of short name (fixes cross-namespace overmatch for
171    /// Function/Class and unrelated-class overmatch for Method via the owning
172    /// FQCN). Returns `None` when the kind doesn't carry an FQN or it can't be
173    /// resolved. For class constants, returns the owning class short name.
174    fn resolve_reference_target_fqn(
175        &self,
176        uri: &Url,
177        doc_opt: Option<&Arc<ParsedDoc>>,
178        word: &str,
179        kind: Option<crate::navigation::references::SymbolKind>,
180        position: Position,
181        constant_owner: Option<String>,
182    ) -> Option<String> {
183        use crate::navigation::references::SymbolKind;
184        let doc = doc_opt?;
185        let imports = self.file_imports(uri);
186        match kind {
187            Some(SymbolKind::Function) | Some(SymbolKind::Class) => {
188                let resolved = crate::navigation::moniker::resolve_fqn(doc, word, &imports);
189                resolved.contains('\\').then_some(resolved)
190            }
191            Some(SymbolKind::Method) => {
192                // Owning FQCN: the class/interface/trait/enum that contains the cursor.
193                let short_owner =
194                    crate::types::type_map::enclosing_class_at(doc.source(), doc, position)?;
195                // `resolve_fqn` walks the doc and applies the namespace prefix if any.
196                Some(crate::navigation::moniker::resolve_fqn(
197                    doc,
198                    &short_owner,
199                    &imports,
200                ))
201            }
202            Some(SymbolKind::Property) => {
203                // Only resolve the owning class when the cursor is on a property
204                // declaration — for access sites (`$obj->prop`) enclosing_class_at
205                // returns the accessing class, not the declaring class, so the session
206                // would be queried with the wrong key. Access sites fall back to the
207                // AST walker which finds all `->prop` occurrences.
208                let stmts = &doc.program().stmts;
209                crate::backend::helpers::cursor_is_on_property_decl(doc.source(), stmts, position)?;
210                let short_owner =
211                    crate::types::type_map::enclosing_class_at(doc.source(), doc, position)?;
212                Some(crate::navigation::moniker::resolve_fqn(
213                    doc,
214                    &short_owner,
215                    &imports,
216                ))
217            }
218            Some(SymbolKind::Constant) => {
219                if constant_owner.is_some() {
220                    // Class constant: the owning class short name as-is.
221                    constant_owner
222                } else {
223                    // Global/namespace constant: compute FQN so cross-namespace
224                    // references like `\Config\DB_HOST` can be found.
225                    let fqn = crate::navigation::moniker::resolve_fqn(doc, word, &imports);
226                    fqn.contains('\\').then_some(fqn)
227                }
228            }
229            _ => None,
230        }
231    }
232
233    /// Type-aware method call sites from the mir session.
234    ///
235    /// Method refs need type-aware filtering: `$mailer->process()` and
236    /// `$queue->process()` share a name, but only the one whose receiver type
237    /// matches the cursor's owning class is a real ref. Mir's `references_to` is
238    /// type-aware; use it as the primary source for Method+`target_fqn`.
239    ///
240    /// Returns `None` when the kind isn't `Method` or no mir symbol can be built;
241    /// otherwise the (possibly empty) call-site set, filtered to files that
242    /// actually mention `owner_short` (drops untyped/Mixed receivers — any file
243    /// where a receiver is legitimately typed as the owner must reference it by
244    /// name somewhere via import, `new`, or type hint).
245    fn session_method_references(
246        &self,
247        word: &str,
248        kind: Option<crate::navigation::references::SymbolKind>,
249        target_fqn: Option<&str>,
250        owner_short: Option<&str>,
251    ) -> Option<Vec<Location>> {
252        if !matches!(
253            kind,
254            Some(crate::navigation::references::SymbolKind::Method)
255        ) {
256            return None;
257        }
258        let sym = build_mir_symbol(word, kind, target_fqn)?;
259        let locs = self
260            .docs
261            .session_references_to(&sym)
262            .into_iter()
263            .filter_map(|tuple| {
264                let loc = crate::references::session_tuple_to_location(tuple)?;
265                if let Some(short) = owner_short {
266                    let mentions = self
267                        .docs
268                        .source_text(&loc.uri)
269                        .as_ref()
270                        .map(|src| src.contains(short))
271                        .unwrap_or(true);
272                    if !mentions {
273                        return None;
274                    }
275                }
276                Some(loc)
277            })
278            .collect();
279        Some(locs)
280    }
281
282    /// Type-aware property access sites from the mir session.
283    ///
284    /// Property refs need type-aware filtering: `$mailer->status` and
285    /// `$order->status` share a name but belong to different classes. Mir keys
286    /// property references on the declaring class (since v0.38.0), so
287    /// `references_to(Name::Property { class: fqcn, name })` returns only
288    /// accesses whose receiver type resolved to the correct owner.
289    ///
290    /// Returns `None` when the kind isn't `Property` or no mir symbol can be
291    /// built (i.e. the cursor is on an access site where the owning class is
292    /// unknown rather than on a declaration).
293    fn session_property_references(
294        &self,
295        word: &str,
296        kind: Option<crate::navigation::references::SymbolKind>,
297        target_fqn: Option<&str>,
298    ) -> Option<Vec<Location>> {
299        if !matches!(
300            kind,
301            Some(crate::navigation::references::SymbolKind::Property)
302        ) {
303            return None;
304        }
305        let sym = build_mir_symbol(word, kind, target_fqn)?;
306        let locs = self
307            .docs
308            .session_references_to(&sym)
309            .into_iter()
310            .filter_map(crate::references::session_tuple_to_location)
311            .collect();
312        Some(locs)
313    }
314
315    /// Resolve the PHP version to use. See `autoload::resolve_php_version_from_roots`
316    /// for the full priority order.
317    fn resolve_php_version(&self, explicit: Option<&str>) -> (String, &'static str) {
318        let roots = self.root_paths.load();
319        crate::lang::autoload::resolve_php_version_from_roots(&roots, explicit)
320    }
321
322    /// Compute diagnostic publishes for every open dependent of `changed_uri`.
323    /// Uses `session.analyze_dependents_of` to scope work to files whose
324    /// Pass-2 results actually changed; merges LSP-side parse + duplicate-decl
325    /// diagnostics so the publish reflects the full picture per file.
326    async fn compute_dependent_publishes(
327        &self,
328        changed_uri: &Url,
329        diag_cfg: &crate::lang::config::DiagnosticsConfig,
330    ) -> Vec<(Url, Vec<Diagnostic>)> {
331        compute_dependent_publishes_owned(
332            Arc::clone(&self.docs),
333            self.open_files.clone(),
334            changed_uri.clone(),
335            diag_cfg.clone(),
336        )
337        .await
338    }
339}
340
341/// Build a `mir_analyzer::Name` from the cursor-resolved `(word, kind,
342/// target_fqn)` triple, when there's enough information to construct one.
343/// Returns `None` when:
344/// - `kind` is `None` (cursor not on a recognizable symbol),
345/// - the required FQN piece isn't available.
346fn build_mir_symbol(
347    word: &str,
348    kind: Option<crate::navigation::references::SymbolKind>,
349    target_fqn: Option<&str>,
350) -> Option<mir_analyzer::Name> {
351    use crate::navigation::references::SymbolKind;
352    use std::sync::Arc as StdArc;
353    match kind {
354        Some(SymbolKind::Function) => {
355            target_fqn.map(|fqn| mir_analyzer::Name::Function(StdArc::from(fqn)))
356        }
357        Some(SymbolKind::Class) => {
358            target_fqn.map(|fqn| mir_analyzer::Name::Class(StdArc::from(fqn)))
359        }
360        Some(SymbolKind::Method) => target_fqn.map(|owning| mir_analyzer::Name::Method {
361            class: StdArc::from(owning),
362            // PHP method dispatch is case-insensitive — Symbol::method
363            // normalizes the name. The constructor function does this for us.
364            name: StdArc::from(word.to_ascii_lowercase()),
365        }),
366        Some(SymbolKind::Property) => target_fqn.map(|owning| mir_analyzer::Name::Property {
367            class: StdArc::from(owning),
368            name: StdArc::from(word),
369        }),
370        Some(SymbolKind::Constant) | None => None,
371    }
372}
373
374/// Refine the cursor's `(word, kind)` for a references request using
375/// declaration-aware heuristics, returning the (possibly rewritten) word, its
376/// symbol kind, and — for class constants — the owning class short name.
377///
378/// Checks, in order: promoted constructor property params (so `$name` in
379/// `__construct(public string $name)` resolves to the `->name` property, not
380/// `$name` variable occurrences), then method / property / constant
381/// declarations, falling back to the character-based `symbol_kind_at` heuristic.
382fn resolve_reference_symbol(
383    doc_opt: Option<&Arc<ParsedDoc>>,
384    source: &str,
385    position: Position,
386    word: String,
387) -> (
388    String,
389    Option<crate::navigation::references::SymbolKind>,
390    Option<String>,
391) {
392    use crate::navigation::references::SymbolKind;
393    let mut constant_owner: Option<String> = None;
394    let (word, kind) = if let Some(doc) = doc_opt
395        && let Some(prop_name) =
396            promoted_property_at_cursor(doc.source(), &doc.program().stmts, position)
397    {
398        (prop_name, Some(SymbolKind::Property))
399    } else if let Some(doc) = doc_opt {
400        let stmts = &doc.program().stmts;
401        if cursor_is_on_method_decl(doc.source(), stmts, position) {
402            (word, Some(SymbolKind::Method))
403        } else if let Some(prop_name) = cursor_is_on_property_decl(doc.source(), stmts, position) {
404            (prop_name, Some(SymbolKind::Property))
405        } else if let Some((const_name, owner)) =
406            cursor_is_on_constant_decl(doc.source(), stmts, position)
407        {
408            constant_owner = owner;
409            (const_name, Some(SymbolKind::Constant))
410        } else {
411            let k = symbol_kind_at(source, position, &word);
412            // For constant access sites (`ClassName::CONST`, `self::CONST`),
413            // extract the owning class so the constant walker is scoped to
414            // the right class rather than falling back to the global-constant
415            // path (which would look for a top-level constant named `CONST`).
416            if matches!(k, Some(SymbolKind::Constant))
417                && let Some(raw) = class_before_double_colon(source, position)
418            {
419                constant_owner = Some(match raw.as_str() {
420                    "self" | "static" => {
421                        crate::types::type_map::enclosing_class_at(doc.source(), doc, position)
422                            .unwrap_or(raw)
423                    }
424                    _ => raw,
425                });
426            }
427            (word, k)
428        }
429    } else {
430        let k = symbol_kind_at(source, position, &word);
431        (word, k)
432    };
433    (word, kind, constant_owner)
434}
435
436/// Extract the class name (or pseudo-keyword) immediately to the left of `::` at
437/// the cursor position. Returns `None` when the cursor is not on an identifier
438/// preceded by `::`.
439///
440/// Used to populate `constant_owner` for constant access sites so that
441/// `Status::ACTIVE` (cursor on `ACTIVE`) scopes the constant walker to `Status`
442/// rather than treating `ACTIVE` as a global constant.
443fn class_before_double_colon(source: &str, position: Position) -> Option<String> {
444    let line = source.lines().nth(position.line as usize)?;
445    let chars: Vec<char> = line.chars().collect();
446    let col = position.character as usize;
447
448    let mut utf16_col = 0usize;
449    let mut char_idx = 0usize;
450    for ch in &chars {
451        if utf16_col >= col {
452            break;
453        }
454        utf16_col += ch.len_utf16();
455        char_idx += 1;
456    }
457
458    let is_word = |c: char| c.is_alphanumeric() || c == '_';
459    while char_idx > 0 && is_word(chars[char_idx - 1]) {
460        char_idx -= 1;
461    }
462
463    if char_idx < 2 || chars[char_idx - 1] != ':' || chars[char_idx - 2] != ':' {
464        return None;
465    }
466
467    let class_end = char_idx - 2;
468    let mut class_start = class_end;
469    while class_start > 0 && (is_word(chars[class_start - 1]) || chars[class_start - 1] == '\\') {
470        class_start -= 1;
471    }
472
473    let name: String = chars[class_start..class_end].iter().collect();
474    if name.is_empty() { None } else { Some(name) }
475}
476
477/// Off-`self` variant of `Backend::compute_dependent_publishes`. Needed
478/// because did_change's blocking republish runs inside a detached
479/// `tokio::spawn` that captures `Arc<Backend>` indirectly via clones of
480/// `docs` / `open_files` rather than `&self`.
481async fn compute_dependent_publishes_owned(
482    docs: Arc<DocumentStore>,
483    open_files: OpenFiles,
484    changed_uri: Url,
485    diag_cfg: crate::lang::config::DiagnosticsConfig,
486) -> Vec<(Url, Vec<Diagnostic>)> {
487    tokio::task::spawn_blocking(move || {
488        // Ask mir which files actually depend on `changed_uri` and let it
489        // re-run Pass 2 for them in parallel. mir 0.25's dependency graph
490        // covers every reference kind that can produce a cross-file
491        // diagnostic (imports, class hierarchy, type hints, instanceof,
492        // catch, ::class, ::CONST, `new`, static and instance calls) and
493        // tracks symbols-deleted-from-a-file so renames / deletions still
494        // surface the orphaned dependents.
495        let php_version = docs.workspace_php_version();
496        let session = docs.analysis_session(php_version);
497        let analyses = session.reanalyze_dependents(changed_uri.as_str());
498        if analyses.is_empty() {
499            return Vec::new();
500        }
501
502        // We only publish for files the editor has open. Filter the
503        // session-wide dependent set down to open URLs.
504        let open_urls: std::collections::HashSet<Url> = open_files
505            .urls()
506            .into_iter()
507            .filter(|u| u != &changed_uri)
508            .collect();
509        let dependents: Vec<(Url, mir_analyzer::FileAnalysis)> = analyses
510            .into_iter()
511            .filter_map(|(file, analysis)| {
512                let url = Url::parse(file.as_ref()).ok()?;
513                open_urls.contains(&url).then_some((url, analysis))
514            })
515            .collect();
516        if dependents.is_empty() {
517            return Vec::new();
518        }
519
520        // Workspace-level class issues (circular inheritance, override
521        // violations, abstract-method gaps) aren't in `FileAnalysis` —
522        // pull them in one batched call covering every affected file.
523        let dep_files: Vec<Arc<str>> = dependents
524            .iter()
525            .map(|(u, _)| Arc::from(u.as_str()))
526            .collect();
527        let class_issues = session.class_issues(&dep_files);
528        let mut class_issues_by_file: std::collections::HashMap<Arc<str>, Vec<mir_issues::Issue>> =
529            std::collections::HashMap::new();
530        for issue in class_issues {
531            if issue.suppressed {
532                continue;
533            }
534            let file = issue.location.file.clone();
535            class_issues_by_file.entry(file).or_default().push(issue);
536        }
537
538        let mut out: Vec<(Url, Vec<Diagnostic>)> = Vec::with_capacity(dependents.len());
539        for (url, analysis) in dependents {
540            let parse = open_files.parse_diagnostics(&url).unwrap_or_default();
541            let mut issues: Vec<mir_issues::Issue> = analysis
542                .issues
543                .into_iter()
544                .filter(|i| !i.suppressed)
545                .collect();
546            if let Some(extra) = class_issues_by_file.remove(&Arc::<str>::from(url.as_str())) {
547                issues.extend(extra);
548            }
549            let semantic =
550                crate::semantic_diagnostics::issues_to_diagnostics(&issues, &url, &diag_cfg);
551            out.push((url, merge_file_diagnostics(parse, semantic)));
552        }
553        out
554    })
555    .await
556    .unwrap_or_default()
557}
558
559/// Compute and publish diagnostics for `uri`, then republish any open files
560/// that depend on it. Requires `open_files.set_parse_diagnostics` to be up to
561/// date for `uri` before this is called.
562pub(super) async fn publish_with_dependents(
563    client: Client,
564    docs: Arc<DocumentStore>,
565    open_files: OpenFiles,
566    uri: Url,
567    diag_cfg: crate::lang::config::DiagnosticsConfig,
568) {
569    let docs_ref = Arc::clone(&docs);
570    let open_files_ref = open_files.clone();
571    let uri_ref = uri.clone();
572    let diag_cfg_ref = diag_cfg.clone();
573    let all_diags = tokio::task::spawn_blocking(move || {
574        compute_open_file_diagnostics(&docs_ref, &open_files_ref, &uri_ref, &diag_cfg_ref)
575    })
576    .await
577    .unwrap_or_default();
578    client
579        .publish_diagnostics(uri.clone(), all_diags, None)
580        .await;
581    let dependents = compute_dependent_publishes_owned(docs, open_files, uri, diag_cfg).await;
582    for (dep_uri, dep_diags) in dependents {
583        client.publish_diagnostics(dep_uri, dep_diags, None).await;
584    }
585}
586
587/// Generate a stable result_id for diagnostics. Uses the count and position of diagnostics
588/// to create a stable identifier. Same diagnostics = same result_id.
589fn compute_diagnostic_result_id(diagnostics: &[Diagnostic], uri: &str) -> String {
590    use std::collections::hash_map::DefaultHasher;
591    use std::hash::{Hash, Hasher};
592
593    let mut hasher = DefaultHasher::new();
594    uri.hash(&mut hasher);
595    diagnostics.len().hash(&mut hasher);
596
597    for diag in diagnostics {
598        diag.range.start.line.hash(&mut hasher);
599        diag.range.start.character.hash(&mut hasher);
600        diag.range.end.line.hash(&mut hasher);
601        diag.range.end.character.hash(&mut hasher);
602        diag.message.hash(&mut hasher);
603        let severity_val = match diag.severity {
604            Some(tower_lsp::lsp_types::DiagnosticSeverity::ERROR) => 1,
605            Some(tower_lsp::lsp_types::DiagnosticSeverity::WARNING) => 2,
606            Some(tower_lsp::lsp_types::DiagnosticSeverity::INFORMATION) => 3,
607            Some(tower_lsp::lsp_types::DiagnosticSeverity::HINT) => 4,
608            None => 0,
609            _ => 5, // Unknown variants
610        };
611        severity_val.hash(&mut hasher);
612        if let Some(code) = &diag.code {
613            format!("{:?}", code).hash(&mut hasher);
614        }
615        if let Some(source) = &diag.source {
616            source.hash(&mut hasher);
617        }
618        if let Some(tags) = &diag.tags {
619            for tag in tags {
620                let tag_val = match *tag {
621                    tower_lsp::lsp_types::DiagnosticTag::UNNECESSARY => 1,
622                    tower_lsp::lsp_types::DiagnosticTag::DEPRECATED => 2,
623                    _ => 3,
624                };
625                tag_val.hash(&mut hasher);
626            }
627        }
628    }
629
630    format!("v1:{:x}", hasher.finish())
631}
632
633mod handlers;
634mod helpers;
635pub mod panic_guard;
636mod server;
637#[cfg(test)]
638mod tests;