Skip to main content

php_lsp/backend/
mod.rs

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