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