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
12enum IndexReadyNotification {}
15impl tower_lsp_server::ls_types::notification::Notification for IndexReadyNotification {
16 type Params = ();
17 const METHOD: &'static str = "$/php-lsp/indexReady";
18}
19
20enum 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
39pub(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#[derive(Debug, serde::Serialize, serde::Deserialize)]
60pub struct DebugStats {
61 pub parses: u64,
63 pub ref_index_locks: u64,
67 pub warm_sweeps_completed: u64,
70 pub workspace_symbol_index_ready: bool,
73 pub workspace_index_walks: u64,
77 pub reachability_scan_passes: u64,
84 pub analysis_compute_count: u64,
89 pub warm_start_replays_completed: u64,
94 pub debug_gate_held: Option<String>,
98 pub workspace_file_count: u64,
101 pub token_cache_len: u64,
103 pub text_cache_len: u64,
106 pub parsed_cache_len: u64,
108 pub analysis_cache_len: u64,
110 pub owned_program_cache_len: u64,
112 pub vendor_index_cache_len: u64,
116 pub mir_ref_query_cache_hits: u64,
119 pub mir_subtype_query_cache_hits: u64,
122 pub mir_mention_scans_recorded: u64,
128}
129
130#[cfg(feature = "test-hooks")]
132#[derive(Debug, serde::Serialize, serde::Deserialize)]
133pub struct DebugHoldGateParams {
134 pub section: String,
136}
137
138pub 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
153pub 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_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 client_capabilities: Arc<ArcSwap<ClientCapabilities>>,
202 debug_gate: Arc<debug_gate::DebugGate>,
204}
205
206impl Backend {
207 pub fn new(client: Client) -> Self {
208 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 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 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 #[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(¶ms.section);
286 Ok(())
287 }
288
289 #[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 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 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 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 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 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 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 let short_owner =
409 crate::types::type_map::enclosing_class_at(doc.source(), doc, position)?;
410 Some(crate::navigation::moniker::resolve_fqn(
412 doc,
413 &short_owner,
414 &imports,
415 ))
416 }
417 Some(SymbolKind::Property) => {
418 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 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 let fqn = crate::navigation::moniker::resolve_fqn(doc, word, &imports);
456 fqn.contains('\\').then_some(fqn)
457 }
458 }
459 _ => None,
460 }
461 }
462
463 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
471fn 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 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" => {
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
544fn 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
585async 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 if !docs.is_index_ready() {
602 return Vec::new();
603 }
604
605 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 let _interactive = docs.interactive_read_guard();
623 let cancel = docs.begin_reanalyze();
627 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 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
697pub(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
748pub(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
760pub(super) async fn publish_with_dependents(
769 client: Client,
770 docs: Arc<DocumentStore>,
771 open_files: OpenFiles,
772 uri: Uri,
773 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
812fn 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, };
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;