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_server::ls_types::notification::Notification for IndexReadyNotification {
13    type Params = ();
14    const METHOD: &'static str = "$/php-lsp/indexReady";
15}
16
17/// A `$/progress` partial-result batch for `textDocument/references`.
18///
19/// `lsp_types::ProgressParamsValue` only has a `WorkDone` variant (it can't
20/// carry an arbitrary partial-result payload), so partial results use this
21/// hand-rolled notification instead — same `$/progress` method, but `value`
22/// is the request's own result type unwrapped, per the LSP spec's
23/// partial-result shape.
24enum ReferencesPartialResult {}
25#[derive(serde::Serialize, serde::Deserialize)]
26#[serde(rename_all = "camelCase")]
27struct ReferencesPartialResultParams {
28    token: tower_lsp_server::ls_types::NumberOrString,
29    value: Vec<tower_lsp_server::ls_types::Location>,
30}
31impl tower_lsp_server::ls_types::notification::Notification for ReferencesPartialResult {
32    type Params = ReferencesPartialResultParams;
33    const METHOD: &'static str = "$/progress";
34}
35
36/// Stream a batch of reference locations to the client as a `$/progress`
37/// partial result. Best-effort: the final response is always sent
38/// separately and is unaffected if the client ignores this.
39pub(crate) async fn send_references_partial_result(
40    client: &Client,
41    token: tower_lsp_server::ls_types::NumberOrString,
42    locations: Vec<tower_lsp_server::ls_types::Location>,
43) {
44    client
45        .send_notification::<ReferencesPartialResult>(ReferencesPartialResultParams {
46            token,
47            value: locations,
48        })
49        .await;
50}
51
52use tower_lsp_server::Client;
53use tower_lsp_server::ls_types::*;
54
55/// Response for the `$/php-lsp/debugStats` custom request.
56#[derive(Debug, serde::Serialize, serde::Deserialize)]
57pub struct DebugStats {
58    /// Cumulative count of real `ParsedDoc` parses (cache misses).
59    pub parses: u64,
60    /// Times mir's reference index was locked. Reads are per-key posting
61    /// lookups and edits commit one file's postings, so this grows by a
62    /// small bounded amount per operation — never per candidate file.
63    pub ref_index_locks: u64,
64    /// Analysis warm sweeps run to completion (not cancelled). Lets benches
65    /// and tests wait for the post-index sweep before baselining.
66    pub warm_sweeps_completed: u64,
67    /// Whether mir's workspace symbol index singleton is populated (seeded
68    /// from the disk cache at warm start, or built by the first sweep).
69    pub workspace_symbol_index_ready: bool,
70    /// Executions of mir's tracked O(all-files) symbol-index walk. A
71    /// cache-warm session must keep this at zero — the regression tests
72    /// assert it.
73    pub workspace_index_walks: u64,
74    /// Workspace-wide FQN/text-needle scan passes for reference-scope
75    /// narrowing (`DocumentStore::resolve_reachability_queries`). A request
76    /// with many narrowing-requiring declarations (e.g. code lens on a file
77    /// with several classes/public-static-methods) must pay one pass per
78    /// request, not one per declaration — the regression tests assert the
79    /// delta across such a request.
80    pub reachability_scan_passes: u64,
81    /// Real `FileAnalyzer::analyze` runs (never a cache hit). Concurrent
82    /// callers racing for the same uncached file's analysis (e.g. `did_open`
83    /// diagnostics vs. a fast-following hover) must serialize onto one run,
84    /// not one per caller — the regression test asserts the delta.
85    pub analysis_compute_count: u64,
86    /// `DocumentStore::warm_start_indexes` calls that replayed at least one
87    /// file. A runtime-added workspace folder runs this in the same spawned
88    /// task as its scan, before the refresh-request notifications go out —
89    /// tests await a count bump here instead of guessing a fixed delay.
90    pub warm_start_replays_completed: u64,
91}
92
93use crate::document::ast::ParsedDoc;
94use crate::document::document_store::DocumentStore;
95use crate::document::open_files::OpenFiles;
96use crate::lang::autoload::Psr4Map;
97use crate::lang::config::LspConfig;
98use crate::laravel::LaravelIndex;
99
100use crate::analysis::diagnostics::merge_file_diagnostics;
101use crate::document::open_files::compute_open_file_diagnostics;
102
103pub struct Backend {
104    client: Client,
105    docs: Arc<DocumentStore>,
106    /// Open-file state: text, version token, parse diagnostics.
107    /// Files that are only background-indexed (never opened in the editor)
108    /// do not appear here; they live only in `DocumentStore`'s salsa layer.
109    open_files: OpenFiles,
110    root_paths: Arc<ArcSwap<Vec<PathBuf>>>,
111    psr4: Arc<ArcSwap<Psr4Map>>,
112    laravel: Arc<ArcSwap<LaravelIndex>>,
113    config: Arc<ArcSwap<LspConfig>>,
114    /// Capabilities the client declared in `initialize`. Consulted in
115    /// `initialized` to decide which capabilities are safe to register
116    /// dynamically (e.g. a client that didn't declare
117    /// `typeHierarchy.dynamicRegistration` won't understand a
118    /// `client/registerCapability` call for it).
119    client_capabilities: Arc<ArcSwap<ClientCapabilities>>,
120}
121
122impl Backend {
123    pub fn new(client: Client) -> Self {
124        // No imperative Codebase field anymore — analysis reads the
125        // salsa-memoized `codebase` query, which composes bundled stubs + every
126        // file's StubSlice and returns a fresh `Arc<Codebase>` (or the memoized
127        // one when inputs are unchanged).
128        let docs = Arc::new(DocumentStore::new());
129        let psr4 = docs.psr4_arc();
130        Backend {
131            client,
132            docs,
133            open_files: OpenFiles::new(),
134            root_paths: Arc::new(ArcSwap::from_pointee(Vec::new())),
135            psr4,
136            laravel: Arc::new(ArcSwap::from_pointee(LaravelIndex::default())),
137            config: Arc::new(ArcSwap::from_pointee(LspConfig::default())),
138            client_capabilities: Arc::new(ArcSwap::from_pointee(ClientCapabilities::default())),
139        }
140    }
141
142    /// `$/php-lsp/debugStats` — internal observability counters, used by the
143    /// stress tests to assert the read path doesn't parse the whole workspace
144    /// and that reference-index lock traffic stays bounded per operation.
145    pub async fn debug_stats(&self) -> tower_lsp_server::jsonrpc::Result<DebugStats> {
146        Ok(DebugStats {
147            parses: self.docs.parse_count(),
148            ref_index_locks: self.docs.ref_index_lock_count(),
149            warm_sweeps_completed: self.docs.warm_sweeps_completed(),
150            workspace_symbol_index_ready: self.docs.workspace_symbol_index_ready(),
151            workspace_index_walks: self.docs.workspace_index_walks(),
152            reachability_scan_passes: self.docs.reachability_scan_passes(),
153            analysis_compute_count: self.docs.analysis_compute_count(),
154            warm_start_replays_completed: self.docs.warm_start_replays_completed(),
155        })
156    }
157
158    fn set_open_text(&self, uri: Uri, text: String) -> u64 {
159        self.open_files.set_open_text(&self.docs, uri, text)
160    }
161
162    fn close_open_file(&self, uri: &Uri) {
163        self.open_files.close(&self.docs, uri);
164    }
165
166    /// Background-index a file from disk, but only if it isn't currently
167    /// open in the editor — the editor's buffer is authoritative while a
168    /// file is open, and we must not overwrite it with disk contents.
169    fn ingest_if_not_open(&self, uri: Uri, text: &str) {
170        if !self.open_files.contains(&uri) {
171            self.docs.ingest(uri, text);
172        }
173    }
174
175    /// Variant of [`ingest_if_not_open`] that reuses an already-parsed doc.
176    fn ingest_from_doc_if_not_open(&self, uri: Uri, doc: &ParsedDoc) {
177        if !self.open_files.contains(&uri) {
178            self.docs.ingest_from_doc(uri, doc);
179        }
180    }
181
182    fn get_open_text(&self, uri: &Uri) -> Option<Arc<str>> {
183        self.open_files.text(uri)
184    }
185
186    fn set_parse_diagnostics(&self, uri: &Uri, diagnostics: Vec<Diagnostic>) {
187        self.open_files.set_parse_diagnostics(uri, diagnostics);
188    }
189
190    fn get_parse_diagnostics(&self, uri: &Uri) -> Option<Vec<Diagnostic>> {
191        self.open_files.parse_diagnostics(uri)
192    }
193
194    fn all_open_files_with_diagnostics(&self) -> Vec<(Uri, Vec<Diagnostic>, Option<i64>)> {
195        self.open_files.all_with_diagnostics()
196    }
197
198    fn open_urls(&self) -> Vec<Uri> {
199        self.open_files.urls()
200    }
201
202    fn get_doc(&self, uri: &Uri) -> Option<Arc<ParsedDoc>> {
203        self.open_files.get_doc(&self.docs, uri)
204    }
205
206    fn get_doc_stale(&self, uri: &Uri) -> Option<Arc<ParsedDoc>> {
207        self.open_files.get_doc_stale(&self.docs, uri)
208    }
209
210    /// `use Foo as Bar;` map for a single file, read directly from the AST.
211    /// Memoized per `ParsedDoc` (see `ParsedDoc::file_imports`) — cheap to
212    /// call repeatedly within or across requests against the same revision.
213    fn file_imports(&self, uri: &Uri) -> Arc<std::collections::HashMap<String, String>> {
214        self.docs
215            .get_doc_salsa(uri)
216            .map(|doc| doc.file_imports())
217            .unwrap_or_default()
218    }
219
220    /// Resolve the FQN of the symbol at the cursor so reference lookups can match
221    /// by exact FQN instead of short name (fixes cross-namespace overmatch for
222    /// Function/Class and unrelated-class overmatch for Method via the owning
223    /// FQCN). Returns `None` when the kind doesn't carry an FQN or it can't be
224    /// resolved. For class constants, returns the owning class short name.
225    fn resolve_reference_target_fqn(
226        &self,
227        uri: &Uri,
228        doc_opt: Option<&Arc<ParsedDoc>>,
229        word: &str,
230        kind: Option<crate::navigation::references::SymbolKind>,
231        position: Position,
232        constant_owner: Option<String>,
233    ) -> Option<String> {
234        use crate::navigation::references::SymbolKind;
235        let doc = doc_opt?;
236        let imports = self.file_imports(uri);
237        match kind {
238            Some(SymbolKind::Function) | Some(SymbolKind::Class) => {
239                let resolved = crate::navigation::moniker::resolve_fqn(doc, word, &imports);
240                resolved.contains('\\').then_some(resolved)
241            }
242            Some(SymbolKind::Method) => {
243                // Owning FQCN: the class/interface/trait/enum that contains the cursor.
244                let short_owner =
245                    crate::types::type_map::enclosing_class_at(doc.source(), doc, position)?;
246                // `resolve_fqn` walks the doc and applies the namespace prefix if any.
247                Some(crate::navigation::moniker::resolve_fqn(
248                    doc,
249                    &short_owner,
250                    &imports,
251                ))
252            }
253            Some(SymbolKind::Property) => {
254                // Resolve the owning class when the cursor is on a property
255                // declaration (including promoted constructor parameters) —
256                // for access sites (`$obj->prop`) the resolved usage symbol
257                // from `FileAnalysis::symbol_at` carries the owner instead.
258                let stmts = &doc.program().stmts;
259                if crate::backend::helpers::cursor_is_on_property_decl(
260                    doc.source(),
261                    stmts,
262                    position,
263                )
264                .is_none()
265                    && promoted_property_at_cursor(doc.source(), stmts, position).is_none()
266                {
267                    return None;
268                }
269                let short_owner =
270                    crate::types::type_map::enclosing_class_at(doc.source(), doc, position)?;
271                Some(crate::navigation::moniker::resolve_fqn(
272                    doc,
273                    &short_owner,
274                    &imports,
275                ))
276            }
277            Some(SymbolKind::Constant) => {
278                if let Some(owner) = constant_owner {
279                    // Class constant: resolve the owning class to its FQCN —
280                    // mir's index keys are `cnst:{fqcn}::{NAME}`.
281                    if owner.contains('\\') {
282                        Some(owner.trim_start_matches('\\').to_string())
283                    } else {
284                        Some(crate::navigation::moniker::resolve_fqn(
285                            doc, &owner, &imports,
286                        ))
287                    }
288                } else {
289                    // Global/namespace constant: compute FQN so cross-namespace
290                    // references like `\Config\DB_HOST` can be found.
291                    let fqn = crate::navigation::moniker::resolve_fqn(doc, word, &imports);
292                    fqn.contains('\\').then_some(fqn)
293                }
294            }
295            _ => None,
296        }
297    }
298
299    /// Resolve the PHP version to use. See `autoload::resolve_php_version_from_roots`
300    /// for the full priority order.
301    fn resolve_php_version(&self, explicit: Option<&str>) -> (String, &'static str) {
302        let roots = self.root_paths.load();
303        crate::lang::autoload::resolve_php_version_from_roots(&roots, explicit)
304    }
305}
306
307/// Refine the cursor's `(word, kind)` for a references request using
308/// declaration-aware heuristics, returning the (possibly rewritten) word, its
309/// symbol kind, and — for class constants — the owning class short name.
310///
311/// Checks, in order: promoted constructor property params (so `$name` in
312/// `__construct(public string $name)` resolves to the `->name` property, not
313/// `$name` variable occurrences), then method / property / constant
314/// declarations, falling back to the character-based `symbol_kind_at` heuristic.
315fn resolve_reference_symbol(
316    doc_opt: Option<&Arc<ParsedDoc>>,
317    source: &str,
318    position: Position,
319    word: String,
320) -> (
321    String,
322    Option<crate::navigation::references::SymbolKind>,
323    Option<String>,
324) {
325    use crate::navigation::references::SymbolKind;
326    let mut constant_owner: Option<String> = None;
327    let (word, kind) = if let Some(doc) = doc_opt
328        && let Some(prop_name) =
329            promoted_property_at_cursor(doc.source(), &doc.program().stmts, position)
330    {
331        (prop_name, Some(SymbolKind::Property))
332    } else if let Some(doc) = doc_opt {
333        let stmts = &doc.program().stmts;
334        if cursor_is_on_method_decl(doc.source(), stmts, position) {
335            (word, Some(SymbolKind::Method))
336        } else if let Some(prop_name) = cursor_is_on_property_decl(doc.source(), stmts, position) {
337            (prop_name, Some(SymbolKind::Property))
338        } else if let Some((const_name, owner)) =
339            cursor_is_on_constant_decl(doc.source(), stmts, position)
340        {
341            constant_owner = owner;
342            (const_name, Some(SymbolKind::Constant))
343        } else {
344            let k = symbol_kind_at(source, position, &word);
345            // For constant access sites (`ClassName::CONST`, `self::CONST`),
346            // extract the owning class so the constant walker is scoped to
347            // the right class rather than falling back to the global-constant
348            // path (which would look for a top-level constant named `CONST`).
349            if matches!(k, Some(SymbolKind::Constant))
350                && let Some(raw) = class_before_double_colon(source, position)
351            {
352                constant_owner = Some(match raw.as_str() {
353                    "self" | "static" => {
354                        crate::types::type_map::enclosing_class_at(doc.source(), doc, position)
355                            .unwrap_or(raw)
356                    }
357                    _ => raw,
358                });
359            }
360            (word, k)
361        }
362    } else {
363        let k = symbol_kind_at(source, position, &word);
364        (word, k)
365    };
366    (word, kind, constant_owner)
367}
368
369/// Extract the class name (or pseudo-keyword) immediately to the left of `::` at
370/// the cursor position. Returns `None` when the cursor is not on an identifier
371/// preceded by `::`.
372///
373/// Used to populate `constant_owner` for constant access sites so that
374/// `Status::ACTIVE` (cursor on `ACTIVE`) scopes the constant walker to `Status`
375/// rather than treating `ACTIVE` as a global constant.
376fn class_before_double_colon(source: &str, position: Position) -> Option<String> {
377    let line = source.lines().nth(position.line as usize)?;
378    let chars: Vec<char> = line.chars().collect();
379    let col = position.character as usize;
380
381    let mut utf16_col = 0usize;
382    let mut char_idx = 0usize;
383    for ch in &chars {
384        if utf16_col >= col {
385            break;
386        }
387        utf16_col += ch.len_utf16();
388        char_idx += 1;
389    }
390
391    let is_word = |c: char| c.is_alphanumeric() || c == '_';
392    while char_idx > 0 && is_word(chars[char_idx - 1]) {
393        char_idx -= 1;
394    }
395
396    if char_idx < 2 || chars[char_idx - 1] != ':' || chars[char_idx - 2] != ':' {
397        return None;
398    }
399
400    let class_end = char_idx - 2;
401    let mut class_start = class_end;
402    while class_start > 0 && (is_word(chars[class_start - 1]) || chars[class_start - 1] == '\\') {
403        class_start -= 1;
404    }
405
406    let name: String = chars[class_start..class_end].iter().collect();
407    if name.is_empty() { None } else { Some(name) }
408}
409
410/// Off-`self` variant of `Backend::compute_dependent_publishes`. Needed
411/// because did_change's blocking republish runs inside a detached
412/// `tokio::spawn` that captures `Arc<Backend>` indirectly via clones of
413/// `docs` / `open_files` rather than `&self`.
414async fn compute_dependent_publishes_owned(
415    docs: Arc<DocumentStore>,
416    open_files: OpenFiles,
417    changed_uri: Uri,
418    diag_cfg: crate::lang::config::DiagnosticsConfig,
419) -> Vec<(Uri, Vec<Diagnostic>)> {
420    tokio::task::spawn_blocking(move || {
421        // rust-analyzer model: we only ever publish for files the editor has
422        // open, so re-analyze exactly that set and let salsa memoization make
423        // the unaffected ones ~free. No dependent set is computed at all —
424        // the old path rebuilt mir's dependency_graph() on every keystroke,
425        // an O(all-ingested-files) walk whose cost grew with session age.
426        let open_set: Vec<std::sync::Arc<str>> = open_files
427            .urls()
428            .into_iter()
429            .filter(|u| u != &changed_uri)
430            .map(|u| std::sync::Arc::from(u.as_str()))
431            .collect();
432        if open_set.is_empty() {
433            return Vec::new();
434        }
435
436        let session = docs.current_analysis_session();
437        // The user is typing: pause the background scan's write storm so this
438        // sweep's snapshots aren't repeatedly cancelled while indexing runs.
439        let _interactive = docs.interactive_read_guard();
440        // Cancel any older in-flight sweep and take a fresh token: when the
441        // next edit starts its own sweep, this one stops at its next file
442        // boundary instead of blocking typing behind the previous sweep.
443        let cancel = docs.begin_reanalyze();
444        let analyses = session.reanalyze_files_cancellable(&open_set, &cancel);
445        // A newer edit that flipped `cancel` mid-sweep is now authoritative and
446        // will republish; drop this sweep's partial results so they can't land
447        // out of order and leave stale diagnostics as the final state.
448        if cancel.is_cancelled() || analyses.is_empty() {
449            return Vec::new();
450        }
451
452        let dependents: Vec<(Uri, mir_analyzer::FileAnalysis)> = analyses
453            .into_iter()
454            .filter_map(|(file, analysis)| {
455                let url = (file.as_ref()).parse::<Uri>().ok()?;
456                Some((url, analysis))
457            })
458            .collect();
459        if dependents.is_empty() {
460            return Vec::new();
461        }
462
463        // Workspace-level class issues (circular inheritance, override
464        // violations, abstract-method gaps) aren't in `FileAnalysis` —
465        // pull them in one batched call covering every affected file.
466        let dep_files: Vec<Arc<str>> = dependents
467            .iter()
468            .map(|(u, _)| Arc::from(u.as_str()))
469            .collect();
470        let class_issues = session.class_issues(&dep_files);
471        let mut class_issues_by_file: std::collections::HashMap<Arc<str>, Vec<mir_issues::Issue>> =
472            std::collections::HashMap::new();
473        for issue in class_issues {
474            if issue.suppressed {
475                continue;
476            }
477            let file = issue.location.file.clone();
478            class_issues_by_file.entry(file).or_default().push(issue);
479        }
480
481        let mut out: Vec<(Uri, Vec<Diagnostic>)> = Vec::with_capacity(dependents.len());
482        for (url, analysis) in dependents {
483            let parse = open_files.parse_diagnostics(&url).unwrap_or_default();
484            let mut issues: Vec<mir_issues::Issue> = analysis
485                .issues
486                .into_iter()
487                .filter(|i| !i.suppressed)
488                .collect();
489            if let Some(extra) = class_issues_by_file.remove(&Arc::<str>::from(url.as_str())) {
490                issues.extend(extra);
491            }
492            let semantic =
493                crate::semantic_diagnostics::issues_to_diagnostics(&issues, &url, &diag_cfg);
494            let mut diags = merge_file_diagnostics(parse, semantic);
495            diags.extend(open_files.external_diagnostics(&url));
496            out.push((url, diags));
497        }
498        out
499    })
500    .await
501    .unwrap_or_default()
502}
503
504/// Republish diagnostics for every currently open file once the initial
505/// workspace index finishes.
506///
507/// `compute_open_file_diagnostics` holds back workspace-symbol-dependent
508/// issues (`UndefinedClass` and friends, see `issue_needs_workspace_index`)
509/// until `docs.is_index_ready()`, so a file opened during the scan may be
510/// first published missing some of those. Push-diagnostics clients (the
511/// common case) have no other trigger to re-request them — pull clients get
512/// `send_refresh_requests`'s `WorkspaceDiagnosticRefresh`, but push clients
513/// would otherwise carry a stale view until the user's next edit. Skips
514/// files whose recomputed diagnostics are identical to what was already
515/// published.
516pub(super) async fn republish_after_index_ready(
517    client: Client,
518    docs: Arc<DocumentStore>,
519    open_files: OpenFiles,
520    diag_cfg: crate::lang::config::DiagnosticsConfig,
521) {
522    let uris = open_files.urls();
523    if uris.is_empty() {
524        return;
525    }
526    let docs_ref = Arc::clone(&docs);
527    let open_files_ref = open_files.clone();
528    let all: Vec<(Uri, Vec<Diagnostic>)> = tokio::task::spawn_blocking(move || {
529        uris.into_iter()
530            .map(|uri| {
531                let diags =
532                    compute_open_file_diagnostics(&docs_ref, &open_files_ref, &uri, &diag_cfg);
533                (uri, diags)
534            })
535            .collect()
536    })
537    .await
538    .unwrap_or_default();
539    for (uri, diags) in all {
540        let hash = diagnostics_content_hash(&diags);
541        if open_files.published_hash(&uri) == Some(hash) {
542            continue;
543        }
544        open_files.note_published(&uri, hash);
545        client.publish_diagnostics(uri, diags, None).await;
546    }
547}
548
549/// Content hash of a diagnostics set, for skipping republishes the client
550/// already displays. In-process only — never persisted.
551pub(super) fn diagnostics_content_hash(diagnostics: &[Diagnostic]) -> u64 {
552    use std::collections::hash_map::DefaultHasher;
553    use std::hash::{Hash, Hasher};
554    let mut hasher = DefaultHasher::new();
555    serde_json::to_string(diagnostics)
556        .unwrap_or_default()
557        .hash(&mut hasher);
558    hasher.finish()
559}
560
561/// Compute and publish diagnostics for `uri`, then republish any open files
562/// that depend on it. Requires `open_files.set_parse_diagnostics` to be up to
563/// date for `uri` before this is called.
564///
565/// The edited file always publishes (editors rely on one publish per change).
566/// Dependents publish only when their content differs from the last publish —
567/// the sweep re-analyzes every open file on every edit, and unchanged results
568/// would otherwise flood the client with no-op notifications.
569pub(super) async fn publish_with_dependents(
570    client: Client,
571    docs: Arc<DocumentStore>,
572    open_files: OpenFiles,
573    uri: Uri,
574    diag_cfg: crate::lang::config::DiagnosticsConfig,
575) {
576    let docs_ref = Arc::clone(&docs);
577    let open_files_ref = open_files.clone();
578    let uri_ref = uri.clone();
579    let diag_cfg_ref = diag_cfg.clone();
580    let all_diags = tokio::task::spawn_blocking(move || {
581        compute_open_file_diagnostics(&docs_ref, &open_files_ref, &uri_ref, &diag_cfg_ref)
582    })
583    .await
584    .unwrap_or_default();
585    open_files.note_published(&uri, diagnostics_content_hash(&all_diags));
586    client
587        .publish_diagnostics(uri.clone(), all_diags, None)
588        .await;
589    let dependents =
590        compute_dependent_publishes_owned(docs, open_files.clone(), uri, diag_cfg).await;
591    for (dep_uri, dep_diags) in dependents {
592        let hash = diagnostics_content_hash(&dep_diags);
593        if open_files.published_hash(&dep_uri) == Some(hash) {
594            continue;
595        }
596        open_files.note_published(&dep_uri, hash);
597        client.publish_diagnostics(dep_uri, dep_diags, None).await;
598    }
599}
600
601/// Generate a stable result_id for diagnostics. Uses the count and position of diagnostics
602/// to create a stable identifier. Same diagnostics = same result_id.
603fn compute_diagnostic_result_id(diagnostics: &[Diagnostic], uri: &str) -> String {
604    use std::collections::hash_map::DefaultHasher;
605    use std::hash::{Hash, Hasher};
606
607    let mut hasher = DefaultHasher::new();
608    uri.hash(&mut hasher);
609    diagnostics.len().hash(&mut hasher);
610
611    for diag in diagnostics {
612        diag.range.start.line.hash(&mut hasher);
613        diag.range.start.character.hash(&mut hasher);
614        diag.range.end.line.hash(&mut hasher);
615        diag.range.end.character.hash(&mut hasher);
616        diag.message.hash(&mut hasher);
617        let severity_val = match diag.severity {
618            Some(tower_lsp_server::ls_types::DiagnosticSeverity::ERROR) => 1,
619            Some(tower_lsp_server::ls_types::DiagnosticSeverity::WARNING) => 2,
620            Some(tower_lsp_server::ls_types::DiagnosticSeverity::INFORMATION) => 3,
621            Some(tower_lsp_server::ls_types::DiagnosticSeverity::HINT) => 4,
622            None => 0,
623            _ => 5, // Unknown variants
624        };
625        severity_val.hash(&mut hasher);
626        if let Some(code) = &diag.code {
627            format!("{:?}", code).hash(&mut hasher);
628        }
629        if let Some(source) = &diag.source {
630            source.hash(&mut hasher);
631        }
632        if let Some(tags) = &diag.tags {
633            for tag in tags {
634                let tag_val = match *tag {
635                    tower_lsp_server::ls_types::DiagnosticTag::UNNECESSARY => 1,
636                    tower_lsp_server::ls_types::DiagnosticTag::DEPRECATED => 2,
637                    _ => 3,
638                };
639                tag_val.hash(&mut hasher);
640            }
641        }
642    }
643
644    format!("v1:{:x}", hasher.finish())
645}
646
647mod handlers;
648mod helpers;
649pub mod panic_guard;
650mod server;
651#[cfg(test)]
652mod tests;