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
15enum IndexReadyNotification {}
18impl tower_lsp_server::ls_types::notification::Notification for IndexReadyNotification {
19 type Params = ();
20 const METHOD: &'static str = "$/php-lsp/indexReady";
21}
22
23enum 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
42pub(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#[derive(Debug, serde::Serialize, serde::Deserialize)]
63pub struct DebugStats {
64 pub parses: u64,
66 pub ref_index_locks: u64,
70 pub warm_sweeps_completed: u64,
73 pub workspace_symbol_index_ready: bool,
76 pub workspace_index_walks: u64,
80 pub reachability_scan_passes: u64,
87 pub analysis_compute_count: u64,
92 pub warm_start_replays_completed: u64,
97 pub debug_gate_held: Option<String>,
101 pub lifecycle_intents: u64,
104 pub workspace_file_count: u64,
107 pub token_cache_len: u64,
109 pub text_cache_len: u64,
112 pub parsed_cache_len: u64,
114 pub analysis_cache_len: u64,
116 pub owned_program_cache_len: u64,
118 pub vendor_index_cache_len: u64,
122 pub mir_ref_query_cache_hits: u64,
125 pub mir_subtype_query_cache_hits: u64,
128 pub mir_mention_scans_recorded: u64,
134}
135
136#[cfg(feature = "test-hooks")]
138#[derive(Debug, serde::Serialize, serde::Deserialize)]
139pub struct DebugHoldGateParams {
140 pub section: String,
142}
143
144pub fn build_lsp_service() -> (LifecycleService, tower_lsp_server::ClientSocket) {
149 let builder = tower_lsp_server::LspService::build(Backend::new)
150 .custom_method("$/php-lsp/debugStats", Backend::debug_stats);
151 #[cfg(feature = "test-hooks")]
152 let builder = builder
153 .custom_method("$/php-lsp/debugHoldGate", Backend::debug_hold_gate)
154 .custom_method("$/php-lsp/debugReleaseGate", Backend::debug_release_gate);
155 let (service, socket) = builder.finish();
156 (LifecycleService { inner: service }, socket)
157}
158
159pub struct LifecycleService {
163 inner: tower_lsp_server::LspService<Backend>,
164}
165
166tokio::task_local! {
167 static LIFECYCLE_GENERATION: u64;
168}
169
170fn lifecycle_generation() -> u64 {
171 LIFECYCLE_GENERATION
172 .try_with(|generation| *generation)
173 .expect("document lifecycle handler called outside LifecycleService")
174}
175
176struct LifecycleIntentGuard {
177 open_files: OpenFiles,
178 uri: Uri,
179 generation: u64,
180}
181
182impl Drop for LifecycleIntentGuard {
183 fn drop(&mut self) {
184 self.open_files.finish_lifecycle_intent(&self.uri);
185 }
186}
187
188impl Service<JsonRpcRequest> for LifecycleService {
189 type Response = Option<JsonRpcResponse>;
190 type Error = tower_lsp_server::ExitedError;
191 type Future = Either<
192 Pin<
193 Box<
194 dyn Future<Output = Result<Option<JsonRpcResponse>, tower_lsp_server::ExitedError>>
195 + Send,
196 >,
197 >,
198 <tower_lsp_server::LspService<Backend> as Service<JsonRpcRequest>>::Future,
199 >;
200
201 fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
202 self.inner.poll_ready(cx)
203 }
204
205 fn call(&mut self, request: JsonRpcRequest) -> Self::Future {
206 let lifecycle = self.inner.inner().note_lifecycle_intent(&request);
207 let future = self.inner.call(request);
208 match lifecycle {
209 Some(guard) => {
210 let generation = guard.generation;
211 Either::Left(Box::pin(LIFECYCLE_GENERATION.scope(
212 generation,
213 async move {
214 let _guard = guard;
215 future.await
216 },
217 )))
218 }
219 None => Either::Right(future),
220 }
221 }
222}
223
224use crate::document::ast::ParsedDoc;
225use crate::document::document_store::DocumentStore;
226use crate::document::open_files::OpenFiles;
227use crate::lang::autoload::Psr4Map;
228use crate::lang::config::LspConfig;
229use crate::laravel::LaravelIndex;
230
231use crate::analysis::diagnostics::merge_file_diagnostics;
232use crate::document::open_files::compute_open_file_diagnostics;
233
234pub struct Backend {
235 client: Client,
236 docs: Arc<DocumentStore>,
237 open_files: OpenFiles,
241 root_paths: Arc<ArcSwap<Vec<PathBuf>>>,
242 psr4: Arc<ArcSwap<Psr4Map>>,
243 laravel: Arc<ArcSwap<LaravelIndex>>,
244 config: Arc<ArcSwap<LspConfig>>,
245 client_capabilities: Arc<ArcSwap<ClientCapabilities>>,
251 debug_gate: Arc<debug_gate::DebugGate>,
253}
254
255impl Backend {
256 pub fn new(client: Client) -> Self {
257 let docs = Arc::new(DocumentStore::new());
262 let psr4 = docs.psr4_arc();
263 Backend {
264 client,
265 docs,
266 open_files: OpenFiles::new(),
267 root_paths: Arc::new(ArcSwap::from_pointee(Vec::new())),
268 psr4,
269 laravel: Arc::new(ArcSwap::from_pointee(LaravelIndex::default())),
270 config: Arc::new(ArcSwap::from_pointee(LspConfig::default())),
271 client_capabilities: Arc::new(ArcSwap::from_pointee(ClientCapabilities::default())),
272 debug_gate: Arc::new(debug_gate::DebugGate::new()),
273 }
274 }
275
276 fn note_lifecycle_intent(&self, request: &JsonRpcRequest) -> Option<LifecycleIntentGuard> {
279 let is_open = match request.method() {
280 "textDocument/didOpen" => true,
281 "textDocument/didClose" => false,
282 _ => return None,
283 };
284 let uri = request
285 .params()
286 .and_then(|params| serde_json::to_value(params).ok())
287 .and_then(|params| {
288 params
289 .pointer("/textDocument/uri")?
290 .as_str()
291 .map(str::to_owned)
292 })
293 .and_then(|uri| uri.parse::<Uri>().ok())?;
294 let generation = self.open_files.note_lifecycle_intent(uri.clone(), is_open);
295 Some(LifecycleIntentGuard {
296 open_files: self.open_files.clone(),
297 uri,
298 generation,
299 })
300 }
301
302 pub async fn debug_stats(&self) -> tower_lsp_server::jsonrpc::Result<DebugStats> {
306 Ok(DebugStats {
307 parses: self.docs.parse_count(),
308 ref_index_locks: self.docs.ref_index_lock_count(),
309 warm_sweeps_completed: self.docs.warm_sweeps_completed(),
310 workspace_symbol_index_ready: self.docs.workspace_symbol_index_ready(),
311 workspace_index_walks: self.docs.workspace_index_walks(),
312 reachability_scan_passes: self.docs.reachability_scan_passes(),
313 analysis_compute_count: self.docs.analysis_compute_count(),
314 warm_start_replays_completed: self.docs.warm_start_replays_completed(),
315 debug_gate_held: self.debug_gate.held_section(),
316 lifecycle_intents: self.open_files.lifecycle_intent_count() as u64,
317 workspace_file_count: self.docs.workspace_file_count(),
318 token_cache_len: self.docs.token_cache_len(),
319 text_cache_len: self.docs.text_cache_len(),
320 parsed_cache_len: self.docs.parsed_cache_len(),
321 analysis_cache_len: self.docs.analysis_cache_len(),
322 owned_program_cache_len: self.docs.owned_program_cache_len(),
323 vendor_index_cache_len: self.docs.vendor_index_cache_len(),
324 mir_ref_query_cache_hits: self.docs.mir_ref_query_cache_hits(),
325 mir_subtype_query_cache_hits: self.docs.mir_subtype_query_cache_hits(),
326 mir_mention_scans_recorded: self.docs.mir_mention_scans_recorded(),
327 })
328 }
329
330 #[cfg(feature = "test-hooks")]
333 pub async fn debug_hold_gate(
334 &self,
335 params: DebugHoldGateParams,
336 ) -> tower_lsp_server::jsonrpc::Result<()> {
337 self.debug_gate.arm(¶ms.section);
338 Ok(())
339 }
340
341 #[cfg(feature = "test-hooks")]
344 pub async fn debug_release_gate(&self) -> tower_lsp_server::jsonrpc::Result<()> {
345 self.debug_gate.release();
346 Ok(())
347 }
348
349 fn set_open_text(&self, uri: Uri, text: String) -> u64 {
350 self.open_files.set_open_text(&self.docs, uri, text)
351 }
352
353 fn close_open_file(&self, uri: &Uri) {
354 self.open_files.close(&self.docs, uri);
355 }
356
357 fn ingest_if_not_open(&self, uri: Uri, text: &str) {
361 if !self.open_files.contains(&uri) {
362 self.docs.ingest(uri, text);
363 }
364 }
365
366 fn get_open_text(&self, uri: &Uri) -> Option<Arc<str>> {
367 self.open_files.text(uri)
368 }
369
370 async fn blocking<F, R>(&self, label: &'static str, f: F) -> Option<R>
373 where
374 F: FnOnce() -> R + Send + 'static,
375 R: Send + 'static,
376 {
377 offload::run(label, f).await
378 }
379
380 async fn blocking_gated<F, R>(&self, section: &'static str, f: F) -> Option<R>
383 where
384 F: FnOnce() -> R + Send + 'static,
385 R: Send + 'static,
386 {
387 offload::run_gated(&self.debug_gate, section, f).await
388 }
389
390 fn set_parse_diagnostics(&self, uri: &Uri, diagnostics: Vec<Diagnostic>) {
391 self.open_files.set_parse_diagnostics(uri, diagnostics);
392 }
393
394 fn all_open_files_with_diagnostic_versions(&self) -> Vec<(Uri, Option<i64>)> {
395 self.open_files.all_with_versions()
396 }
397
398 fn open_urls(&self) -> Vec<Uri> {
399 self.open_files.urls()
400 }
401
402 fn get_doc(&self, uri: &Uri) -> Option<Arc<ParsedDoc>> {
403 self.open_files.get_doc(&self.docs, uri)
404 }
405
406 fn get_doc_stale(&self, uri: &Uri) -> Option<Arc<ParsedDoc>> {
407 self.open_files.get_doc_stale(&self.docs, uri)
408 }
409
410 pub(crate) fn remirror_open_files(&self) {
419 for uri in self.open_files.urls() {
420 if let Some(text) = self.open_files.text(&uri) {
421 self.docs.mirror_text_arc(&uri, text);
422 }
423 }
424 }
425
426 fn file_imports(&self, uri: &Uri) -> Arc<std::collections::HashMap<String, String>> {
430 self.docs
431 .get_doc_salsa(uri)
432 .map(|doc| doc.file_imports())
433 .unwrap_or_default()
434 }
435
436 fn resolve_php_version(&self, explicit: Option<&str>) -> (String, &'static str) {
439 let roots = self.root_paths.load();
440 crate::lang::autoload::resolve_php_version_from_roots(&roots, explicit)
441 }
442}
443
444fn resolve_reference_symbol(
453 doc_opt: Option<&Arc<ParsedDoc>>,
454 source: &str,
455 position: Position,
456 word: String,
457) -> (
458 String,
459 Option<crate::navigation::references::SymbolKind>,
460 Option<String>,
461) {
462 use crate::navigation::references::SymbolKind;
463 let mut constant_owner: Option<String> = None;
464 let (word, kind) = if let Some(doc) = doc_opt
465 && let Some(prop_name) =
466 promoted_property_at_cursor(doc.source(), &doc.program().stmts, position)
467 {
468 (prop_name, Some(SymbolKind::Property))
469 } else if let Some(doc) = doc_opt {
470 let stmts = &doc.program().stmts;
471 if let Some(prop_name) = cursor_is_on_property_decl(doc.source(), stmts, position) {
472 (prop_name, Some(SymbolKind::Property))
473 } else if let Some((const_name, owner)) =
474 cursor_is_on_constant_decl(doc.source(), stmts, position)
475 {
476 constant_owner = owner;
477 (const_name, Some(SymbolKind::Constant))
478 } else {
479 let k = symbol_kind_at(source, position, &word);
480 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" => {
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
517fn 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
558async 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 if !docs.is_index_ready() {
575 return Vec::new();
576 }
577
578 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 _interactive = docs.interactive_read_guard();
596 let cancel = docs.begin_reanalyze();
600 let Some(analyses) = docs.reanalyze_files(&open_set, &cancel) else {
604 return Vec::new();
605 };
606 if cancel.is_cancelled() || analyses.is_empty() {
607 return Vec::new();
608 }
609
610 let dependents: Vec<(Uri, mir_analyzer::FileAnalysis)> = analyses
611 .into_iter()
612 .filter_map(|(file, analysis)| {
613 let url = (file.as_ref()).parse::<Uri>().ok()?;
614 Some((url, analysis))
615 })
616 .collect();
617 if dependents.is_empty() {
618 return Vec::new();
619 }
620
621 let dep_files: Vec<Arc<str>> = dependents
625 .iter()
626 .map(|(u, _)| Arc::from(u.as_str()))
627 .collect();
628 let Ok(class_issues) = docs.with_snapshot(
629 |session| session.prepare_for_query(None),
630 |snap| snap.class_issues(&dep_files),
631 ) else {
632 return Vec::new();
633 };
634 let mut class_issues_by_file: std::collections::HashMap<Arc<str>, Vec<mir_issues::Issue>> =
635 std::collections::HashMap::new();
636 for issue in class_issues {
637 if issue.suppressed {
638 continue;
639 }
640 let file = issue.location.file.clone();
641 class_issues_by_file.entry(file).or_default().push(issue);
642 }
643
644 let mut out: Vec<(Uri, Vec<Diagnostic>)> = Vec::with_capacity(dependents.len());
645 for (url, analysis) in dependents {
646 let parse = open_files.parse_diagnostics(&url).unwrap_or_default();
647 let mut issues: Vec<mir_issues::Issue> = analysis
648 .issues
649 .into_iter()
650 .filter(|i| !i.suppressed)
651 .collect();
652 if let Some(extra) = class_issues_by_file.remove(&Arc::<str>::from(url.as_str())) {
653 issues.extend(extra);
654 }
655 let semantic =
656 crate::semantic_diagnostics::issues_to_diagnostics(&issues, &url, &diag_cfg);
657 let mut diags = merge_file_diagnostics(parse, semantic);
658 diags.extend(crate::laravel::unguarded_model_diagnostics(
659 &docs, &url, is_laravel,
660 ));
661 diags.extend(open_files.external_diagnostics(&url));
662 out.push((url, diags));
663 }
664 out
665 })
666 .await
667 .unwrap_or_default()
668}
669
670pub(super) async fn republish_after_index_ready(
683 client: Client,
684 docs: Arc<DocumentStore>,
685 open_files: OpenFiles,
686 diag_cfg: crate::lang::config::DiagnosticsConfig,
687 is_laravel: bool,
688) {
689 let uris = open_files.urls();
690 if uris.is_empty() {
691 return;
692 }
693 let docs_ref = Arc::clone(&docs);
694 let open_files_ref = open_files.clone();
695 let all: Vec<(Uri, Vec<Diagnostic>)> = tokio::task::spawn_blocking(move || {
696 uris.into_iter()
697 .map(|uri| {
698 let diags = compute_open_file_diagnostics(
699 &docs_ref,
700 &open_files_ref,
701 &uri,
702 &diag_cfg,
703 is_laravel,
704 );
705 (uri, diags)
706 })
707 .collect()
708 })
709 .await
710 .unwrap_or_default();
711 for (uri, diags) in all {
712 let hash = diagnostics_content_hash(&diags);
713 if open_files.published_hash(&uri) == Some(hash) {
714 continue;
715 }
716 open_files.note_published(&uri, hash);
717 client.publish_diagnostics(uri, diags, None).await;
718 }
719}
720
721pub(super) fn diagnostics_content_hash(diagnostics: &[Diagnostic]) -> u64 {
724 use std::collections::hash_map::DefaultHasher;
725 use std::hash::{Hash, Hasher};
726 let mut hasher = DefaultHasher::new();
727 serde_json::to_string(diagnostics)
728 .unwrap_or_default()
729 .hash(&mut hasher);
730 hasher.finish()
731}
732
733pub(super) enum DiagnosticPublish {
742 Open { version: i32, generation: u64 },
743 Other(Option<i32>),
744}
745
746pub(super) async fn publish_with_dependents(
747 client: Client,
748 docs: Arc<DocumentStore>,
749 open_files: OpenFiles,
750 uri: Uri,
751 publish: DiagnosticPublish,
752 diag_cfg: crate::lang::config::DiagnosticsConfig,
753 is_laravel: bool,
754) {
755 let (version, expected_open_generation) = match publish {
756 DiagnosticPublish::Open {
757 version,
758 generation,
759 } => (Some(version), Some(generation)),
760 DiagnosticPublish::Other(version) => (version, None),
761 };
762 let docs_ref = Arc::clone(&docs);
763 let open_files_ref = open_files.clone();
764 let uri_ref = uri.clone();
765 let diag_cfg_ref = diag_cfg.clone();
766 let all_diags = tokio::task::spawn_blocking(move || {
767 compute_open_file_diagnostics(
768 &docs_ref,
769 &open_files_ref,
770 &uri_ref,
771 &diag_cfg_ref,
772 is_laravel,
773 )
774 })
775 .await
776 .unwrap_or_default();
777 if expected_open_generation
778 .is_some_and(|generation| !open_files.should_apply_lifecycle(&uri, generation, true))
779 {
780 return;
781 }
782 open_files.note_published(&uri, diagnostics_content_hash(&all_diags));
783 client
784 .publish_diagnostics(uri.clone(), all_diags, version)
785 .await;
786 let dependents =
787 compute_dependent_publishes_owned(docs, open_files.clone(), uri, diag_cfg, is_laravel)
788 .await;
789 for (dep_uri, dep_diags) in dependents {
790 let hash = diagnostics_content_hash(&dep_diags);
791 if open_files.published_hash(&dep_uri) == Some(hash) {
792 continue;
793 }
794 open_files.note_published(&dep_uri, hash);
795 client.publish_diagnostics(dep_uri, dep_diags, None).await;
796 }
797}
798
799fn compute_diagnostic_result_id(diagnostics: &[Diagnostic], uri: &str) -> String {
802 use std::collections::hash_map::DefaultHasher;
803 use std::hash::{Hash, Hasher};
804
805 let mut hasher = DefaultHasher::new();
806 uri.hash(&mut hasher);
807 diagnostics.len().hash(&mut hasher);
808
809 for diag in diagnostics {
810 diag.range.start.line.hash(&mut hasher);
811 diag.range.start.character.hash(&mut hasher);
812 diag.range.end.line.hash(&mut hasher);
813 diag.range.end.character.hash(&mut hasher);
814 diag.message.hash(&mut hasher);
815 let severity_val = match diag.severity {
816 Some(tower_lsp_server::ls_types::DiagnosticSeverity::ERROR) => 1,
817 Some(tower_lsp_server::ls_types::DiagnosticSeverity::WARNING) => 2,
818 Some(tower_lsp_server::ls_types::DiagnosticSeverity::INFORMATION) => 3,
819 Some(tower_lsp_server::ls_types::DiagnosticSeverity::HINT) => 4,
820 None => 0,
821 _ => 5, };
823 severity_val.hash(&mut hasher);
824 if let Some(code) = &diag.code {
825 format!("{:?}", code).hash(&mut hasher);
826 }
827 if let Some(source) = &diag.source {
828 source.hash(&mut hasher);
829 }
830 if let Some(tags) = &diag.tags {
831 for tag in tags {
832 let tag_val = match *tag {
833 tower_lsp_server::ls_types::DiagnosticTag::UNNECESSARY => 1,
834 tower_lsp_server::ls_types::DiagnosticTag::DEPRECATED => 2,
835 _ => 3,
836 };
837 tag_val.hash(&mut hasher);
838 }
839 }
840 }
841
842 format!("v1:{:x}", hasher.finish())
843}
844
845pub mod debug_gate;
846mod handlers;
847mod helpers;
848pub mod offload;
849pub mod panic_guard;
850mod server;
851#[cfg(test)]
852mod tests;