Skip to main content

php_lsp/backend/
mod.rs

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