Skip to main content

php_lsp/backend/
mod.rs

1use std::future::Future;
2use std::path::PathBuf;
3use std::pin::Pin;
4use std::sync::Arc;
5use std::task::{Context, Poll};
6
7use arc_swap::ArcSwap;
8use tower::Service;
9use tower_lsp_server::jsonrpc::{Request as JsonRpcRequest, Response as JsonRpcResponse};
10
11use self::helpers::{
12    cursor_is_on_constant_decl, cursor_is_on_property_decl, promoted_property_at_cursor,
13    symbol_kind_at,
14};
15
16/// Sent to the client once Phase 3 (reference index build) finishes.
17/// Allows tests and tooling to wait for the codebase fast path to be active.
18enum IndexReadyNotification {}
19impl tower_lsp_server::ls_types::notification::Notification for IndexReadyNotification {
20    type Params = ();
21    const METHOD: &'static str = "$/php-lsp/indexReady";
22}
23
24/// A `$/progress` partial-result batch for `textDocument/references`.
25///
26/// `lsp_types::ProgressParamsValue` only has a `WorkDone` variant (it can't
27/// carry an arbitrary partial-result payload), so partial results use this
28/// hand-rolled notification instead — same `$/progress` method, but `value`
29/// is the request's own result type unwrapped, per the LSP spec's
30/// partial-result shape.
31enum ReferencesPartialResult {}
32#[derive(serde::Serialize, serde::Deserialize)]
33#[serde(rename_all = "camelCase")]
34struct ReferencesPartialResultParams {
35    token: tower_lsp_server::ls_types::NumberOrString,
36    value: Vec<tower_lsp_server::ls_types::Location>,
37}
38impl tower_lsp_server::ls_types::notification::Notification for ReferencesPartialResult {
39    type Params = ReferencesPartialResultParams;
40    const METHOD: &'static str = "$/progress";
41}
42
43/// Stream a batch of reference locations to the client as a `$/progress`
44/// partial result. Best-effort: the final response is always sent
45/// separately and is unaffected if the client ignores this.
46pub(crate) async fn send_references_partial_result(
47    client: &Client,
48    token: tower_lsp_server::ls_types::NumberOrString,
49    locations: Vec<tower_lsp_server::ls_types::Location>,
50) {
51    client
52        .send_notification::<ReferencesPartialResult>(ReferencesPartialResultParams {
53            token,
54            value: locations,
55        })
56        .await;
57}
58
59use tower_lsp_server::Client;
60use tower_lsp_server::ls_types::*;
61
62/// Response for the `$/php-lsp/debugStats` custom request.
63#[derive(Debug, serde::Serialize, serde::Deserialize)]
64pub struct DebugStats {
65    /// Cumulative count of real `ParsedDoc` parses (cache misses).
66    pub parses: u64,
67    /// Times mir's reference index was locked. Reads are per-key posting
68    /// lookups and edits commit one file's postings, so this grows by a
69    /// small bounded amount per operation — never per candidate file.
70    pub ref_index_locks: u64,
71    /// Analysis warm sweeps run to completion (not cancelled). Lets benches
72    /// and tests wait for the post-index sweep before baselining.
73    pub warm_sweeps_completed: u64,
74    /// Whether mir's workspace symbol index singleton is populated (seeded
75    /// from the disk cache at warm start, or built by the first sweep).
76    pub workspace_symbol_index_ready: bool,
77    /// Executions of mir's tracked O(all-files) symbol-index walk. A
78    /// cache-warm session must keep this at zero — the regression tests
79    /// assert it.
80    pub workspace_index_walks: u64,
81    /// Workspace-wide FQN/text-needle scan passes for reference-scope
82    /// narrowing (`DocumentStore::resolve_reachability_queries`). A request
83    /// with many narrowing-requiring declarations (e.g. code lens on a file
84    /// with several classes/public-static-methods) must pay one pass per
85    /// request, not one per declaration — the regression tests assert the
86    /// delta across such a request.
87    pub reachability_scan_passes: u64,
88    /// Real `FileAnalyzer::analyze` runs (never a cache hit). Concurrent
89    /// callers racing for the same uncached file's analysis (e.g. `did_open`
90    /// diagnostics vs. a fast-following hover) must serialize onto one run,
91    /// not one per caller — the regression test asserts the delta.
92    pub analysis_compute_count: u64,
93    /// `DocumentStore::warm_start_indexes` calls that replayed at least one
94    /// file. A runtime-added workspace folder runs this in the same spawned
95    /// task as its scan, before the refresh-request notifications go out —
96    /// tests await a count bump here instead of guessing a fixed delay.
97    pub warm_start_replays_completed: u64,
98    /// Section currently parked at the debug gate (see `debug_gate.rs`), if
99    /// any. The responsiveness tests poll this to prove a handler's blocking
100    /// work is both off the serve future and genuinely in flight.
101    pub debug_gate_held: Option<String>,
102    /// Lifecycle notifications currently executing. Used by race tests as a
103    /// completion barrier; settled URIs must not remain retained here.
104    pub lifecycle_intents: u64,
105    /// Files mirrored into the salsa workspace. Denominator for the per-file
106    /// cache sizes below.
107    pub workspace_file_count: u64,
108    /// Entries in the semantic-tokens delta cache.
109    pub token_cache_len: u64,
110    /// Entries in the mirrored-source-text cache. Expected to track
111    /// `workspace_file_count`.
112    pub text_cache_len: u64,
113    /// Entries in the read-through `ParsedDoc` cache (LRU-capped).
114    pub parsed_cache_len: u64,
115    /// Entries in the per-file `FileAnalysis` cache (LRU-capped).
116    pub analysis_cache_len: u64,
117    /// Entries in the owned-`Program` cache (LRU-capped).
118    pub owned_program_cache_len: u64,
119    /// Hits served from mir's `indexed_references_to` memoization cache
120    /// (see `mir_analyzer::AnalysisSession::ref_query_cache_hits`).
121    pub mir_ref_query_cache_hits: u64,
122    /// Hits served from mir's `indexed_subtype_classes` memoization cache
123    /// (see `mir_analyzer::AnalysisSession::subtype_query_cache_hits`).
124    pub mir_subtype_query_cache_hits: u64,
125    /// Per-file text scans recorded in mir's own `ClassMentionIndex` — the
126    /// single shared backend every `resolve_reachability_queries` needle is
127    /// answered by. The ratio against `reachability_scan_passes` is the
128    /// per-file mention cache's hit rate — a warm, unedited repeat query
129    /// should not move this at all.
130    pub mir_mention_scans_recorded: u64,
131}
132
133/// Params for the `$/php-lsp/debugHoldGate` custom request.
134#[cfg(feature = "test-hooks")]
135#[derive(Debug, serde::Serialize, serde::Deserialize)]
136pub struct DebugHoldGateParams {
137    /// One of the `GATE_*` constants in `debug_gate`.
138    pub section: String,
139}
140
141/// The one place custom methods are registered — `main` and the test harness
142/// both build the service here so their method tables can never drift. The
143/// gate methods exist only under `test-hooks` (test targets); a production
144/// build answers them with MethodNotFound like any unknown method.
145pub fn build_lsp_service() -> (LifecycleService, ClientLoopback) {
146    let builder = tower_lsp_server::LspService::build(Backend::new)
147        .custom_method("$/php-lsp/debugStats", Backend::debug_stats);
148    #[cfg(feature = "test-hooks")]
149    let builder = builder
150        .custom_method("$/php-lsp/debugHoldGate", Backend::debug_hold_gate)
151        .custom_method("$/php-lsp/debugReleaseGate", Backend::debug_release_gate);
152    let (service, socket) = builder.finish();
153    let spellings = drive_letter::ClientSpellings::default();
154    (
155        LifecycleService {
156            inner: service,
157            spellings: spellings.clone(),
158        },
159        ClientLoopback { socket, spellings },
160    )
161}
162
163/// Records document lifecycle order before tower starts polling notification
164/// futures. The transport deliberately runs up to four messages concurrently,
165/// so handler start order alone is not the order in which the client sent them.
166pub struct LifecycleService {
167    inner: tower_lsp_server::LspService<Backend>,
168    spellings: drive_letter::ClientSpellings,
169}
170
171tokio::task_local! {
172    static LIFECYCLE_GENERATION: u64;
173}
174
175fn lifecycle_generation() -> u64 {
176    LIFECYCLE_GENERATION
177        .try_with(|generation| *generation)
178        .expect("document lifecycle handler called outside LifecycleService")
179}
180
181struct LifecycleIntentGuard {
182    open_files: OpenFiles,
183    uri: Uri,
184    generation: u64,
185}
186
187impl Drop for LifecycleIntentGuard {
188    fn drop(&mut self) {
189        self.open_files.finish_lifecycle_intent(&self.uri);
190    }
191}
192
193impl Service<JsonRpcRequest> for LifecycleService {
194    type Response = Option<JsonRpcResponse>;
195    type Error = tower_lsp_server::ExitedError;
196    type Future =
197        Pin<Box<dyn Future<Output = Result<Option<JsonRpcResponse>, Self::Error>> + Send>>;
198
199    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
200        self.inner.poll_ready(cx)
201    }
202
203    fn call(&mut self, request: JsonRpcRequest) -> Self::Future {
204        let request = self.spellings.normalize_request(request);
205        let lifecycle = self.inner.inner().note_lifecycle_intent(&request);
206        let future = self.inner.call(request);
207        let spellings = self.spellings.clone();
208        let future = async move {
209            let response = future.await?;
210            Ok(response.map(|response| spellings.restore_response(response)))
211        };
212        match lifecycle {
213            Some(guard) => {
214                let generation = guard.generation;
215                Box::pin(LIFECYCLE_GENERATION.scope(generation, async move {
216                    let _guard = guard;
217                    future.await
218                }))
219            }
220            None => Box::pin(future),
221        }
222    }
223}
224
225use crate::document::ast::ParsedDoc;
226use crate::document::document_store::DocumentStore;
227use crate::document::open_files::OpenFiles;
228use crate::lang::autoload::Psr4Map;
229use crate::lang::config::LspConfig;
230use crate::laravel::LaravelIndex;
231
232use crate::analysis::diagnostics::merge_file_diagnostics;
233use crate::document::open_files::{compute_open_file_diagnostics, is_diagnostics_excluded};
234
235pub struct Backend {
236    client: Client,
237    docs: Arc<DocumentStore>,
238    /// Open-file state: text, version token, parse diagnostics.
239    /// Files that are only background-indexed (never opened in the editor)
240    /// do not appear here; they live only in `DocumentStore`'s salsa layer.
241    open_files: OpenFiles,
242    root_paths: Arc<ArcSwap<Vec<PathBuf>>>,
243    psr4: Arc<ArcSwap<Psr4Map>>,
244    laravel: Arc<ArcSwap<LaravelIndex>>,
245    config: Arc<ArcSwap<LspConfig>>,
246    /// Capabilities the client declared in `initialize`. Consulted in
247    /// `initialized` to decide which capabilities are safe to register
248    /// dynamically (e.g. a client that didn't declare
249    /// `typeHierarchy.dynamicRegistration` won't understand a
250    /// `client/registerCapability` call for it).
251    client_capabilities: Arc<ArcSwap<ClientCapabilities>>,
252    code_lens_scopes: Arc<crate::analysis::code_lens::CodeLensScopeCache>,
253    external_runs: crate::analysis::external::RunRegistry,
254    /// Test-only hold points for the responsiveness regression tests.
255    debug_gate: Arc<debug_gate::DebugGate>,
256}
257
258impl Backend {
259    pub fn new(client: Client) -> Self {
260        // No imperative Codebase field anymore — analysis reads the
261        // salsa-memoized `codebase` query, which composes bundled stubs + every
262        // file's StubSlice and returns a fresh `Arc<Codebase>` (or the memoized
263        // one when inputs are unchanged).
264        let docs = Arc::new(DocumentStore::new());
265        let psr4 = docs.psr4_arc();
266        Backend {
267            client,
268            docs,
269            open_files: OpenFiles::new(),
270            root_paths: Arc::new(ArcSwap::from_pointee(Vec::new())),
271            psr4,
272            laravel: Arc::new(ArcSwap::from_pointee(LaravelIndex::default())),
273            config: Arc::new(ArcSwap::from_pointee(LspConfig::default())),
274            client_capabilities: Arc::new(ArcSwap::from_pointee(ClientCapabilities::default())),
275            code_lens_scopes: Arc::default(),
276            external_runs: Default::default(),
277            debug_gate: Arc::new(debug_gate::DebugGate::new()),
278        }
279    }
280
281    /// Capture `didOpen`/`didClose` state as soon as it enters the service,
282    /// rather than when its async handler happens to be polled.
283    fn note_lifecycle_intent(&self, request: &JsonRpcRequest) -> Option<LifecycleIntentGuard> {
284        let is_open = match request.method() {
285            "textDocument/didOpen" => true,
286            "textDocument/didClose" => false,
287            _ => return None,
288        };
289        let uri = request
290            .params()
291            .and_then(|params| serde_json::to_value(params).ok())
292            .and_then(|params| {
293                params
294                    .pointer("/textDocument/uri")?
295                    .as_str()
296                    .map(str::to_owned)
297            })
298            .and_then(|uri| uri.parse::<Uri>().ok())?;
299        let generation = self.open_files.note_lifecycle_intent(uri.clone(), is_open);
300        Some(LifecycleIntentGuard {
301            open_files: self.open_files.clone(),
302            uri,
303            generation,
304        })
305    }
306
307    /// `$/php-lsp/debugStats` — internal observability counters, used by the
308    /// stress tests to assert the read path doesn't parse the whole workspace
309    /// and that reference-index lock traffic stays bounded per operation.
310    pub async fn debug_stats(&self) -> tower_lsp_server::jsonrpc::Result<DebugStats> {
311        Ok(DebugStats {
312            parses: self.docs.parse_count(),
313            ref_index_locks: self.docs.ref_index_lock_count(),
314            warm_sweeps_completed: self.docs.warm_sweeps_completed(),
315            workspace_symbol_index_ready: self.docs.workspace_symbol_index_ready(),
316            workspace_index_walks: self.docs.workspace_index_walks(),
317            reachability_scan_passes: self.docs.reachability_scan_passes(),
318            analysis_compute_count: self.docs.analysis_compute_count(),
319            warm_start_replays_completed: self.docs.warm_start_replays_completed(),
320            debug_gate_held: self.debug_gate.held_section(),
321            lifecycle_intents: self.open_files.lifecycle_intent_count() as u64,
322            workspace_file_count: self.docs.workspace_file_count(),
323            token_cache_len: self.docs.token_cache_len(),
324            text_cache_len: self.docs.text_cache_len(),
325            parsed_cache_len: self.docs.parsed_cache_len(),
326            analysis_cache_len: self.docs.analysis_cache_len(),
327            owned_program_cache_len: self.docs.owned_program_cache_len(),
328            mir_ref_query_cache_hits: self.docs.mir_ref_query_cache_hits(),
329            mir_subtype_query_cache_hits: self.docs.mir_subtype_query_cache_hits(),
330            mir_mention_scans_recorded: self.docs.mir_mention_scans_recorded(),
331        })
332    }
333
334    /// `$/php-lsp/debugHoldGate` — test-only: park the next blocking-pool
335    /// passage through the named section until `debugReleaseGate`.
336    #[cfg(feature = "test-hooks")]
337    pub async fn debug_hold_gate(
338        &self,
339        params: DebugHoldGateParams,
340    ) -> tower_lsp_server::jsonrpc::Result<()> {
341        self.debug_gate.arm(&params.section);
342        Ok(())
343    }
344
345    /// `$/php-lsp/debugReleaseGate` — release a passage parked by
346    /// `debugHoldGate` (and disarm the gate).
347    #[cfg(feature = "test-hooks")]
348    pub async fn debug_release_gate(&self) -> tower_lsp_server::jsonrpc::Result<()> {
349        self.debug_gate.release();
350        Ok(())
351    }
352
353    fn set_open_text(&self, uri: Uri, text: String) -> u64 {
354        self.open_files.set_open_text(&self.docs, uri, text)
355    }
356
357    fn close_open_file(&self, uri: &Uri) {
358        self.open_files.close(&self.docs, uri);
359    }
360
361    /// Background-index a file from disk, but only if it isn't currently
362    /// open in the editor — the editor's buffer is authoritative while a
363    /// file is open, and we must not overwrite it with disk contents.
364    fn ingest_if_not_open(&self, uri: Uri, text: &str) {
365        if !self.open_files.contains(&uri) {
366            self.docs.ingest(uri, text);
367        }
368    }
369
370    fn get_open_text(&self, uri: &Uri) -> Option<Arc<str>> {
371        self.open_files.text(uri)
372    }
373
374    /// See [`offload::run`]. The sanctioned way for a handler to leave the
375    /// async runtime worker for CPU-bound work with no debug-gated test.
376    async fn blocking<F, R>(&self, label: &'static str, f: F) -> Option<R>
377    where
378        F: FnOnce() -> R + Send + 'static,
379        R: Send + 'static,
380    {
381        offload::run(label, f).await
382    }
383
384    /// See [`offload::run_gated`]. Use when a responsiveness regression
385    /// test needs to pin this closure in flight via a `GATE_*` section.
386    async fn blocking_gated<F, R>(&self, section: &'static str, f: F) -> Option<R>
387    where
388        F: FnOnce() -> R + Send + 'static,
389        R: Send + 'static,
390    {
391        offload::run_gated(&self.debug_gate, section, f).await
392    }
393
394    fn set_parse_diagnostics(&self, uri: &Uri, diagnostics: Vec<Diagnostic>) {
395        self.open_files.set_parse_diagnostics(uri, diagnostics);
396    }
397
398    fn all_open_files_with_diagnostic_versions(&self) -> Vec<(Uri, Option<i64>)> {
399        self.open_files.all_with_versions()
400    }
401
402    fn open_urls(&self) -> Vec<Uri> {
403        self.open_files.urls()
404    }
405
406    fn get_doc(&self, uri: &Uri) -> Option<Arc<ParsedDoc>> {
407        self.open_files.get_doc(&self.docs, uri)
408    }
409
410    fn get_doc_stale(&self, uri: &Uri) -> Option<Arc<ParsedDoc>> {
411        self.open_files.get_doc_stale(&self.docs, uri)
412    }
413
414    /// Re-mirror every open buffer's live text into `self.docs`.
415    ///
416    /// Needed after anything that rebuilds the analysis session's salsa db
417    /// (`DocumentStore::set_session_cache_dir` / `set_php_version`, when
418    /// they report a drop): the workspace scan that normally repopulates
419    /// `lsp_ws_files` explicitly skips files already open, so an open file
420    /// whose entry was cleared by the drop would otherwise stay unresolvable
421    /// (`get_doc` returning `None`) until the next edit.
422    pub(crate) fn remirror_open_files(&self) {
423        for uri in self.open_files.urls() {
424            if let Some(text) = self.open_files.text(&uri) {
425                self.docs.mirror_text_arc(&uri, text);
426            }
427        }
428    }
429
430    /// `use Foo as Bar;` map for a single file, read directly from the AST.
431    /// Memoized per `ParsedDoc` (see `ParsedDoc::file_imports`) — cheap to
432    /// call repeatedly within or across requests against the same revision.
433    fn file_imports(&self, uri: &Uri) -> Arc<std::collections::HashMap<String, String>> {
434        self.docs
435            .get_doc_salsa(uri)
436            .map(|doc| doc.file_imports())
437            .unwrap_or_default()
438    }
439
440    /// Resolve the PHP version to use. See `autoload::resolve_php_version_from_roots`
441    /// for the full priority order.
442    fn resolve_php_version(&self, explicit: Option<&str>) -> (String, &'static str) {
443        let roots = self.root_paths.load();
444        crate::lang::autoload::resolve_php_version_from_roots(&roots, explicit)
445    }
446}
447
448/// Refine the cursor's `(word, kind)` for a references request using
449/// declaration-aware heuristics, returning the (possibly rewritten) word, its
450/// symbol kind, and — for class constants — the owning class short name.
451///
452/// Checks, in order: promoted constructor property params (so `$name` in
453/// `__construct(public string $name)` resolves to the `->name` property, not
454/// `$name` variable occurrences), then property / constant
455/// declarations, falling back to the character-based `symbol_kind_at` heuristic.
456fn resolve_reference_symbol(
457    doc_opt: Option<&Arc<ParsedDoc>>,
458    source: &str,
459    position: Position,
460    word: String,
461) -> (
462    String,
463    Option<crate::navigation::references::SymbolKind>,
464    Option<String>,
465) {
466    use crate::navigation::references::SymbolKind;
467    let mut constant_owner: Option<String> = None;
468    let (word, kind) = if let Some(doc) = doc_opt
469        && let Some(prop_name) =
470            promoted_property_at_cursor(doc.source(), &doc.program().stmts, position)
471    {
472        (prop_name, Some(SymbolKind::Property))
473    } else if let Some(doc) = doc_opt {
474        let stmts = &doc.program().stmts;
475        if let Some(prop_name) = cursor_is_on_property_decl(doc.source(), stmts, position) {
476            (prop_name, Some(SymbolKind::Property))
477        } else if let Some((const_name, owner)) =
478            cursor_is_on_constant_decl(doc.source(), stmts, position)
479        {
480            constant_owner = owner;
481            (const_name, Some(SymbolKind::Constant))
482        } else {
483            let k = symbol_kind_at(source, position, &word);
484            // For constant access sites (`ClassName::CONST`, `self::CONST`),
485            // extract the owning class so the constant walker is scoped to
486            // the right class rather than falling back to the global-constant
487            // path (which would look for a top-level constant named `CONST`).
488            if matches!(k, Some(SymbolKind::Constant))
489                && let Some(raw) = class_before_double_colon(source, position)
490            {
491                constant_owner = Some(match raw.as_str() {
492                    "self" | "static" => {
493                        crate::types::type_map::enclosing_class_at(doc.source(), doc, position)
494                            .unwrap_or(raw)
495                    }
496                    // `parent::CONST` is compile-time resolved to the literal
497                    // `extends` class, never subject to late static binding —
498                    // same semantics `resolve_parent_construct_class` and
499                    // `type_definition.rs::param_decl_type` already apply.
500                    // Without this, the owner stayed the literal string
501                    // `"parent"`, which can never match a real class.
502                    "parent" => {
503                        crate::types::type_map::enclosing_class_at(doc.source(), doc, position)
504                            .and_then(|child| {
505                                crate::types::type_map::parent_class_name(doc, &child)
506                            })
507                            .unwrap_or(raw)
508                    }
509                    _ => raw,
510                });
511            }
512            (word, k)
513        }
514    } else {
515        let k = symbol_kind_at(source, position, &word);
516        (word, k)
517    };
518    (word, kind, constant_owner)
519}
520
521/// Extract the class name (or pseudo-keyword) immediately to the left of `::` at
522/// the cursor position. Returns `None` when the cursor is not on an identifier
523/// preceded by `::`.
524///
525/// Used to populate `constant_owner` for constant access sites so that
526/// `Status::ACTIVE` (cursor on `ACTIVE`) scopes the constant walker to `Status`
527/// rather than treating `ACTIVE` as a global constant.
528fn class_before_double_colon(source: &str, position: Position) -> Option<String> {
529    let line = source.lines().nth(position.line as usize)?;
530    let chars: Vec<char> = line.chars().collect();
531    let col = position.character as usize;
532
533    let mut utf16_col = 0usize;
534    let mut char_idx = 0usize;
535    for ch in &chars {
536        if utf16_col >= col {
537            break;
538        }
539        utf16_col += ch.len_utf16();
540        char_idx += 1;
541    }
542
543    let is_word = |c: char| c.is_alphanumeric() || c == '_';
544    while char_idx > 0 && is_word(chars[char_idx - 1]) {
545        char_idx -= 1;
546    }
547
548    if char_idx < 2 || chars[char_idx - 1] != ':' || chars[char_idx - 2] != ':' {
549        return None;
550    }
551
552    let class_end = char_idx - 2;
553    let mut class_start = class_end;
554    while class_start > 0 && (is_word(chars[class_start - 1]) || chars[class_start - 1] == '\\') {
555        class_start -= 1;
556    }
557
558    let name: String = chars[class_start..class_end].iter().collect();
559    if name.is_empty() { None } else { Some(name) }
560}
561
562/// Takes `docs`/`open_files` by owned clone rather than `&Backend` because
563/// did_change's blocking republish runs inside a detached `tokio::spawn`,
564/// which requires a `'static` future — a borrow of `self` wouldn't live
565/// long enough.
566async fn compute_dependent_publishes_owned(
567    docs: Arc<DocumentStore>,
568    open_files: OpenFiles,
569    changed_uri: Uri,
570    diag_cfg: crate::lang::config::DiagnosticsConfig,
571    is_laravel: bool,
572) -> Vec<(Uri, Vec<Diagnostic>)> {
573    tokio::task::spawn_blocking(move || {
574        // A dependent sweep started during the initial scan can deadlock the
575        // shared rayon pool: reanalysis retains salsa snapshots while a scan
576        // writer waits for them to drop. Defer the sweep until readiness;
577        // post-scan republishing covers every open file.
578        if !docs.is_index_ready() {
579            return Vec::new();
580        }
581
582        // rust-analyzer model: we only ever publish for files the editor has
583        // open, so re-analyze exactly that set and let salsa memoization make
584        // the unaffected ones ~free. No dependent set is computed at all —
585        // the old path rebuilt mir's dependency_graph() on every keystroke,
586        // an O(all-ingested-files) walk whose cost grew with session age.
587        let open_set: Vec<std::sync::Arc<str>> = open_files
588            .urls()
589            .into_iter()
590            .filter(|u| u != &changed_uri && !is_diagnostics_excluded(u, &diag_cfg))
591            .map(|u| std::sync::Arc::from(u.as_str()))
592            .collect();
593        if open_set.is_empty() {
594            return Vec::new();
595        }
596
597        // The user is typing: pause the background scan's write storm so this
598        // sweep's snapshots aren't repeatedly cancelled while indexing runs.
599        let _interactive = docs.interactive_read_guard();
600        // Cancel any older in-flight sweep and take a fresh token: when the
601        // next edit starts its own sweep, this one stops at its next file
602        // boundary instead of blocking typing behind the previous sweep.
603        let cancel = docs.begin_reanalyze();
604        // A newer edit that flipped `cancel` mid-sweep is now authoritative and
605        // will republish; drop this sweep's partial results so they can't land
606        // out of order and leave stale diagnostics as the final state.
607        let Some(analyses) = docs.reanalyze_files(&open_set, &cancel) else {
608            return Vec::new();
609        };
610        if cancel.is_cancelled() || analyses.is_empty() {
611            return Vec::new();
612        }
613
614        let dependents: Vec<(Uri, mir_analyzer::FileAnalysis)> = analyses
615            .into_iter()
616            .filter_map(|(file, analysis)| {
617                let url = (file.as_ref()).parse::<Uri>().ok()?;
618                Some((url, analysis))
619            })
620            .collect();
621        if dependents.is_empty() {
622            return Vec::new();
623        }
624
625        // Workspace-level class issues (circular inheritance, override
626        // violations, abstract-method gaps) aren't in `FileAnalysis` —
627        // pull them in one batched call covering every affected file.
628        let dep_files: Vec<Arc<str>> = dependents
629            .iter()
630            .map(|(u, _)| Arc::from(u.as_str()))
631            .collect();
632        let Ok(class_issues) = docs.with_snapshot(
633            |session| session.prepare_for_query(None),
634            |snap| snap.class_issues(&dep_files),
635        ) else {
636            return Vec::new();
637        };
638        let mut class_issues_by_file: std::collections::HashMap<Arc<str>, Vec<mir_issues::Issue>> =
639            std::collections::HashMap::new();
640        for issue in class_issues {
641            if issue.suppressed {
642                continue;
643            }
644            let file = issue.location.file.clone();
645            class_issues_by_file.entry(file).or_default().push(issue);
646        }
647
648        let mut out: Vec<(Uri, Vec<Diagnostic>)> = Vec::with_capacity(dependents.len());
649        for (url, analysis) in dependents {
650            let parse = open_files.parse_diagnostics(&url).unwrap_or_default();
651            let mut issues: Vec<mir_issues::Issue> = analysis
652                .issues
653                .into_iter()
654                .filter(|i| !i.suppressed)
655                .collect();
656            if let Some(extra) = class_issues_by_file.remove(&Arc::<str>::from(url.as_str())) {
657                issues.extend(extra);
658            }
659            let semantic =
660                crate::semantic_diagnostics::issues_to_diagnostics(&issues, &url, &diag_cfg);
661            let mut diags = merge_file_diagnostics(parse, semantic);
662            diags.extend(crate::laravel::unguarded_model_diagnostics(
663                &docs, &url, is_laravel,
664            ));
665            diags.extend(open_files.external_diagnostics(&url));
666            out.push((url, diags));
667        }
668        out
669    })
670    .await
671    .unwrap_or_default()
672}
673
674/// Republish diagnostics for every currently open file once the initial
675/// workspace index finishes.
676///
677/// `compute_open_file_diagnostics` holds back workspace-symbol-dependent
678/// issues (`UndefinedClass` and friends, see `issue_needs_workspace_index`)
679/// until `docs.is_index_ready()`, so a file opened during the scan may be
680/// first published missing some of those. Push-diagnostics clients (the
681/// common case) have no other trigger to re-request them — pull clients get
682/// `send_refresh_requests`'s `WorkspaceDiagnosticRefresh`, but push clients
683/// would otherwise carry a stale view until the user's next edit. Skips
684/// files whose recomputed diagnostics are identical to what was already
685/// published.
686pub(super) async fn republish_after_index_ready(
687    client: Client,
688    docs: Arc<DocumentStore>,
689    open_files: OpenFiles,
690    diag_cfg: crate::lang::config::DiagnosticsConfig,
691    is_laravel: bool,
692) {
693    let uris = open_files.urls();
694    if uris.is_empty() {
695        return;
696    }
697    let docs_ref = Arc::clone(&docs);
698    let open_files_ref = open_files.clone();
699    let all: Vec<(Uri, Vec<Diagnostic>)> = tokio::task::spawn_blocking(move || {
700        uris.into_iter()
701            .map(|uri| {
702                let diags = compute_open_file_diagnostics(
703                    &docs_ref,
704                    &open_files_ref,
705                    &uri,
706                    &diag_cfg,
707                    is_laravel,
708                );
709                (uri, diags)
710            })
711            .collect()
712    })
713    .await
714    .unwrap_or_default();
715    for (uri, diags) in all {
716        let hash = diagnostics_content_hash(&diags);
717        if open_files.published_hash(&uri) == Some(hash) {
718            continue;
719        }
720        open_files.note_published(&uri, hash);
721        client.publish_diagnostics(uri, diags, None).await;
722    }
723}
724
725/// Content hash of a diagnostics set, for skipping republishes the client
726/// already displays. In-process only — never persisted.
727pub(super) fn diagnostics_content_hash(diagnostics: &[Diagnostic]) -> u64 {
728    use std::collections::hash_map::DefaultHasher;
729    use std::hash::{Hash, Hasher};
730    let mut hasher = DefaultHasher::new();
731    serde_json::to_string(diagnostics)
732        .unwrap_or_default()
733        .hash(&mut hasher);
734    hasher.finish()
735}
736
737/// Compute and publish diagnostics for `uri`, then republish any open files
738/// that depend on it. Requires `open_files.set_parse_diagnostics` to be up to
739/// date for `uri` before this is called.
740///
741/// The edited file always publishes (editors rely on one publish per change).
742/// Dependents publish only when their content differs from the last publish —
743/// the sweep re-analyzes every open file on every edit, and unchanged results
744/// would otherwise flood the client with no-op notifications.
745pub(super) enum DiagnosticPublish {
746    Open { version: i32, generation: u64 },
747    Other(Option<i32>),
748}
749
750pub(super) async fn publish_with_dependents(
751    client: Client,
752    docs: Arc<DocumentStore>,
753    open_files: OpenFiles,
754    uri: Uri,
755    publish: DiagnosticPublish,
756    diag_cfg: crate::lang::config::DiagnosticsConfig,
757    is_laravel: bool,
758) {
759    let (version, expected_open_generation) = match publish {
760        DiagnosticPublish::Open {
761            version,
762            generation,
763        } => (Some(version), Some(generation)),
764        DiagnosticPublish::Other(version) => (version, None),
765    };
766    let docs_ref = Arc::clone(&docs);
767    let open_files_ref = open_files.clone();
768    let uri_ref = uri.clone();
769    let diag_cfg_ref = diag_cfg.clone();
770    let all_diags = tokio::task::spawn_blocking(move || {
771        compute_open_file_diagnostics(
772            &docs_ref,
773            &open_files_ref,
774            &uri_ref,
775            &diag_cfg_ref,
776            is_laravel,
777        )
778    })
779    .await
780    .unwrap_or_default();
781    if expected_open_generation
782        .is_some_and(|generation| !open_files.should_apply_lifecycle(&uri, generation, true))
783    {
784        return;
785    }
786    open_files.note_published(&uri, diagnostics_content_hash(&all_diags));
787    client
788        .publish_diagnostics(uri.clone(), all_diags, version)
789        .await;
790    let dependents =
791        compute_dependent_publishes_owned(docs, open_files.clone(), uri, diag_cfg, is_laravel)
792            .await;
793    for (dep_uri, dep_diags) in dependents {
794        let hash = diagnostics_content_hash(&dep_diags);
795        if open_files.published_hash(&dep_uri) == Some(hash) {
796            continue;
797        }
798        open_files.note_published(&dep_uri, hash);
799        client.publish_diagnostics(dep_uri, dep_diags, None).await;
800    }
801}
802
803/// Generate a stable result_id for diagnostics. Uses the count and position of diagnostics
804/// to create a stable identifier. Same diagnostics = same result_id.
805fn compute_diagnostic_result_id(diagnostics: &[Diagnostic], uri: &str) -> String {
806    use std::collections::hash_map::DefaultHasher;
807    use std::hash::{Hash, Hasher};
808
809    let mut hasher = DefaultHasher::new();
810    uri.hash(&mut hasher);
811    diagnostics.len().hash(&mut hasher);
812
813    for diag in diagnostics {
814        diag.range.start.line.hash(&mut hasher);
815        diag.range.start.character.hash(&mut hasher);
816        diag.range.end.line.hash(&mut hasher);
817        diag.range.end.character.hash(&mut hasher);
818        diag.message.hash(&mut hasher);
819        let severity_val = match diag.severity {
820            Some(tower_lsp_server::ls_types::DiagnosticSeverity::ERROR) => 1,
821            Some(tower_lsp_server::ls_types::DiagnosticSeverity::WARNING) => 2,
822            Some(tower_lsp_server::ls_types::DiagnosticSeverity::INFORMATION) => 3,
823            Some(tower_lsp_server::ls_types::DiagnosticSeverity::HINT) => 4,
824            None => 0,
825            _ => 5, // Unknown variants
826        };
827        severity_val.hash(&mut hasher);
828        if let Some(code) = &diag.code {
829            format!("{:?}", code).hash(&mut hasher);
830        }
831        if let Some(source) = &diag.source {
832            source.hash(&mut hasher);
833        }
834        if let Some(tags) = &diag.tags {
835            for tag in tags {
836                let tag_val = match *tag {
837                    tower_lsp_server::ls_types::DiagnosticTag::UNNECESSARY => 1,
838                    tower_lsp_server::ls_types::DiagnosticTag::DEPRECATED => 2,
839                    _ => 3,
840                };
841                tag_val.hash(&mut hasher);
842            }
843        }
844    }
845
846    format!("v1:{:x}", hasher.finish())
847}
848
849pub mod debug_gate;
850mod drive_letter;
851pub use drive_letter::ClientLoopback;
852mod handlers;
853mod helpers;
854pub mod offload;
855pub mod panic_guard;
856mod server;
857#[cfg(test)]
858mod tests;