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