1use std::path::PathBuf;
2use std::sync::{Arc, RwLock};
3
4use dashmap::DashMap;
5use tower_lsp::jsonrpc::Result;
6use tower_lsp::lsp_types::notification::Progress as ProgressNotification;
7
8enum IndexReadyNotification {}
11impl tower_lsp::lsp_types::notification::Notification for IndexReadyNotification {
12 type Params = ();
13 const METHOD: &'static str = "$/php-lsp/indexReady";
14}
15use tower_lsp::lsp_types::request::{
16 CodeLensRefresh, InlayHintRefreshRequest, InlineValueRefreshRequest, SemanticTokensRefresh,
17 WorkDoneProgressCreate, WorkspaceDiagnosticRefresh,
18};
19use tower_lsp::lsp_types::*;
20use tower_lsp::{Client, LanguageServer, async_trait};
21
22use php_ast::{ClassMemberKind, EnumMemberKind, NamespaceBody, Stmt, StmtKind};
23
24use crate::ast::{ParsedDoc, str_offset};
25use crate::autoload::Psr4Map;
26use crate::call_hierarchy::{incoming_calls, outgoing_calls, prepare_call_hierarchy};
27use crate::code_lens::code_lenses;
28use crate::completion::{CompletionCtx, filtered_completions_at};
29use crate::declaration::{goto_declaration, goto_declaration_from_index};
30use crate::definition::{find_declaration_range, find_in_indexes, goto_definition};
31use crate::diagnostics::parse_document;
32use crate::document_highlight::document_highlights;
33use crate::document_link::document_links;
34use crate::document_store::DocumentStore;
35use crate::extract_action::extract_variable_actions;
36use crate::extract_constant_action::extract_constant_actions;
37use crate::extract_method_action::extract_method_actions;
38use crate::file_rename::{use_edits_for_delete, use_edits_for_rename};
39use crate::folding::folding_ranges;
40use crate::formatting::{format_document, format_range};
41use crate::generate_action::{generate_constructor_actions, generate_getters_setters_actions};
42use crate::hover::{
43 class_hover_from_index, docs_for_symbol_from_index, hover_info, signature_for_symbol_from_index,
44};
45use crate::implement_action::implement_missing_actions;
46use crate::implementation::{find_implementations, find_implementations_from_workspace};
47use crate::inlay_hints::inlay_hints;
48use crate::inline_action::inline_variable_actions;
49use crate::inline_value::inline_values_in_range;
50use crate::moniker::moniker_at;
51use crate::on_type_format::on_type_format;
52use crate::organize_imports::organize_imports_action;
53use crate::phpdoc_action::phpdoc_actions;
54use crate::phpstorm_meta::PhpStormMeta;
55use crate::promote_action::promote_constructor_actions;
56use crate::references::{
57 SymbolKind, find_constructor_references, find_references, find_references_codebase_with_target,
58 find_references_with_target,
59};
60use crate::rename::{prepare_rename, rename, rename_property, rename_variable};
61use crate::selection_range::selection_ranges;
62use crate::semantic_diagnostics::duplicate_declaration_diagnostics;
63use crate::semantic_tokens::{
64 compute_token_delta, legend, semantic_tokens, semantic_tokens_range, token_hash,
65};
66use crate::signature_help::signature_help;
67use crate::symbols::{
68 document_symbols, resolve_workspace_symbol, workspace_symbols_from_workspace,
69};
70use crate::type_action::add_return_type_actions;
71use crate::type_definition::{goto_type_definition, goto_type_definition_from_index};
72use crate::type_hierarchy::{
73 prepare_type_hierarchy_from_workspace, subtypes_of_from_workspace, supertypes_of_from_workspace,
74};
75use crate::use_import::{build_use_import_edit, find_fqn_for_class};
76use crate::util::word_at;
77
78#[derive(Debug, Clone)]
84pub struct DiagnosticsConfig {
85 pub enabled: bool,
87 pub undefined_variables: bool,
89 pub undefined_functions: bool,
91 pub undefined_classes: bool,
93 pub arity_errors: bool,
95 pub type_errors: bool,
97 pub deprecated_calls: bool,
99 pub duplicate_declarations: bool,
101}
102
103impl Default for DiagnosticsConfig {
104 fn default() -> Self {
105 DiagnosticsConfig {
106 enabled: false,
107 undefined_variables: true,
108 undefined_functions: true,
109 undefined_classes: true,
110 arity_errors: true,
111 type_errors: true,
112 deprecated_calls: true,
113 duplicate_declarations: true,
114 }
115 }
116}
117
118impl DiagnosticsConfig {
119 #[cfg(test)]
122 pub fn all_enabled() -> Self {
123 DiagnosticsConfig {
124 enabled: true,
125 ..DiagnosticsConfig::default()
126 }
127 }
128
129 fn from_value(v: &serde_json::Value) -> Self {
130 let mut cfg = DiagnosticsConfig::default();
131 let Some(obj) = v.as_object() else { return cfg };
132 let flag = |key: &str| obj.get(key).and_then(|x| x.as_bool()).unwrap_or(true);
133 cfg.enabled = obj
134 .get("enabled")
135 .and_then(|x| x.as_bool())
136 .unwrap_or(false);
137 cfg.undefined_variables = flag("undefinedVariables");
138 cfg.undefined_functions = flag("undefinedFunctions");
139 cfg.undefined_classes = flag("undefinedClasses");
140 cfg.arity_errors = flag("arityErrors");
141 cfg.type_errors = flag("typeErrors");
142 cfg.deprecated_calls = flag("deprecatedCalls");
143 cfg.duplicate_declarations = flag("duplicateDeclarations");
144 cfg
145 }
146}
147
148#[derive(Debug, Clone)]
150pub struct LspConfig {
151 pub php_version: Option<String>,
154 pub exclude_paths: Vec<String>,
156 pub diagnostics: DiagnosticsConfig,
158 pub max_indexed_files: usize,
162}
163
164impl Default for LspConfig {
165 fn default() -> Self {
166 LspConfig {
167 php_version: None,
168 exclude_paths: Vec::new(),
169 diagnostics: DiagnosticsConfig::default(),
170 max_indexed_files: MAX_INDEXED_FILES,
171 }
172 }
173}
174
175impl LspConfig {
176 pub fn merge_project_configs(
184 file: Option<&serde_json::Value>,
185 editor: Option<&serde_json::Value>,
186 ) -> serde_json::Value {
187 let mut merged = file
188 .cloned()
189 .unwrap_or(serde_json::Value::Object(Default::default()));
190 let Some(editor_obj) = editor.and_then(|e| e.as_object()) else {
191 return merged;
192 };
193 let merged_obj = merged
194 .as_object_mut()
195 .expect("merged base is always an object");
196 for (key, val) in editor_obj {
197 if key == "excludePaths" {
198 let file_arr = merged_obj
199 .get("excludePaths")
200 .and_then(|v| v.as_array())
201 .cloned()
202 .unwrap_or_default();
203 let editor_arr = val.as_array().cloned().unwrap_or_default();
204 merged_obj.insert(
205 key.clone(),
206 serde_json::Value::Array([file_arr, editor_arr].concat()),
207 );
208 } else {
209 merged_obj.insert(key.clone(), val.clone());
210 }
211 }
212 merged
213 }
214
215 fn from_value(v: &serde_json::Value) -> Self {
216 let mut cfg = LspConfig::default();
217 if let Some(ver) = v.get("phpVersion").and_then(|x| x.as_str())
218 && crate::autoload::is_valid_php_version(ver)
219 {
220 cfg.php_version = Some(ver.to_string());
221 }
222 if let Some(arr) = v.get("excludePaths").and_then(|x| x.as_array()) {
223 cfg.exclude_paths = arr
224 .iter()
225 .filter_map(|x| x.as_str().map(str::to_string))
226 .collect();
227 }
228 if let Some(diag_val) = v.get("diagnostics") {
229 cfg.diagnostics = DiagnosticsConfig::from_value(diag_val);
230 }
231 if let Some(n) = v.get("maxIndexedFiles").and_then(|x| x.as_u64()) {
232 cfg.max_indexed_files = n as usize;
233 }
234 cfg
235 }
236}
237
238#[derive(Default, Clone)]
245struct OpenFile {
246 text: String,
248 version: u64,
251 parse_diagnostics: Vec<Diagnostic>,
253}
254
255#[derive(Clone, Default)]
258pub struct OpenFiles(Arc<DashMap<Url, OpenFile>>);
259
260impl OpenFiles {
261 fn new() -> Self {
262 Self::default()
263 }
264
265 fn set_open_text(&self, docs: &DocumentStore, uri: Url, text: String) -> u64 {
266 docs.mirror_text(&uri, &text);
267 let mut entry = self.0.entry(uri).or_default();
268 entry.version += 1;
269 entry.text = text;
270 entry.version
271 }
272
273 fn close(&self, docs: &DocumentStore, uri: &Url) {
274 self.0.remove(uri);
275 docs.evict_token_cache(uri);
276 }
277
278 fn current_version(&self, uri: &Url) -> Option<u64> {
279 self.0.get(uri).map(|e| e.version)
280 }
281
282 fn text(&self, uri: &Url) -> Option<String> {
283 self.0.get(uri).map(|e| e.text.clone())
284 }
285
286 fn set_parse_diagnostics(&self, uri: &Url, diagnostics: Vec<Diagnostic>) {
287 if let Some(mut entry) = self.0.get_mut(uri) {
288 entry.parse_diagnostics = diagnostics;
289 }
290 }
291
292 fn parse_diagnostics(&self, uri: &Url) -> Option<Vec<Diagnostic>> {
293 self.0.get(uri).map(|e| e.parse_diagnostics.clone())
294 }
295
296 fn all_with_diagnostics(&self) -> Vec<(Url, Vec<Diagnostic>, Option<i64>)> {
297 self.0
298 .iter()
299 .map(|e| {
300 (
301 e.key().clone(),
302 e.value().parse_diagnostics.clone(),
303 Some(e.value().version as i64),
304 )
305 })
306 .collect()
307 }
308
309 fn urls(&self) -> Vec<Url> {
310 self.0.iter().map(|e| e.key().clone()).collect()
311 }
312
313 fn contains(&self, uri: &Url) -> bool {
314 self.0.contains_key(uri)
315 }
316
317 fn get_doc(&self, docs: &DocumentStore, uri: &Url) -> Option<Arc<ParsedDoc>> {
319 if !self.contains(uri) {
320 return None;
321 }
322 docs.get_doc_salsa(uri)
323 }
324}
325
326fn compute_open_file_diagnostics(
342 docs: &DocumentStore,
343 open_files: &OpenFiles,
344 uri: &Url,
345 diag_cfg: &DiagnosticsConfig,
346) -> Vec<Diagnostic> {
347 let mut out = open_files.parse_diagnostics(uri).unwrap_or_default();
348 let source = open_files.text(uri).unwrap_or_default();
349 if let Some(d) = open_files.get_doc(docs, uri) {
350 out.extend(duplicate_declaration_diagnostics(&source, &d, diag_cfg));
351 }
352 if let Some(issues) = docs.get_semantic_issues_salsa(uri) {
353 out.extend(crate::semantic_diagnostics::issues_to_diagnostics(
354 &issues, uri, diag_cfg,
355 ));
356 }
357 out
358}
359
360pub struct Backend {
361 client: Client,
362 docs: Arc<DocumentStore>,
363 open_files: OpenFiles,
367 root_paths: Arc<RwLock<Vec<PathBuf>>>,
368 psr4: Arc<RwLock<Psr4Map>>,
369 meta: Arc<RwLock<PhpStormMeta>>,
370 config: Arc<RwLock<LspConfig>>,
371}
372
373impl Backend {
374 pub fn new(client: Client) -> Self {
375 Backend {
380 client,
381 docs: Arc::new(DocumentStore::new()),
382 open_files: OpenFiles::new(),
383 root_paths: Arc::new(RwLock::new(Vec::new())),
384 psr4: Arc::new(RwLock::new(Psr4Map::empty())),
385 meta: Arc::new(RwLock::new(PhpStormMeta::default())),
386 config: Arc::new(RwLock::new(LspConfig::default())),
387 }
388 }
389
390 fn set_open_text(&self, uri: Url, text: String) -> u64 {
393 self.open_files.set_open_text(&self.docs, uri, text)
394 }
395
396 fn close_open_file(&self, uri: &Url) {
397 self.open_files.close(&self.docs, uri);
398 }
399
400 fn index_if_not_open(&self, uri: Url, text: &str) {
404 if !self.open_files.contains(&uri) {
405 self.docs.index(uri, text);
406 }
407 }
408
409 fn index_from_doc_if_not_open(&self, uri: Url, doc: &ParsedDoc, diags: Vec<Diagnostic>) {
411 if !self.open_files.contains(&uri) {
412 self.docs.index_from_doc(uri, doc, diags);
413 }
414 }
415
416 fn get_open_text(&self, uri: &Url) -> Option<String> {
417 self.open_files.text(uri)
418 }
419
420 fn set_parse_diagnostics(&self, uri: &Url, diagnostics: Vec<Diagnostic>) {
421 self.open_files.set_parse_diagnostics(uri, diagnostics);
422 }
423
424 fn get_parse_diagnostics(&self, uri: &Url) -> Option<Vec<Diagnostic>> {
425 self.open_files.parse_diagnostics(uri)
426 }
427
428 fn all_open_files_with_diagnostics(&self) -> Vec<(Url, Vec<Diagnostic>, Option<i64>)> {
429 self.open_files.all_with_diagnostics()
430 }
431
432 fn open_urls(&self) -> Vec<Url> {
433 self.open_files.urls()
434 }
435
436 fn get_doc(&self, uri: &Url) -> Option<Arc<ParsedDoc>> {
437 self.open_files.get_doc(&self.docs, uri)
438 }
439
440 fn codebase(&self) -> Arc<mir_codebase::Codebase> {
445 self.docs.get_codebase_salsa()
446 }
447
448 fn file_imports(&self, uri: &Url) -> std::collections::HashMap<String, String> {
450 self.codebase()
451 .file_imports
452 .get(uri.as_str())
453 .map(|r| r.clone())
454 .unwrap_or_default()
455 }
456
457 fn resolve_php_version(&self, explicit: Option<&str>) -> (String, &'static str) {
460 let roots = self.root_paths.read().unwrap().clone();
461 crate::autoload::resolve_php_version_from_roots(&roots, explicit)
462 }
463}
464
465#[async_trait]
466impl LanguageServer for Backend {
467 async fn initialize(&self, params: InitializeParams) -> Result<InitializeResult> {
468 {
471 let mut roots: Vec<PathBuf> = params
472 .workspace_folders
473 .as_deref()
474 .unwrap_or(&[])
475 .iter()
476 .filter_map(|f| f.uri.to_file_path().ok())
477 .collect();
478 if roots.is_empty()
479 && let Some(path) = params.root_uri.as_ref().and_then(|u| u.to_file_path().ok())
480 {
481 roots.push(path);
482 }
483 *self.root_paths.write().unwrap() = roots;
484 }
485
486 {
488 let opts = params.initialization_options.as_ref();
489 let roots = self.root_paths.read().unwrap().clone();
490
491 let file_cfg = crate::autoload::load_project_config_json(&roots);
493
494 if matches!(file_cfg, Some(serde_json::Value::Null)) {
496 self.client
497 .log_message(
498 tower_lsp::lsp_types::MessageType::WARNING,
499 "php-lsp: .php-lsp.json contains invalid JSON — ignoring",
500 )
501 .await;
502 }
503
504 if let Some(serde_json::Value::Object(ref obj)) = file_cfg
506 && let Some(ver) = obj.get("phpVersion").and_then(|v| v.as_str())
507 && !crate::autoload::is_valid_php_version(ver)
508 {
509 self.client
510 .log_message(
511 tower_lsp::lsp_types::MessageType::WARNING,
512 format!(
513 "php-lsp: .php-lsp.json unsupported phpVersion {ver:?} — valid values: {}",
514 crate::autoload::SUPPORTED_PHP_VERSIONS.join(", ")
515 ),
516 )
517 .await;
518 }
519
520 if let Some(ver) = opts
522 .and_then(|o| o.get("phpVersion"))
523 .and_then(|v| v.as_str())
524 && !crate::autoload::is_valid_php_version(ver)
525 {
526 self.client
527 .log_message(
528 tower_lsp::lsp_types::MessageType::WARNING,
529 format!(
530 "php-lsp: unsupported phpVersion {ver:?} — valid values: {}",
531 crate::autoload::SUPPORTED_PHP_VERSIONS.join(", ")
532 ),
533 )
534 .await;
535 }
536
537 let file_obj = file_cfg.as_ref().filter(|v| v.is_object());
540 let merged = LspConfig::merge_project_configs(file_obj, opts);
541 let mut cfg = LspConfig::from_value(&merged);
542
543 let (ver, source) = self.resolve_php_version(cfg.php_version.as_deref());
548 self.client
549 .log_message(
550 tower_lsp::lsp_types::MessageType::INFO,
551 format!("php-lsp: using PHP {ver} ({source})"),
552 )
553 .await;
554 if source != "set by editor" && !crate::autoload::is_valid_php_version(&ver) {
559 self.client
560 .show_message(
561 tower_lsp::lsp_types::MessageType::WARNING,
562 format!(
563 "php-lsp: detected PHP {ver} is outside the supported range ({}); \
564 analysis may be inaccurate",
565 crate::autoload::SUPPORTED_PHP_VERSIONS.join(", ")
566 ),
567 )
568 .await;
569 }
570 cfg.php_version = Some(ver.clone());
571 if let Ok(pv) = ver.parse::<mir_analyzer::PhpVersion>() {
572 self.docs.set_php_version(pv);
573 }
574 *self.config.write().unwrap() = cfg;
575 }
576
577 Ok(InitializeResult {
578 capabilities: ServerCapabilities {
579 text_document_sync: Some(TextDocumentSyncCapability::Options(
580 TextDocumentSyncOptions {
581 open_close: Some(true),
582 change: Some(TextDocumentSyncKind::FULL),
583 will_save: Some(true),
584 will_save_wait_until: Some(true),
585 save: Some(TextDocumentSyncSaveOptions::SaveOptions(SaveOptions {
586 include_text: Some(false),
587 })),
588 },
589 )),
590 completion_provider: Some(CompletionOptions {
591 trigger_characters: Some(vec![
592 "$".to_string(),
593 ">".to_string(),
594 ":".to_string(),
595 "(".to_string(),
596 "[".to_string(),
597 ]),
598 resolve_provider: Some(true),
599 ..Default::default()
600 }),
601 hover_provider: Some(HoverProviderCapability::Simple(true)),
602 definition_provider: Some(OneOf::Left(true)),
603 references_provider: Some(OneOf::Left(true)),
604 document_symbol_provider: Some(OneOf::Left(true)),
605 workspace_symbol_provider: Some(OneOf::Right(WorkspaceSymbolOptions {
606 resolve_provider: Some(true),
607 work_done_progress_options: Default::default(),
608 })),
609 rename_provider: Some(OneOf::Right(RenameOptions {
610 prepare_provider: Some(true),
611 work_done_progress_options: Default::default(),
612 })),
613 signature_help_provider: Some(SignatureHelpOptions {
614 trigger_characters: Some(vec!["(".to_string(), ",".to_string()]),
615 retrigger_characters: None,
616 work_done_progress_options: Default::default(),
617 }),
618 inlay_hint_provider: Some(OneOf::Right(InlayHintServerCapabilities::Options(
619 InlayHintOptions {
620 resolve_provider: Some(true),
621 work_done_progress_options: Default::default(),
622 },
623 ))),
624 folding_range_provider: Some(FoldingRangeProviderCapability::Simple(true)),
625 semantic_tokens_provider: Some(
626 SemanticTokensServerCapabilities::SemanticTokensOptions(
627 SemanticTokensOptions {
628 legend: legend(),
629 full: Some(SemanticTokensFullOptions::Delta { delta: Some(true) }),
630 range: Some(true),
631 ..Default::default()
632 },
633 ),
634 ),
635 selection_range_provider: Some(SelectionRangeProviderCapability::Simple(true)),
636 call_hierarchy_provider: Some(CallHierarchyServerCapability::Simple(true)),
637 document_highlight_provider: Some(OneOf::Left(true)),
638 implementation_provider: Some(ImplementationProviderCapability::Simple(true)),
639 code_action_provider: Some(CodeActionProviderCapability::Options(
640 CodeActionOptions {
641 resolve_provider: Some(true),
642 ..Default::default()
643 },
644 )),
645 declaration_provider: Some(DeclarationCapability::Simple(true)),
646 type_definition_provider: Some(TypeDefinitionProviderCapability::Simple(true)),
647 code_lens_provider: Some(CodeLensOptions {
648 resolve_provider: Some(true),
649 }),
650 document_formatting_provider: Some(OneOf::Left(true)),
651 document_range_formatting_provider: Some(OneOf::Left(true)),
652 document_on_type_formatting_provider: Some(DocumentOnTypeFormattingOptions {
653 first_trigger_character: "}".to_string(),
654 more_trigger_character: Some(vec!["\n".to_string()]),
655 }),
656 document_link_provider: Some(DocumentLinkOptions {
657 resolve_provider: Some(true),
658 work_done_progress_options: Default::default(),
659 }),
660 execute_command_provider: Some(ExecuteCommandOptions {
661 commands: vec!["php-lsp.runTest".to_string()],
662 work_done_progress_options: Default::default(),
663 }),
664 diagnostic_provider: Some(DiagnosticServerCapabilities::Options(
665 DiagnosticOptions {
666 identifier: None,
667 inter_file_dependencies: true,
668 workspace_diagnostics: true,
669 work_done_progress_options: Default::default(),
670 },
671 )),
672 workspace: Some(WorkspaceServerCapabilities {
673 workspace_folders: Some(WorkspaceFoldersServerCapabilities {
674 supported: Some(true),
675 change_notifications: Some(OneOf::Left(true)),
676 }),
677 file_operations: Some(WorkspaceFileOperationsServerCapabilities {
678 will_rename: Some(php_file_op()),
679 did_rename: Some(php_file_op()),
680 did_create: Some(php_file_op()),
681 will_delete: Some(php_file_op()),
682 did_delete: Some(php_file_op()),
683 ..Default::default()
684 }),
685 }),
686 linked_editing_range_provider: Some(LinkedEditingRangeServerCapabilities::Simple(
687 true,
688 )),
689 moniker_provider: Some(OneOf::Left(true)),
690 inline_value_provider: Some(OneOf::Right(InlineValueServerCapabilities::Options(
691 InlineValueOptions {
692 work_done_progress_options: Default::default(),
693 },
694 ))),
695 ..Default::default()
696 },
697 ..Default::default()
698 })
699 }
700
701 async fn initialized(&self, _params: InitializedParams) {
702 let php_selector = serde_json::json!([{"language": "php"}]);
704 let registrations = vec![
705 Registration {
706 id: "php-lsp-file-watcher".to_string(),
707 method: "workspace/didChangeWatchedFiles".to_string(),
708 register_options: Some(serde_json::json!({
709 "watchers": [{"globPattern": "**/*.php"}]
710 })),
711 },
712 Registration {
715 id: "php-lsp-type-hierarchy".to_string(),
716 method: "textDocument/prepareTypeHierarchy".to_string(),
717 register_options: Some(serde_json::json!({"documentSelector": php_selector})),
718 },
719 Registration {
721 id: "php-lsp-config-change".to_string(),
722 method: "workspace/didChangeConfiguration".to_string(),
723 register_options: Some(serde_json::json!({"section": "php-lsp"})),
724 },
725 ];
726 self.client.register_capability(registrations).await.ok();
727
728 let roots = self.root_paths.read().unwrap().clone();
731 if !roots.is_empty() {
732 {
734 let mut merged = Psr4Map::empty();
735 for root in &roots {
736 merged.extend(Psr4Map::load(root));
737 }
738 *self.psr4.write().unwrap() = merged;
739 }
740 *self.meta.write().unwrap() = PhpStormMeta::load(&roots[0]);
742
743 let token = NumberOrString::String("php-lsp/indexing".to_string());
745 self.client
746 .send_request::<WorkDoneProgressCreate>(WorkDoneProgressCreateParams {
747 token: token.clone(),
748 })
749 .await
750 .ok();
751
752 let docs = Arc::clone(&self.docs);
753 let open_files = self.open_files.clone();
754 let client = self.client.clone();
755 let (exclude_paths, max_indexed_files) = {
756 let cfg = self.config.read().unwrap();
757 (cfg.exclude_paths.clone(), cfg.max_indexed_files)
758 };
759 tokio::spawn(async move {
760 client
761 .send_notification::<ProgressNotification>(ProgressParams {
762 token: token.clone(),
763 value: ProgressParamsValue::WorkDone(WorkDoneProgress::Begin(
764 WorkDoneProgressBegin {
765 title: "php-lsp: indexing workspace".to_string(),
766 cancellable: Some(false),
767 message: None,
768 percentage: None,
769 },
770 )),
771 })
772 .await;
773
774 let mut total = 0usize;
775 for root in roots {
776 let cache = crate::cache::WorkspaceCache::new(&root);
782 total += scan_workspace(
783 root,
784 Arc::clone(&docs),
785 open_files.clone(),
786 cache,
787 &exclude_paths,
788 max_indexed_files,
789 )
790 .await;
791 }
792
793 client
794 .send_notification::<ProgressNotification>(ProgressParams {
795 token,
796 value: ProgressParamsValue::WorkDone(WorkDoneProgress::End(
797 WorkDoneProgressEnd {
798 message: Some(format!("Indexed {total} files")),
799 },
800 )),
801 })
802 .await;
803
804 client
805 .log_message(
806 MessageType::INFO,
807 format!("php-lsp: indexed {total} workspace files"),
808 )
809 .await;
810
811 send_refresh_requests(&client).await;
815
816 let warm_docs = Arc::clone(&docs);
830 tokio::task::spawn_blocking(move || {
831 warm_docs.warm_reference_index();
832 });
833 drop(docs);
834 client.send_notification::<IndexReadyNotification>(()).await;
835 });
836 }
837
838 self.client
839 .log_message(MessageType::INFO, "php-lsp ready")
840 .await;
841 }
842
843 async fn did_change_configuration(&self, _params: DidChangeConfigurationParams) {
844 let items = vec![ConfigurationItem {
847 scope_uri: None,
848 section: Some("php-lsp".to_string()),
849 }];
850 if let Ok(values) = self.client.configuration(items).await
851 && let Some(value) = values.into_iter().next()
852 {
853 let roots = self.root_paths.read().unwrap().clone();
854
855 let file_cfg = crate::autoload::load_project_config_json(&roots);
858
859 if let Some(ver) = value.get("phpVersion").and_then(|v| v.as_str())
860 && !crate::autoload::is_valid_php_version(ver)
861 {
862 self.client
863 .log_message(
864 tower_lsp::lsp_types::MessageType::WARNING,
865 format!(
866 "php-lsp: unsupported phpVersion {ver:?} — valid values: {}",
867 crate::autoload::SUPPORTED_PHP_VERSIONS.join(", ")
868 ),
869 )
870 .await;
871 }
872
873 let file_obj = file_cfg.as_ref().filter(|v| v.is_object());
874 let merged = LspConfig::merge_project_configs(file_obj, Some(&value));
875 let mut cfg = LspConfig::from_value(&merged);
876
877 let (ver, source) = self.resolve_php_version(cfg.php_version.as_deref());
879 self.client
880 .log_message(
881 tower_lsp::lsp_types::MessageType::INFO,
882 format!("php-lsp: using PHP {ver} ({source})"),
883 )
884 .await;
885 if source != "set by editor" && !crate::autoload::is_valid_php_version(&ver) {
888 self.client
889 .show_message(
890 tower_lsp::lsp_types::MessageType::WARNING,
891 format!(
892 "php-lsp: detected PHP {ver} is outside the supported range ({}); \
893 analysis may be inaccurate",
894 crate::autoload::SUPPORTED_PHP_VERSIONS.join(", ")
895 ),
896 )
897 .await;
898 }
899 cfg.php_version = Some(ver.clone());
900 if let Ok(pv) = ver.parse::<mir_analyzer::PhpVersion>() {
901 self.docs.set_php_version(pv);
902 }
903 *self.config.write().unwrap() = cfg;
904 send_refresh_requests(&self.client).await;
905 }
906 }
907
908 async fn did_change_workspace_folders(&self, params: DidChangeWorkspaceFoldersParams) {
909 {
911 let mut roots = self.root_paths.write().unwrap();
912 for removed in ¶ms.event.removed {
913 if let Ok(path) = removed.uri.to_file_path() {
914 roots.retain(|r| r != &path);
915 }
916 }
917 }
918
919 let (exclude_paths, max_indexed_files) = {
921 let cfg = self.config.read().unwrap();
922 (cfg.exclude_paths.clone(), cfg.max_indexed_files)
923 };
924 for added in ¶ms.event.added {
925 if let Ok(path) = added.uri.to_file_path() {
926 let is_new = {
927 let mut roots = self.root_paths.write().unwrap();
928 if !roots.contains(&path) {
929 roots.push(path.clone());
930 true
931 } else {
932 false
933 }
934 };
935 if is_new {
936 let docs = Arc::clone(&self.docs);
937 let open_files = self.open_files.clone();
938 let ex = exclude_paths.clone();
939 let path_clone = path.clone();
940 let client = self.client.clone();
941 tokio::spawn(async move {
942 let cache = crate::cache::WorkspaceCache::new(&path_clone);
943 scan_workspace(path_clone, docs, open_files, cache, &ex, max_indexed_files)
944 .await;
945 send_refresh_requests(&client).await;
946 });
947 }
948 }
949 }
950 }
951
952 async fn shutdown(&self) -> Result<()> {
953 Ok(())
954 }
955
956 async fn did_open(&self, params: DidOpenTextDocumentParams) {
957 let uri = params.text_document.uri;
958 let text = params.text_document.text;
959
960 self.set_open_text(uri.clone(), text.clone());
964
965 let docs_for_spawn = Arc::clone(&self.docs);
966 let diag_cfg = self.config.read().unwrap().diagnostics.clone();
967
968 let uri_sem = uri.clone();
973 let (parse_diags, sem_issues) = tokio::task::spawn_blocking(move || {
974 let (_doc, parse_diags) = parse_document(&text);
975 let sem_issues = docs_for_spawn.get_semantic_issues_salsa(&uri_sem);
976 (parse_diags, sem_issues)
977 })
978 .await
979 .unwrap_or_else(|_| (vec![], None));
980
981 self.set_parse_diagnostics(&uri, parse_diags.clone());
982 let stored_source = self.get_open_text(&uri).unwrap_or_default();
983 let doc2 = self.get_doc(&uri);
984 let mut all_diags = parse_diags;
985 if let Some(ref d) = doc2 {
986 let dup_diags = duplicate_declaration_diagnostics(&stored_source, d, &diag_cfg);
987 all_diags.extend(dup_diags);
988 }
989 if let Some(issues) = sem_issues {
990 all_diags.extend(crate::semantic_diagnostics::issues_to_diagnostics(
991 &issues, &uri, &diag_cfg,
992 ));
993 }
994 self.client
996 .publish_diagnostics(uri.clone(), all_diags, None)
997 .await;
998
999 let docs_dep = Arc::clone(&self.docs);
1003 let open_files_dep = self.open_files.clone();
1004 let diag_cfg_dep = diag_cfg.clone();
1005 let opened_uri = uri.clone();
1006 let dependents = tokio::task::spawn_blocking(move || {
1007 let mut out: Vec<(Url, Vec<Diagnostic>)> = Vec::new();
1008 for other in open_files_dep.urls() {
1009 if other == opened_uri {
1010 continue;
1011 }
1012 let diags = compute_open_file_diagnostics(
1013 &docs_dep,
1014 &open_files_dep,
1015 &other,
1016 &diag_cfg_dep,
1017 );
1018 out.push((other, diags));
1019 }
1020 out
1021 })
1022 .await
1023 .unwrap_or_default();
1024 for (dep_uri, dep_diags) in dependents {
1025 self.client
1026 .publish_diagnostics(dep_uri, dep_diags, None)
1027 .await;
1028 }
1029 }
1030
1031 async fn did_change(&self, params: DidChangeTextDocumentParams) {
1032 let uri = params.text_document.uri;
1033 let text = match params.content_changes.into_iter().last() {
1034 Some(c) => c.text,
1035 None => return,
1036 };
1037
1038 let version = self.set_open_text(uri.clone(), text.clone());
1042
1043 let docs = Arc::clone(&self.docs);
1044 let open_files = self.open_files.clone();
1045 let client = self.client.clone();
1046 let diag_cfg = self.config.read().unwrap().diagnostics.clone();
1047 tokio::spawn(async move {
1048 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1051
1052 let (_doc, diagnostics) = tokio::task::spawn_blocking(move || parse_document(&text))
1053 .await
1054 .unwrap_or_else(|_| (ParsedDoc::default(), vec![]));
1055
1056 if open_files.current_version(&uri) == Some(version) {
1059 open_files.set_parse_diagnostics(&uri, diagnostics.clone());
1060
1061 let docs_sem = Arc::clone(&docs);
1067 let open_files_sem = open_files.clone();
1068 let uri_sem = uri.clone();
1069 let diag_cfg_sem = diag_cfg.clone();
1070 let extra = tokio::task::spawn_blocking(move || {
1071 let Some(d) = open_files_sem.get_doc(&docs_sem, &uri_sem) else {
1072 return Vec::<Diagnostic>::new();
1073 };
1074 let source = open_files_sem.text(&uri_sem).unwrap_or_default();
1075 let mut out = Vec::new();
1076 if let Some(issues) = docs_sem.get_semantic_issues_salsa(&uri_sem) {
1077 out.extend(crate::semantic_diagnostics::issues_to_diagnostics(
1078 &issues,
1079 &uri_sem,
1080 &diag_cfg_sem,
1081 ));
1082 }
1083 out.extend(duplicate_declaration_diagnostics(
1084 &source,
1085 &d,
1086 &diag_cfg_sem,
1087 ));
1088 out
1089 })
1090 .await
1091 .unwrap_or_default();
1092
1093 let mut all_diags = diagnostics;
1094 all_diags.extend(extra);
1095 client
1100 .publish_diagnostics(uri.clone(), all_diags, None)
1101 .await;
1102
1103 let docs_dep = Arc::clone(&docs);
1112 let open_files_dep = open_files.clone();
1113 let diag_cfg_dep = diag_cfg.clone();
1114 let changed_uri = uri.clone();
1115 let dependents = tokio::task::spawn_blocking(move || {
1116 let mut out: Vec<(Url, Vec<Diagnostic>)> = Vec::new();
1117 for other in open_files_dep.urls() {
1118 if other == changed_uri {
1119 continue;
1120 }
1121 let diags = compute_open_file_diagnostics(
1122 &docs_dep,
1123 &open_files_dep,
1124 &other,
1125 &diag_cfg_dep,
1126 );
1127 out.push((other, diags));
1128 }
1129 out
1130 })
1131 .await
1132 .unwrap_or_default();
1133 for (dep_uri, dep_diags) in dependents {
1134 client.publish_diagnostics(dep_uri, dep_diags, None).await;
1135 }
1136 }
1137 });
1138 }
1139
1140 async fn did_close(&self, params: DidCloseTextDocumentParams) {
1141 let uri = params.text_document.uri;
1142 self.close_open_file(&uri);
1143 self.client.publish_diagnostics(uri, vec![], None).await;
1145 }
1146
1147 async fn will_save(&self, _params: WillSaveTextDocumentParams) {}
1148
1149 async fn will_save_wait_until(
1150 &self,
1151 params: WillSaveTextDocumentParams,
1152 ) -> Result<Option<Vec<TextEdit>>> {
1153 let source = self
1154 .get_open_text(¶ms.text_document.uri)
1155 .unwrap_or_default();
1156 Ok(format_document(&source))
1157 }
1158
1159 async fn did_save(&self, params: DidSaveTextDocumentParams) {
1160 let uri = params.text_document.uri;
1161 let diag_cfg = self.config.read().unwrap().diagnostics.clone();
1167 let all = compute_open_file_diagnostics(&self.docs, &self.open_files, &uri, &diag_cfg);
1168 self.client.publish_diagnostics(uri, all, None).await;
1169 }
1170
1171 async fn did_change_watched_files(&self, params: DidChangeWatchedFilesParams) {
1172 for change in params.changes {
1173 match change.typ {
1174 FileChangeType::CREATED | FileChangeType::CHANGED => {
1175 if let Ok(path) = change.uri.to_file_path()
1176 && let Ok(text) = tokio::fs::read_to_string(&path).await
1177 {
1178 let (doc, diags) = parse_document(&text);
1183 self.index_from_doc_if_not_open(change.uri.clone(), &doc, diags);
1184 }
1185 }
1186 FileChangeType::DELETED => {
1187 self.docs.remove(&change.uri);
1188 }
1189 _ => {}
1190 }
1191 }
1192 send_refresh_requests(&self.client).await;
1194 }
1195
1196 async fn completion(&self, params: CompletionParams) -> Result<Option<CompletionResponse>> {
1197 let uri = ¶ms.text_document_position.text_document.uri;
1198 let position = params.text_document_position.position;
1199 let source = self.get_open_text(uri).unwrap_or_default();
1200 let doc = match self.get_doc(uri) {
1202 Some(d) => d,
1203 None => return Ok(Some(CompletionResponse::Array(vec![]))),
1204 };
1205 let other_with_returns = self.docs.other_docs_with_returns(uri, &self.open_urls());
1206 let other_docs: Vec<Arc<ParsedDoc>> = other_with_returns
1207 .iter()
1208 .map(|(_, d, _)| d.clone())
1209 .collect();
1210 let other_returns: Vec<Arc<crate::ast::MethodReturnsMap>> = other_with_returns
1211 .iter()
1212 .map(|(_, _, r)| r.clone())
1213 .collect();
1214 let doc_returns = self.docs.get_method_returns_salsa(uri);
1215 let trigger = params
1216 .context
1217 .as_ref()
1218 .and_then(|c| c.trigger_character.as_deref());
1219 let meta_guard = self.meta.read().unwrap();
1220 let meta_opt = if meta_guard.is_empty() {
1221 None
1222 } else {
1223 Some(&*meta_guard)
1224 };
1225 let imports = self.file_imports(uri);
1226 let ctx = CompletionCtx {
1227 source: Some(&source),
1228 position: Some(position),
1229 meta: meta_opt,
1230 doc_uri: Some(uri),
1231 file_imports: Some(&imports),
1232 doc_returns: doc_returns.as_deref(),
1233 other_returns: Some(&other_returns),
1234 };
1235 Ok(Some(CompletionResponse::Array(filtered_completions_at(
1236 &doc,
1237 &other_docs,
1238 trigger,
1239 &ctx,
1240 ))))
1241 }
1242
1243 async fn completion_resolve(&self, mut item: CompletionItem) -> Result<CompletionItem> {
1244 if item.documentation.is_some() && item.detail.is_some() {
1245 return Ok(item);
1246 }
1247 let name = item.label.trim_end_matches(':');
1249 let all_indexes = self.docs.all_indexes();
1250 if item.detail.is_none()
1251 && let Some(sig) = signature_for_symbol_from_index(name, &all_indexes)
1252 {
1253 item.detail = Some(sig);
1254 }
1255 if item.documentation.is_none()
1256 && let Some(md) = docs_for_symbol_from_index(name, &all_indexes)
1257 {
1258 item.documentation = Some(Documentation::MarkupContent(MarkupContent {
1259 kind: MarkupKind::Markdown,
1260 value: md,
1261 }));
1262 }
1263 Ok(item)
1264 }
1265
1266 async fn goto_definition(
1267 &self,
1268 params: GotoDefinitionParams,
1269 ) -> Result<Option<GotoDefinitionResponse>> {
1270 let uri = ¶ms.text_document_position_params.text_document.uri;
1271 let position = params.text_document_position_params.position;
1272 let source = self.get_open_text(uri).unwrap_or_default();
1273 let doc = match self.get_doc(uri) {
1274 Some(d) => d,
1275 None => return Ok(None),
1276 };
1277 let empty_other_docs: Vec<(Url, Arc<ParsedDoc>)> = vec![];
1279 if let Some(loc) = goto_definition(uri, &source, &doc, &empty_other_docs, position) {
1280 return Ok(Some(GotoDefinitionResponse::Scalar(loc)));
1281 }
1282 let other_indexes = self.docs.other_indexes(uri);
1284 if let Some(word) = crate::util::word_at(&source, position)
1285 && let Some(loc) = find_in_indexes(&word, &other_indexes)
1286 {
1287 return Ok(Some(GotoDefinitionResponse::Scalar(loc)));
1288 }
1289
1290 if let Some(word) = word_at(&source, position)
1292 && word.contains('\\')
1293 && let Some(loc) = self.psr4_goto(&word).await
1294 {
1295 return Ok(Some(GotoDefinitionResponse::Scalar(loc)));
1296 }
1297
1298 Ok(None)
1299 }
1300
1301 async fn references(&self, params: ReferenceParams) -> Result<Option<Vec<Location>>> {
1302 let uri = ¶ms.text_document_position.text_document.uri;
1303 let position = params.text_document_position.position;
1304 let source = self.get_open_text(uri).unwrap_or_default();
1305 let word = match word_at(&source, position) {
1306 Some(w) => w,
1307 None => return Ok(None),
1308 };
1309 if word == "__construct"
1316 && let Some(doc) = self.get_doc(uri)
1317 && let Some(class_name) =
1318 class_name_at_construct_decl(doc.source(), &doc.program().stmts, position)
1319 {
1320 let all_docs = self.docs.all_docs_for_scan();
1321 let include_declaration = params.context.include_declaration;
1322 let short_name = class_name
1328 .rsplit('\\')
1329 .next()
1330 .unwrap_or(class_name.as_str())
1331 .to_owned();
1332 let class_fqn = if class_name.contains('\\') {
1333 Some(class_name.as_str())
1334 } else {
1335 None
1336 };
1337 let mut locations = find_constructor_references(&short_name, &all_docs, class_fqn);
1341 if include_declaration {
1342 let end = Position {
1348 line: position.line,
1349 character: position.character + "__construct".len() as u32,
1350 };
1351 locations.push(Location {
1352 uri: uri.clone(),
1353 range: Range {
1354 start: position,
1355 end,
1356 },
1357 });
1358 }
1359 return Ok(if locations.is_empty() {
1360 None
1361 } else {
1362 Some(locations)
1363 });
1364 }
1365
1366 let doc_opt = self.get_doc(uri);
1367 let (word, kind) = if let Some(doc) = &doc_opt
1371 && let Some(prop_name) =
1372 promoted_property_at_cursor(doc.source(), &doc.program().stmts, position)
1373 {
1374 (prop_name, Some(SymbolKind::Property))
1375 } else {
1376 let k = if let Some(doc) = &doc_opt
1377 && cursor_is_on_method_decl(doc.source(), &doc.program().stmts, position)
1378 {
1379 Some(SymbolKind::Method)
1380 } else {
1381 symbol_kind_at(&source, position, &word)
1382 };
1383 (word, k)
1384 };
1385 let all_docs = self.docs.all_docs_for_scan();
1386 let include_declaration = params.context.include_declaration;
1387
1388 let target_fqn: Option<String> = doc_opt.as_ref().and_then(|doc| {
1393 let imports = self.file_imports(uri);
1394 match kind {
1395 Some(SymbolKind::Function) | Some(SymbolKind::Class) => {
1396 let resolved = crate::moniker::resolve_fqn(doc, &word, &imports);
1397 if resolved.contains('\\') {
1398 Some(resolved)
1399 } else {
1400 None
1401 }
1402 }
1403 Some(SymbolKind::Method) => {
1404 let short_owner =
1406 crate::type_map::enclosing_class_at(doc.source(), doc, position)?;
1407 Some(crate::moniker::resolve_fqn(doc, &short_owner, &imports))
1409 }
1410 _ => None,
1411 }
1412 });
1413
1414 let locations = {
1419 let cb = self.codebase();
1420 let docs = Arc::clone(&self.docs);
1421 let lookup = move |key: &str| docs.get_symbol_refs_salsa(key);
1422 find_references_codebase_with_target(
1423 &word,
1424 &all_docs,
1425 include_declaration,
1426 kind,
1427 target_fqn.as_deref(),
1428 &cb,
1429 &lookup,
1430 )
1431 .unwrap_or_else(|| match target_fqn.as_deref() {
1432 Some(t) => {
1433 find_references_with_target(&word, &all_docs, include_declaration, kind, t)
1434 }
1435 None => find_references(&word, &all_docs, include_declaration, kind),
1436 })
1437 };
1438
1439 Ok(if locations.is_empty() {
1440 None
1441 } else {
1442 Some(locations)
1443 })
1444 }
1445
1446 async fn prepare_rename(
1447 &self,
1448 params: TextDocumentPositionParams,
1449 ) -> Result<Option<PrepareRenameResponse>> {
1450 let uri = ¶ms.text_document.uri;
1451 let source = self.get_open_text(uri).unwrap_or_default();
1452 Ok(prepare_rename(&source, params.position).map(PrepareRenameResponse::Range))
1453 }
1454
1455 async fn rename(&self, params: RenameParams) -> Result<Option<WorkspaceEdit>> {
1456 let uri = ¶ms.text_document_position.text_document.uri;
1457 let position = params.text_document_position.position;
1458 let source = self.get_open_text(uri).unwrap_or_default();
1459 let word = match word_at(&source, position) {
1460 Some(w) => w,
1461 None => return Ok(None),
1462 };
1463 if word.starts_with('$') {
1464 let doc = match self.get_doc(uri) {
1465 Some(d) => d,
1466 None => return Ok(None),
1467 };
1468 Ok(Some(rename_variable(
1469 &word,
1470 ¶ms.new_name,
1471 uri,
1472 &doc,
1473 position,
1474 )))
1475 } else if is_after_arrow(&source, position) {
1476 let all_docs = self.docs.all_docs_for_scan();
1477 Ok(Some(rename_property(&word, ¶ms.new_name, &all_docs)))
1478 } else {
1479 let all_docs = self.docs.all_docs_for_scan();
1480 Ok(Some(rename(&word, ¶ms.new_name, &all_docs)))
1481 }
1482 }
1483
1484 async fn signature_help(&self, params: SignatureHelpParams) -> Result<Option<SignatureHelp>> {
1485 let uri = ¶ms.text_document_position_params.text_document.uri;
1486 let position = params.text_document_position_params.position;
1487 let source = self.get_open_text(uri).unwrap_or_default();
1488 let doc = match self.get_doc(uri) {
1489 Some(d) => d,
1490 None => return Ok(None),
1491 };
1492 Ok(signature_help(&source, &doc, position))
1493 }
1494
1495 async fn hover(&self, params: HoverParams) -> Result<Option<Hover>> {
1496 let uri = ¶ms.text_document_position_params.text_document.uri;
1497 let position = params.text_document_position_params.position;
1498 let source = self.get_open_text(uri).unwrap_or_default();
1499 let doc = match self.get_doc(uri) {
1500 Some(d) => d,
1501 None => return Ok(None),
1502 };
1503 let doc_returns = self
1504 .docs
1505 .get_method_returns_salsa(uri)
1506 .unwrap_or_else(|| std::sync::Arc::new(Default::default()));
1507 let other_docs = self.docs.other_docs_with_returns(uri, &self.open_urls());
1508 let result = hover_info(&source, &doc, &doc_returns, position, &other_docs);
1509 if result.is_some() {
1510 return Ok(result);
1511 }
1512 let all_indexes = self.docs.all_indexes();
1516 if let Some(word) = crate::util::word_at(&source, position)
1517 && let Some(h) = class_hover_from_index(&word, &all_indexes)
1518 {
1519 return Ok(Some(h));
1520 }
1521 Ok(None)
1522 }
1523
1524 async fn document_symbol(
1525 &self,
1526 params: DocumentSymbolParams,
1527 ) -> Result<Option<DocumentSymbolResponse>> {
1528 let uri = ¶ms.text_document.uri;
1529 let doc = match self.get_doc(uri) {
1530 Some(d) => d,
1531 None => return Ok(None),
1532 };
1533 Ok(Some(DocumentSymbolResponse::Nested(document_symbols(
1534 doc.source(),
1535 &doc,
1536 ))))
1537 }
1538
1539 async fn folding_range(&self, params: FoldingRangeParams) -> Result<Option<Vec<FoldingRange>>> {
1540 let uri = ¶ms.text_document.uri;
1541 let doc = match self.get_doc(uri) {
1542 Some(d) => d,
1543 None => return Ok(None),
1544 };
1545 let ranges = folding_ranges(doc.source(), &doc);
1546 Ok(if ranges.is_empty() {
1547 None
1548 } else {
1549 Some(ranges)
1550 })
1551 }
1552
1553 async fn inlay_hint(&self, params: InlayHintParams) -> Result<Option<Vec<InlayHint>>> {
1554 let uri = ¶ms.text_document.uri;
1555 let doc = match self.get_doc(uri) {
1556 Some(d) => d,
1557 None => return Ok(None),
1558 };
1559 let doc_returns = self.docs.get_method_returns_salsa(uri);
1560 Ok(Some(inlay_hints(
1561 doc.source(),
1562 &doc,
1563 doc_returns.as_deref(),
1564 params.range,
1565 )))
1566 }
1567
1568 async fn inlay_hint_resolve(&self, mut item: InlayHint) -> Result<InlayHint> {
1569 if item.tooltip.is_some() {
1570 return Ok(item);
1571 }
1572 let func_name = item
1573 .data
1574 .as_ref()
1575 .and_then(|d| d.get("php_lsp_fn"))
1576 .and_then(|v| v.as_str())
1577 .map(str::to_string);
1578 if let Some(name) = func_name {
1579 let all_indexes = self.docs.all_indexes();
1580 if let Some(md) = docs_for_symbol_from_index(&name, &all_indexes) {
1581 item.tooltip = Some(InlayHintTooltip::MarkupContent(MarkupContent {
1582 kind: MarkupKind::Markdown,
1583 value: md,
1584 }));
1585 }
1586 }
1587 Ok(item)
1588 }
1589
1590 async fn symbol(
1591 &self,
1592 params: WorkspaceSymbolParams,
1593 ) -> Result<Option<Vec<SymbolInformation>>> {
1594 let wi = self.docs.get_workspace_index_salsa();
1598 let results = workspace_symbols_from_workspace(¶ms.query, &wi);
1599 Ok(if results.is_empty() {
1600 None
1601 } else {
1602 Some(results)
1603 })
1604 }
1605
1606 async fn symbol_resolve(&self, params: WorkspaceSymbol) -> Result<WorkspaceSymbol> {
1607 let docs = self.docs.docs_for(&self.open_urls());
1609 Ok(resolve_workspace_symbol(params, &docs))
1610 }
1611
1612 async fn semantic_tokens_full(
1613 &self,
1614 params: SemanticTokensParams,
1615 ) -> Result<Option<SemanticTokensResult>> {
1616 let uri = ¶ms.text_document.uri;
1617 let doc = match self.get_doc(uri) {
1618 Some(d) => d,
1619 None => {
1620 return Ok(Some(SemanticTokensResult::Tokens(SemanticTokens {
1621 result_id: None,
1622 data: vec![],
1623 })));
1624 }
1625 };
1626 let tokens = semantic_tokens(doc.source(), &doc);
1627 let result_id = token_hash(&tokens);
1628 self.docs
1629 .store_token_cache(uri, result_id.clone(), tokens.clone());
1630 Ok(Some(SemanticTokensResult::Tokens(SemanticTokens {
1631 result_id: Some(result_id),
1632 data: tokens,
1633 })))
1634 }
1635
1636 async fn semantic_tokens_range(
1637 &self,
1638 params: SemanticTokensRangeParams,
1639 ) -> Result<Option<SemanticTokensRangeResult>> {
1640 let uri = ¶ms.text_document.uri;
1641 let doc = match self.get_doc(uri) {
1642 Some(d) => d,
1643 None => {
1644 return Ok(Some(SemanticTokensRangeResult::Tokens(SemanticTokens {
1645 result_id: None,
1646 data: vec![],
1647 })));
1648 }
1649 };
1650 let tokens = semantic_tokens_range(doc.source(), &doc, params.range);
1651 Ok(Some(SemanticTokensRangeResult::Tokens(SemanticTokens {
1652 result_id: None,
1653 data: tokens,
1654 })))
1655 }
1656
1657 async fn semantic_tokens_full_delta(
1658 &self,
1659 params: SemanticTokensDeltaParams,
1660 ) -> Result<Option<SemanticTokensFullDeltaResult>> {
1661 let uri = ¶ms.text_document.uri;
1662 let doc = match self.get_doc(uri) {
1663 Some(d) => d,
1664 None => return Ok(None),
1665 };
1666
1667 let new_tokens = semantic_tokens(doc.source(), &doc);
1668 let new_result_id = token_hash(&new_tokens);
1669 let prev_id = ¶ms.previous_result_id;
1670
1671 let result = match self.docs.get_token_cache(uri, prev_id) {
1672 Some(old_tokens) => {
1673 let edits = compute_token_delta(&old_tokens, &new_tokens);
1674 SemanticTokensFullDeltaResult::TokensDelta(SemanticTokensDelta {
1675 result_id: Some(new_result_id.clone()),
1676 edits,
1677 })
1678 }
1679 None => SemanticTokensFullDeltaResult::Tokens(SemanticTokens {
1681 result_id: Some(new_result_id.clone()),
1682 data: new_tokens.clone(),
1683 }),
1684 };
1685
1686 self.docs.store_token_cache(uri, new_result_id, new_tokens);
1687 Ok(Some(result))
1688 }
1689
1690 async fn selection_range(
1691 &self,
1692 params: SelectionRangeParams,
1693 ) -> Result<Option<Vec<SelectionRange>>> {
1694 let uri = ¶ms.text_document.uri;
1695 let doc = match self.get_doc(uri) {
1696 Some(d) => d,
1697 None => return Ok(None),
1698 };
1699 let ranges = selection_ranges(doc.source(), &doc, ¶ms.positions);
1700 Ok(if ranges.is_empty() {
1701 None
1702 } else {
1703 Some(ranges)
1704 })
1705 }
1706
1707 async fn prepare_call_hierarchy(
1708 &self,
1709 params: CallHierarchyPrepareParams,
1710 ) -> Result<Option<Vec<CallHierarchyItem>>> {
1711 let uri = ¶ms.text_document_position_params.text_document.uri;
1712 let position = params.text_document_position_params.position;
1713 let source = self.get_open_text(uri).unwrap_or_default();
1714 let word = match word_at(&source, position) {
1715 Some(w) => w,
1716 None => return Ok(None),
1717 };
1718 let all_docs = self.docs.all_docs_for_scan();
1719 Ok(prepare_call_hierarchy(&word, &all_docs).map(|item| vec![item]))
1720 }
1721
1722 async fn incoming_calls(
1723 &self,
1724 params: CallHierarchyIncomingCallsParams,
1725 ) -> Result<Option<Vec<CallHierarchyIncomingCall>>> {
1726 let all_docs = self.docs.all_docs_for_scan();
1727 let calls = incoming_calls(¶ms.item, &all_docs);
1728 Ok(if calls.is_empty() { None } else { Some(calls) })
1729 }
1730
1731 async fn outgoing_calls(
1732 &self,
1733 params: CallHierarchyOutgoingCallsParams,
1734 ) -> Result<Option<Vec<CallHierarchyOutgoingCall>>> {
1735 let all_docs = self.docs.all_docs_for_scan();
1736 let calls = outgoing_calls(¶ms.item, &all_docs);
1737 Ok(if calls.is_empty() { None } else { Some(calls) })
1738 }
1739
1740 async fn document_highlight(
1741 &self,
1742 params: DocumentHighlightParams,
1743 ) -> Result<Option<Vec<DocumentHighlight>>> {
1744 let uri = ¶ms.text_document_position_params.text_document.uri;
1745 let position = params.text_document_position_params.position;
1746 let source = self.get_open_text(uri).unwrap_or_default();
1747 let doc = match self.get_doc(uri) {
1748 Some(d) => d,
1749 None => return Ok(None),
1750 };
1751 let highlights = document_highlights(&source, &doc, position);
1752 Ok(if highlights.is_empty() {
1753 None
1754 } else {
1755 Some(highlights)
1756 })
1757 }
1758
1759 async fn linked_editing_range(
1760 &self,
1761 params: LinkedEditingRangeParams,
1762 ) -> Result<Option<LinkedEditingRanges>> {
1763 let uri = ¶ms.text_document_position_params.text_document.uri;
1764 let position = params.text_document_position_params.position;
1765 let source = self.get_open_text(uri).unwrap_or_default();
1766 let doc = match self.get_doc(uri) {
1767 Some(d) => d,
1768 None => return Ok(None),
1769 };
1770 let highlights = document_highlights(&source, &doc, position);
1772 if highlights.is_empty() {
1773 return Ok(None);
1774 }
1775 let ranges: Vec<Range> = highlights.into_iter().map(|h| h.range).collect();
1776 Ok(Some(LinkedEditingRanges {
1777 ranges,
1778 word_pattern: Some(r"[$a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*".to_string()),
1780 }))
1781 }
1782
1783 async fn goto_implementation(
1784 &self,
1785 params: tower_lsp::lsp_types::request::GotoImplementationParams,
1786 ) -> Result<Option<tower_lsp::lsp_types::request::GotoImplementationResponse>> {
1787 let uri = ¶ms.text_document_position_params.text_document.uri;
1788 let position = params.text_document_position_params.position;
1789 let source = self.get_open_text(uri).unwrap_or_default();
1790 let imports = self.file_imports(uri);
1791 let word = crate::util::word_at(&source, position).unwrap_or_default();
1792 let fqn = imports.get(&word).map(|s| s.as_str());
1793 let open_docs = self.docs.docs_for(&self.open_urls());
1795 let mut locs = find_implementations(&word, fqn, &open_docs);
1796 if locs.is_empty() {
1797 let wi = self.docs.get_workspace_index_salsa();
1800 locs = find_implementations_from_workspace(&word, fqn, &wi);
1801 }
1802 if locs.is_empty() {
1803 Ok(None)
1804 } else {
1805 Ok(Some(GotoDefinitionResponse::Array(locs)))
1806 }
1807 }
1808
1809 async fn goto_declaration(
1810 &self,
1811 params: tower_lsp::lsp_types::request::GotoDeclarationParams,
1812 ) -> Result<Option<tower_lsp::lsp_types::request::GotoDeclarationResponse>> {
1813 let uri = ¶ms.text_document_position_params.text_document.uri;
1814 let position = params.text_document_position_params.position;
1815 let source = self.get_open_text(uri).unwrap_or_default();
1816 let open_docs = self.docs.docs_for(&self.open_urls());
1818 if let Some(loc) = goto_declaration(&source, &open_docs, position) {
1819 return Ok(Some(GotoDefinitionResponse::Scalar(loc)));
1820 }
1821 let all_indexes = self.docs.all_indexes();
1823 Ok(goto_declaration_from_index(&source, &all_indexes, position)
1824 .map(GotoDefinitionResponse::Scalar))
1825 }
1826
1827 async fn goto_type_definition(
1828 &self,
1829 params: tower_lsp::lsp_types::request::GotoTypeDefinitionParams,
1830 ) -> Result<Option<tower_lsp::lsp_types::request::GotoTypeDefinitionResponse>> {
1831 let uri = ¶ms.text_document_position_params.text_document.uri;
1832 let position = params.text_document_position_params.position;
1833 let source = self.get_open_text(uri).unwrap_or_default();
1834 let doc = match self.get_doc(uri) {
1835 Some(d) => d,
1836 None => return Ok(None),
1837 };
1838 let doc_returns = self.docs.get_method_returns_salsa(uri);
1839 let open_docs = self.docs.docs_for(&self.open_urls());
1841 if let Some(loc) =
1842 goto_type_definition(&source, &doc, doc_returns.as_deref(), &open_docs, position)
1843 {
1844 return Ok(Some(GotoDefinitionResponse::Scalar(loc)));
1845 }
1846 let all_indexes = self.docs.all_indexes();
1848 Ok(goto_type_definition_from_index(
1849 &source,
1850 &doc,
1851 doc_returns.as_deref(),
1852 &all_indexes,
1853 position,
1854 )
1855 .map(GotoDefinitionResponse::Scalar))
1856 }
1857
1858 async fn prepare_type_hierarchy(
1859 &self,
1860 params: TypeHierarchyPrepareParams,
1861 ) -> Result<Option<Vec<TypeHierarchyItem>>> {
1862 let uri = ¶ms.text_document_position_params.text_document.uri;
1863 let position = params.text_document_position_params.position;
1864 let source = self.get_open_text(uri).unwrap_or_default();
1865 let wi = self.docs.get_workspace_index_salsa();
1867 Ok(prepare_type_hierarchy_from_workspace(&source, &wi, position).map(|item| vec![item]))
1868 }
1869
1870 async fn supertypes(
1871 &self,
1872 params: TypeHierarchySupertypesParams,
1873 ) -> Result<Option<Vec<TypeHierarchyItem>>> {
1874 let wi = self.docs.get_workspace_index_salsa();
1876 let result = supertypes_of_from_workspace(¶ms.item, &wi);
1877 Ok(if result.is_empty() {
1878 None
1879 } else {
1880 Some(result)
1881 })
1882 }
1883
1884 async fn subtypes(
1885 &self,
1886 params: TypeHierarchySubtypesParams,
1887 ) -> Result<Option<Vec<TypeHierarchyItem>>> {
1888 let wi = self.docs.get_workspace_index_salsa();
1890 let result = subtypes_of_from_workspace(¶ms.item, &wi);
1891 Ok(if result.is_empty() {
1892 None
1893 } else {
1894 Some(result)
1895 })
1896 }
1897
1898 async fn code_lens(&self, params: CodeLensParams) -> Result<Option<Vec<CodeLens>>> {
1899 let uri = ¶ms.text_document.uri;
1900 let doc = match self.get_doc(uri) {
1901 Some(d) => d,
1902 None => return Ok(None),
1903 };
1904 let all_docs = self.docs.all_docs_for_scan();
1905 let lenses = code_lenses(uri, &doc, &all_docs);
1906 Ok(if lenses.is_empty() {
1907 None
1908 } else {
1909 Some(lenses)
1910 })
1911 }
1912
1913 async fn code_lens_resolve(&self, params: CodeLens) -> Result<CodeLens> {
1914 Ok(params)
1916 }
1917
1918 async fn document_link(&self, params: DocumentLinkParams) -> Result<Option<Vec<DocumentLink>>> {
1919 let uri = ¶ms.text_document.uri;
1920 let doc = match self.get_doc(uri) {
1921 Some(d) => d,
1922 None => return Ok(None),
1923 };
1924 let links = document_links(uri, &doc, doc.source());
1925 Ok(if links.is_empty() { None } else { Some(links) })
1926 }
1927
1928 async fn document_link_resolve(&self, params: DocumentLink) -> Result<DocumentLink> {
1929 Ok(params)
1931 }
1932
1933 async fn formatting(&self, params: DocumentFormattingParams) -> Result<Option<Vec<TextEdit>>> {
1934 let uri = ¶ms.text_document.uri;
1935 let source = self.get_open_text(uri).unwrap_or_default();
1936 Ok(format_document(&source))
1937 }
1938
1939 async fn range_formatting(
1940 &self,
1941 params: DocumentRangeFormattingParams,
1942 ) -> Result<Option<Vec<TextEdit>>> {
1943 let uri = ¶ms.text_document.uri;
1944 let source = self.get_open_text(uri).unwrap_or_default();
1945 Ok(format_range(&source, params.range))
1946 }
1947
1948 async fn on_type_formatting(
1949 &self,
1950 params: DocumentOnTypeFormattingParams,
1951 ) -> Result<Option<Vec<TextEdit>>> {
1952 let uri = ¶ms.text_document_position.text_document.uri;
1953 let source = self.get_open_text(uri).unwrap_or_default();
1954 let edits = on_type_format(
1955 &source,
1956 params.text_document_position.position,
1957 ¶ms.ch,
1958 ¶ms.options,
1959 );
1960 Ok(if edits.is_empty() { None } else { Some(edits) })
1961 }
1962
1963 async fn execute_command(
1964 &self,
1965 params: ExecuteCommandParams,
1966 ) -> Result<Option<serde_json::Value>> {
1967 match params.command.as_str() {
1968 "php-lsp.runTest" => {
1969 let file_uri = params
1971 .arguments
1972 .first()
1973 .and_then(|v| v.as_str())
1974 .and_then(|s| Url::parse(s).ok());
1975 let filter = params
1976 .arguments
1977 .get(1)
1978 .and_then(|v| v.as_str())
1979 .unwrap_or("")
1980 .to_string();
1981
1982 let root = self.root_paths.read().unwrap().first().cloned();
1983 let client = self.client.clone();
1984
1985 tokio::spawn(async move {
1986 run_phpunit(&client, &filter, root.as_deref(), file_uri.as_ref()).await;
1987 });
1988
1989 Ok(None)
1990 }
1991 _ => Ok(None),
1992 }
1993 }
1994
1995 async fn will_rename_files(&self, params: RenameFilesParams) -> Result<Option<WorkspaceEdit>> {
1996 let psr4 = self.psr4.read().unwrap();
1997 let all_docs = self.docs.all_docs_for_scan();
1998 let mut merged_changes: std::collections::HashMap<
1999 tower_lsp::lsp_types::Url,
2000 Vec<tower_lsp::lsp_types::TextEdit>,
2001 > = std::collections::HashMap::new();
2002
2003 for file_rename in ¶ms.files {
2004 let old_path = tower_lsp::lsp_types::Url::parse(&file_rename.old_uri)
2005 .ok()
2006 .and_then(|u| u.to_file_path().ok());
2007 let new_path = tower_lsp::lsp_types::Url::parse(&file_rename.new_uri)
2008 .ok()
2009 .and_then(|u| u.to_file_path().ok());
2010
2011 let (Some(old_path), Some(new_path)) = (old_path, new_path) else {
2012 continue;
2013 };
2014
2015 let old_fqn = psr4.file_to_fqn(&old_path);
2016 let new_fqn = psr4.file_to_fqn(&new_path);
2017
2018 let (Some(old_fqn), Some(new_fqn)) = (old_fqn, new_fqn) else {
2019 continue;
2020 };
2021
2022 let edit = use_edits_for_rename(&old_fqn, &new_fqn, &all_docs);
2023 if let Some(changes) = edit.changes {
2024 for (uri, edits) in changes {
2025 merged_changes.entry(uri).or_default().extend(edits);
2026 }
2027 }
2028 }
2029
2030 Ok(if merged_changes.is_empty() {
2031 None
2032 } else {
2033 Some(WorkspaceEdit {
2034 changes: Some(merged_changes),
2035 ..Default::default()
2036 })
2037 })
2038 }
2039
2040 async fn did_rename_files(&self, params: RenameFilesParams) {
2041 for file_rename in ¶ms.files {
2042 if let Ok(old_uri) = tower_lsp::lsp_types::Url::parse(&file_rename.old_uri) {
2044 self.docs.remove(&old_uri);
2045 }
2046 if let Ok(new_uri) = tower_lsp::lsp_types::Url::parse(&file_rename.new_uri)
2048 && let Ok(path) = new_uri.to_file_path()
2049 && let Ok(text) = tokio::fs::read_to_string(&path).await
2050 {
2051 self.index_if_not_open(new_uri, &text);
2052 }
2053 }
2054 }
2055
2056 async fn will_create_files(&self, params: CreateFilesParams) -> Result<Option<WorkspaceEdit>> {
2059 let psr4 = self.psr4.read().unwrap();
2060 let mut changes: std::collections::HashMap<Url, Vec<TextEdit>> =
2061 std::collections::HashMap::new();
2062
2063 for file in ¶ms.files {
2064 let Ok(uri) = Url::parse(&file.uri) else {
2065 continue;
2066 };
2067 if !uri.path().ends_with(".php") {
2070 continue;
2071 }
2072
2073 let stub = if let Ok(path) = uri.to_file_path()
2074 && let Some(fqn) = psr4.file_to_fqn(&path)
2075 {
2076 let (ns, class_name) = match fqn.rfind('\\') {
2077 Some(pos) => (&fqn[..pos], &fqn[pos + 1..]),
2078 None => ("", fqn.as_str()),
2079 };
2080 if ns.is_empty() {
2081 format!("<?php\n\ndeclare(strict_types=1);\n\nclass {class_name}\n{{\n}}\n")
2082 } else {
2083 format!(
2084 "<?php\n\ndeclare(strict_types=1);\n\nnamespace {ns};\n\nclass {class_name}\n{{\n}}\n"
2085 )
2086 }
2087 } else {
2088 "<?php\n\n".to_string()
2089 };
2090
2091 changes.insert(
2092 uri,
2093 vec![TextEdit {
2094 range: Range {
2095 start: Position {
2096 line: 0,
2097 character: 0,
2098 },
2099 end: Position {
2100 line: 0,
2101 character: 0,
2102 },
2103 },
2104 new_text: stub,
2105 }],
2106 );
2107 }
2108
2109 Ok(if changes.is_empty() {
2110 None
2111 } else {
2112 Some(WorkspaceEdit {
2113 changes: Some(changes),
2114 ..Default::default()
2115 })
2116 })
2117 }
2118
2119 async fn did_create_files(&self, params: CreateFilesParams) {
2120 for file in ¶ms.files {
2121 if let Ok(uri) = Url::parse(&file.uri)
2122 && let Ok(path) = uri.to_file_path()
2123 && let Ok(text) = tokio::fs::read_to_string(&path).await
2124 {
2125 self.index_if_not_open(uri, &text);
2126 }
2127 }
2128 send_refresh_requests(&self.client).await;
2129 }
2130
2131 async fn will_delete_files(&self, params: DeleteFilesParams) -> Result<Option<WorkspaceEdit>> {
2136 let psr4 = self.psr4.read().unwrap();
2137 let all_docs = self.docs.all_docs_for_scan();
2138 let mut merged_changes: std::collections::HashMap<Url, Vec<TextEdit>> =
2139 std::collections::HashMap::new();
2140
2141 for file in ¶ms.files {
2142 let path = Url::parse(&file.uri)
2143 .ok()
2144 .and_then(|u| u.to_file_path().ok());
2145 let Some(path) = path else { continue };
2146 let Some(fqn) = psr4.file_to_fqn(&path) else {
2147 continue;
2148 };
2149
2150 let edit = use_edits_for_delete(&fqn, &all_docs);
2151 if let Some(changes) = edit.changes {
2152 for (uri, edits) in changes {
2153 merged_changes.entry(uri).or_default().extend(edits);
2154 }
2155 }
2156 }
2157
2158 Ok(if merged_changes.is_empty() {
2159 None
2160 } else {
2161 Some(WorkspaceEdit {
2162 changes: Some(merged_changes),
2163 ..Default::default()
2164 })
2165 })
2166 }
2167
2168 async fn did_delete_files(&self, params: DeleteFilesParams) {
2169 for file in ¶ms.files {
2170 if let Ok(uri) = Url::parse(&file.uri) {
2171 self.docs.remove(&uri);
2172 self.client.publish_diagnostics(uri, vec![], None).await;
2174 }
2175 }
2176 send_refresh_requests(&self.client).await;
2177 }
2178
2179 async fn moniker(&self, params: MonikerParams) -> Result<Option<Vec<Moniker>>> {
2182 let uri = ¶ms.text_document_position_params.text_document.uri;
2183 let position = params.text_document_position_params.position;
2184 let source = self.get_open_text(uri).unwrap_or_default();
2185 let doc = match self.get_doc(uri) {
2186 Some(d) => d,
2187 None => return Ok(None),
2188 };
2189 let imports = self.file_imports(uri);
2190 Ok(moniker_at(&source, &doc, position, &imports).map(|m| vec![m]))
2191 }
2192
2193 async fn inline_value(&self, params: InlineValueParams) -> Result<Option<Vec<InlineValue>>> {
2196 let uri = ¶ms.text_document.uri;
2197 let source = self.get_open_text(uri).unwrap_or_default();
2198 let values = inline_values_in_range(&source, params.range);
2199 Ok(if values.is_empty() {
2200 None
2201 } else {
2202 Some(values)
2203 })
2204 }
2205
2206 async fn diagnostic(
2207 &self,
2208 params: DocumentDiagnosticParams,
2209 ) -> Result<DocumentDiagnosticReportResult> {
2210 let uri = ¶ms.text_document.uri;
2211 let source = self.get_open_text(uri).unwrap_or_default();
2212
2213 let parse_diags = self.get_parse_diagnostics(uri).unwrap_or_default();
2214 let doc = match self.get_doc(uri) {
2215 Some(d) => d,
2216 None => {
2217 return Ok(DocumentDiagnosticReportResult::Report(
2218 DocumentDiagnosticReport::Full(RelatedFullDocumentDiagnosticReport {
2219 related_documents: None,
2220 full_document_diagnostic_report: FullDocumentDiagnosticReport {
2221 result_id: None,
2222 items: parse_diags,
2223 },
2224 }),
2225 ));
2226 }
2227 };
2228 let (diag_cfg, php_version) = {
2229 let cfg = self.config.read().unwrap();
2230 (cfg.diagnostics.clone(), cfg.php_version.clone())
2231 };
2232 let _ = php_version.as_deref();
2233 let docs = Arc::clone(&self.docs);
2235 let uri_owned = uri.clone();
2236 let diag_cfg_sem = diag_cfg.clone();
2237 let sem_diags = tokio::task::spawn_blocking(move || {
2238 docs.get_semantic_issues_salsa(&uri_owned)
2239 .map(|issues| {
2240 crate::semantic_diagnostics::issues_to_diagnostics(
2241 &issues,
2242 &uri_owned,
2243 &diag_cfg_sem,
2244 )
2245 })
2246 .unwrap_or_default()
2247 })
2248 .await
2249 .unwrap_or_default();
2250 let dup_diags = duplicate_declaration_diagnostics(&source, &doc, &diag_cfg);
2251
2252 let mut items = parse_diags;
2253 items.extend(sem_diags);
2254 items.extend(dup_diags);
2255
2256 Ok(DocumentDiagnosticReportResult::Report(
2257 DocumentDiagnosticReport::Full(RelatedFullDocumentDiagnosticReport {
2258 related_documents: None,
2259 full_document_diagnostic_report: FullDocumentDiagnosticReport {
2260 result_id: None,
2261 items,
2262 },
2263 }),
2264 ))
2265 }
2266
2267 async fn workspace_diagnostic(
2268 &self,
2269 _params: WorkspaceDiagnosticParams,
2270 ) -> Result<WorkspaceDiagnosticReportResult> {
2271 let all_parse_diags = self.all_open_files_with_diagnostics();
2272 let (diag_cfg, php_version) = {
2273 let cfg = self.config.read().unwrap();
2274 (cfg.diagnostics.clone(), cfg.php_version.clone())
2275 };
2276
2277 let _ = php_version.as_deref();
2285 let docs = Arc::clone(&self.docs);
2286 let diag_cfg_sweep = diag_cfg.clone();
2287 let items = tokio::task::spawn_blocking(move || {
2288 all_parse_diags
2289 .into_iter()
2290 .filter_map(|(uri, parse_diags, version)| {
2291 let doc = docs.get_doc_salsa(&uri)?;
2292
2293 let source = doc.source().to_string();
2294 let sem_diags = docs
2295 .get_semantic_issues_salsa(&uri)
2296 .map(|issues| {
2297 crate::semantic_diagnostics::issues_to_diagnostics(
2298 &issues,
2299 &uri,
2300 &diag_cfg_sweep,
2301 )
2302 })
2303 .unwrap_or_default();
2304 let dup_diags =
2305 duplicate_declaration_diagnostics(&source, &doc, &diag_cfg_sweep);
2306
2307 let mut all_diags = parse_diags;
2308 all_diags.extend(sem_diags);
2309 all_diags.extend(dup_diags);
2310
2311 Some(WorkspaceDocumentDiagnosticReport::Full(
2312 WorkspaceFullDocumentDiagnosticReport {
2313 uri,
2314 version,
2315 full_document_diagnostic_report: FullDocumentDiagnosticReport {
2316 result_id: None,
2317 items: all_diags,
2318 },
2319 },
2320 ))
2321 })
2322 .collect::<Vec<_>>()
2323 })
2324 .await
2325 .unwrap_or_default();
2326
2327 Ok(WorkspaceDiagnosticReportResult::Report(
2328 WorkspaceDiagnosticReport { items },
2329 ))
2330 }
2331
2332 async fn code_action(&self, params: CodeActionParams) -> Result<Option<CodeActionResponse>> {
2333 let uri = ¶ms.text_document.uri;
2334 let source = self.get_open_text(uri).unwrap_or_default();
2335 let doc = match self.get_doc(uri) {
2336 Some(d) => d,
2337 None => return Ok(None),
2338 };
2339 let other_docs = self.docs.other_docs(uri, &self.open_urls());
2340
2341 let diag_cfg = self.config.read().unwrap().diagnostics.clone();
2348 let docs_sem = Arc::clone(&self.docs);
2349 let uri_sem = uri.clone();
2350 let diag_cfg_sem = diag_cfg.clone();
2351 let sem_diags = tokio::task::spawn_blocking(move || {
2352 docs_sem
2353 .get_semantic_issues_salsa(&uri_sem)
2354 .map(|issues| {
2355 crate::semantic_diagnostics::issues_to_diagnostics(
2356 &issues,
2357 &uri_sem,
2358 &diag_cfg_sem,
2359 )
2360 })
2361 .unwrap_or_default()
2362 })
2363 .await
2364 .unwrap_or_default();
2365
2366 let mut actions: Vec<CodeActionOrCommand> = Vec::new();
2368 for diag in &sem_diags {
2369 if diag.code != Some(NumberOrString::String("UndefinedClass".to_string())) {
2370 continue;
2371 }
2372 if diag.range.start.line < params.range.start.line
2374 || diag.range.start.line > params.range.end.line
2375 {
2376 continue;
2377 }
2378 let class_name = diag
2380 .message
2381 .strip_prefix("Class ")
2382 .and_then(|s| s.strip_suffix(" does not exist"))
2383 .unwrap_or("")
2384 .trim();
2385 if class_name.is_empty() {
2386 continue;
2387 }
2388
2389 for (_other_uri, other_doc) in &other_docs {
2391 if let Some(fqn) = find_fqn_for_class(other_doc, class_name) {
2392 let edit = build_use_import_edit(&source, uri, &fqn);
2393 let action = CodeAction {
2394 title: format!("Add use {fqn}"),
2395 kind: Some(CodeActionKind::QUICKFIX),
2396 edit: Some(edit),
2397 diagnostics: Some(vec![diag.clone()]),
2398 ..Default::default()
2399 };
2400 actions.push(CodeActionOrCommand::CodeAction(action));
2401 break; }
2403 }
2404 }
2405
2406 for tag in DEFERRED_ACTION_TAGS {
2409 actions.extend(defer_actions(
2410 self.generate_deferred_actions(tag, &source, &doc, params.range, uri),
2411 tag,
2412 uri,
2413 params.range,
2414 ));
2415 }
2416
2417 actions.extend(extract_variable_actions(&source, params.range, uri));
2419 actions.extend(extract_method_actions(&source, &doc, params.range, uri));
2420 actions.extend(extract_constant_actions(&source, params.range, uri));
2421 actions.extend(inline_variable_actions(&source, params.range, uri));
2423 if let Some(action) = organize_imports_action(&source, uri) {
2425 actions.push(action);
2426 }
2427
2428 Ok(if actions.is_empty() {
2429 None
2430 } else {
2431 Some(actions)
2432 })
2433 }
2434
2435 async fn code_action_resolve(&self, item: CodeAction) -> Result<CodeAction> {
2436 let data = match &item.data {
2437 Some(d) => d.clone(),
2438 None => return Ok(item),
2439 };
2440 let kind_tag = match data.get("php_lsp_resolve").and_then(|v| v.as_str()) {
2441 Some(k) => k.to_string(),
2442 None => return Ok(item),
2443 };
2444 let uri: Url = match data
2445 .get("uri")
2446 .and_then(|v| v.as_str())
2447 .and_then(|s| Url::parse(s).ok())
2448 {
2449 Some(u) => u,
2450 None => return Ok(item),
2451 };
2452 let range: Range = match data
2453 .get("range")
2454 .and_then(|v| serde_json::from_value(v.clone()).ok())
2455 {
2456 Some(r) => r,
2457 None => return Ok(item),
2458 };
2459
2460 let source = self.get_open_text(&uri).unwrap_or_default();
2461 let doc = match self.get_doc(&uri) {
2462 Some(d) => d,
2463 None => return Ok(item),
2464 };
2465
2466 let candidates = self.generate_deferred_actions(&kind_tag, &source, &doc, range, &uri);
2467
2468 for candidate in candidates {
2470 if let CodeActionOrCommand::CodeAction(ca) = candidate
2471 && ca.title == item.title
2472 {
2473 return Ok(ca);
2474 }
2475 }
2476
2477 Ok(item)
2478 }
2479}
2480
2481fn php_file_op() -> FileOperationRegistrationOptions {
2483 FileOperationRegistrationOptions {
2484 filters: vec![FileOperationFilter {
2485 scheme: Some("file".to_string()),
2486 pattern: FileOperationPattern {
2487 glob: "**/*.php".to_string(),
2488 matches: Some(FileOperationPatternKind::File),
2489 options: None,
2490 },
2491 }],
2492 }
2493}
2494
2495fn defer_actions(
2498 actions: Vec<CodeActionOrCommand>,
2499 kind_tag: &str,
2500 uri: &Url,
2501 range: Range,
2502) -> Vec<CodeActionOrCommand> {
2503 actions
2504 .into_iter()
2505 .map(|a| match a {
2506 CodeActionOrCommand::CodeAction(mut ca) => {
2507 ca.edit = None;
2508 ca.data = Some(serde_json::json!({
2509 "php_lsp_resolve": kind_tag,
2510 "uri": uri.to_string(),
2511 "range": range,
2512 }));
2513 CodeActionOrCommand::CodeAction(ca)
2514 }
2515 other => other,
2516 })
2517 .collect()
2518}
2519
2520fn is_after_arrow(source: &str, position: Position) -> bool {
2523 let line = match source.lines().nth(position.line as usize) {
2524 Some(l) => l,
2525 None => return false,
2526 };
2527 let chars: Vec<char> = line.chars().collect();
2528 let col = position.character as usize;
2529 let mut utf16_col = 0usize;
2531 let mut char_idx = 0usize;
2532 for ch in &chars {
2533 if utf16_col >= col {
2534 break;
2535 }
2536 utf16_col += ch.len_utf16();
2537 char_idx += 1;
2538 }
2539 let is_word = |c: char| c.is_alphanumeric() || c == '_';
2541 while char_idx > 0 && is_word(chars[char_idx - 1]) {
2542 char_idx -= 1;
2543 }
2544 char_idx >= 2 && chars[char_idx - 1] == '>' && chars[char_idx - 2] == '-'
2545}
2546
2547fn symbol_kind_at(source: &str, position: Position, word: &str) -> Option<SymbolKind> {
2558 if word.starts_with('$') {
2559 return None; }
2561 let line = source.lines().nth(position.line as usize)?;
2562 let chars: Vec<char> = line.chars().collect();
2563
2564 let col = position.character as usize;
2566 let mut utf16_col = 0usize;
2567 let mut char_idx = 0usize;
2568 for ch in &chars {
2569 if utf16_col >= col {
2570 break;
2571 }
2572 utf16_col += ch.len_utf16();
2573 char_idx += 1;
2574 }
2575
2576 let is_word_char = |c: char| c.is_alphanumeric() || c == '_';
2578 while char_idx > 0 && is_word_char(chars[char_idx - 1]) {
2579 char_idx -= 1;
2580 }
2581
2582 if char_idx >= 2 && chars[char_idx - 1] == '>' && chars[char_idx - 2] == '-' {
2584 return Some(SymbolKind::Method);
2585 }
2586 if char_idx >= 3
2587 && chars[char_idx - 1] == '>'
2588 && chars[char_idx - 2] == '-'
2589 && chars[char_idx - 3] == '?'
2590 {
2591 return Some(SymbolKind::Method);
2592 }
2593
2594 if char_idx >= 2 && chars[char_idx - 1] == ':' && chars[char_idx - 2] == ':' {
2596 return Some(SymbolKind::Method);
2597 }
2598
2599 if word
2601 .chars()
2602 .next()
2603 .map(|c| c.is_uppercase())
2604 .unwrap_or(false)
2605 {
2606 return Some(SymbolKind::Class);
2607 }
2608
2609 Some(SymbolKind::Function)
2611}
2612
2613fn position_to_offset(source: &str, position: Position) -> Option<u32> {
2616 let mut byte_offset = 0usize;
2617 for (idx, line) in source.split('\n').enumerate() {
2618 if idx as u32 == position.line {
2619 let line_content = line.trim_end_matches('\r');
2621 let mut col = 0u32;
2622 for (byte_idx, ch) in line_content.char_indices() {
2623 if col >= position.character {
2624 return Some((byte_offset + byte_idx) as u32);
2625 }
2626 col += ch.len_utf16() as u32;
2627 }
2628 return Some((byte_offset + line_content.len()) as u32);
2629 }
2630 byte_offset += line.len() + 1; }
2632 None
2633}
2634
2635fn cursor_is_on_method_decl(source: &str, stmts: &[Stmt<'_, '_>], position: Position) -> bool {
2642 let Some(cursor) = position_to_offset(source, position) else {
2643 return false;
2644 };
2645
2646 fn check(source: &str, stmts: &[Stmt<'_, '_>], cursor: u32) -> bool {
2647 for stmt in stmts {
2648 match &stmt.kind {
2649 StmtKind::Class(c) => {
2650 for member in c.members.iter() {
2651 if let ClassMemberKind::Method(m) = &member.kind {
2652 let start = str_offset(source, m.name);
2653 let end = start + m.name.len() as u32;
2654 if cursor >= start && cursor < end {
2655 return true;
2656 }
2657 }
2658 }
2659 }
2660 StmtKind::Interface(i) => {
2661 for member in i.members.iter() {
2662 if let ClassMemberKind::Method(m) = &member.kind {
2663 let start = str_offset(source, m.name);
2664 let end = start + m.name.len() as u32;
2665 if cursor >= start && cursor < end {
2666 return true;
2667 }
2668 }
2669 }
2670 }
2671 StmtKind::Trait(t) => {
2672 for member in t.members.iter() {
2673 if let ClassMemberKind::Method(m) = &member.kind {
2674 let start = str_offset(source, m.name);
2675 let end = start + m.name.len() as u32;
2676 if cursor >= start && cursor < end {
2677 return true;
2678 }
2679 }
2680 }
2681 }
2682 StmtKind::Enum(e) => {
2683 for member in e.members.iter() {
2684 if let EnumMemberKind::Method(m) = &member.kind {
2685 let start = str_offset(source, m.name);
2686 let end = start + m.name.len() as u32;
2687 if cursor >= start && cursor < end {
2688 return true;
2689 }
2690 }
2691 }
2692 }
2693 StmtKind::Namespace(ns) => {
2694 if let NamespaceBody::Braced(inner) = &ns.body
2695 && check(source, inner, cursor)
2696 {
2697 return true;
2698 }
2699 }
2700 _ => {}
2701 }
2702 }
2703 false
2704 }
2705
2706 check(source, stmts, cursor)
2707}
2708
2709fn class_name_at_construct_decl(
2716 source: &str,
2717 stmts: &[Stmt<'_, '_>],
2718 position: Position,
2719) -> Option<String> {
2720 let cursor = position_to_offset(source, position)?;
2721
2722 fn check(source: &str, stmts: &[Stmt<'_, '_>], cursor: u32, ns_prefix: &str) -> Option<String> {
2723 let mut current_ns = ns_prefix.to_owned();
2724 for stmt in stmts {
2725 match &stmt.kind {
2726 StmtKind::Class(c) => {
2727 for member in c.members.iter() {
2728 if let ClassMemberKind::Method(m) = &member.kind
2729 && m.name == "__construct"
2730 {
2731 let start = str_offset(source, m.name);
2732 let end = start + m.name.len() as u32;
2733 if cursor >= start && cursor < end {
2734 let short = c.name?;
2735 return Some(if current_ns.is_empty() {
2736 short.to_owned()
2737 } else {
2738 format!("{}\\{}", current_ns, short)
2739 });
2740 }
2741 }
2742 }
2743 }
2744 StmtKind::Namespace(ns) => {
2745 let ns_name = ns
2746 .name
2747 .as_ref()
2748 .map(|n| n.to_string_repr().to_string())
2749 .unwrap_or_default();
2750 match &ns.body {
2751 NamespaceBody::Braced(inner) => {
2752 if let Some(name) = check(source, inner, cursor, &ns_name) {
2753 return Some(name);
2754 }
2755 }
2756 NamespaceBody::Simple => {
2757 current_ns = ns_name;
2758 }
2759 }
2760 }
2761 _ => {}
2762 }
2763 }
2764 None
2765 }
2766
2767 check(source, stmts, cursor, "")
2768}
2769
2770fn promoted_property_at_cursor(
2778 source: &str,
2779 stmts: &[Stmt<'_, '_>],
2780 position: Position,
2781) -> Option<String> {
2782 let cursor = position_to_offset(source, position)?;
2783
2784 fn check(source: &str, stmts: &[Stmt<'_, '_>], cursor: u32) -> Option<String> {
2785 for stmt in stmts {
2786 match &stmt.kind {
2787 StmtKind::Class(c) => {
2788 for member in c.members.iter() {
2789 if let ClassMemberKind::Method(m) = &member.kind
2790 && m.name == "__construct"
2791 {
2792 for param in m.params.iter() {
2793 if param.visibility.is_none() {
2794 continue;
2795 }
2796 let name_start = str_offset(source, param.name);
2797 let name_end = name_start + param.name.len() as u32;
2798 if cursor >= name_start && cursor < name_end {
2799 return Some(param.name.trim_start_matches('$').to_owned());
2800 }
2801 }
2802 }
2803 }
2804 }
2805 StmtKind::Namespace(ns) => {
2806 if let NamespaceBody::Braced(inner) = &ns.body
2807 && let Some(name) = check(source, inner, cursor)
2808 {
2809 return Some(name);
2810 }
2811 }
2812 _ => {}
2813 }
2814 }
2815 None
2816 }
2817
2818 check(source, stmts, cursor)
2819}
2820
2821const DEFERRED_ACTION_TAGS: &[&str] = &[
2824 "phpdoc",
2825 "implement",
2826 "constructor",
2827 "getters_setters",
2828 "return_type",
2829 "promote",
2830];
2831
2832impl Backend {
2833 fn generate_deferred_actions(
2835 &self,
2836 tag: &str,
2837 source: &str,
2838 doc: &Arc<ParsedDoc>,
2839 range: Range,
2840 uri: &Url,
2841 ) -> Vec<CodeActionOrCommand> {
2842 match tag {
2843 "phpdoc" => phpdoc_actions(uri, doc, source, range),
2844 "implement" => {
2845 let imports = self.file_imports(uri);
2846 implement_missing_actions(
2847 source,
2848 doc,
2849 &self
2850 .docs
2851 .doc_with_others(uri, Arc::clone(doc), &self.open_urls()),
2852 range,
2853 uri,
2854 &imports,
2855 )
2856 }
2857 "constructor" => generate_constructor_actions(source, doc, range, uri),
2858 "getters_setters" => generate_getters_setters_actions(source, doc, range, uri),
2859 "return_type" => add_return_type_actions(source, doc, range, uri),
2860 "promote" => promote_constructor_actions(source, doc, range, uri),
2861 _ => Vec::new(),
2862 }
2863 }
2864
2865 async fn psr4_goto(&self, fqn: &str) -> Option<Location> {
2868 let path = {
2869 let psr4 = self.psr4.read().unwrap();
2870 psr4.resolve(fqn)?
2871 };
2872
2873 let file_uri = Url::from_file_path(&path).ok()?;
2874
2875 if self.docs.get_doc_salsa(&file_uri).is_none() {
2880 let text = tokio::fs::read_to_string(&path).await.ok()?;
2881 self.index_if_not_open(file_uri.clone(), &text);
2882 }
2883
2884 let doc = self.docs.get_doc_salsa(&file_uri)?;
2885
2886 let short_name = fqn.split('\\').next_back()?;
2889 let range = find_declaration_range(doc.source(), &doc, short_name)?;
2890
2891 Some(Location {
2892 uri: file_uri,
2893 range,
2894 })
2895 }
2896}
2897
2898async fn run_phpunit(
2904 client: &Client,
2905 filter: &str,
2906 root: Option<&std::path::Path>,
2907 file_uri: Option<&Url>,
2908) {
2909 let output = tokio::process::Command::new("vendor/bin/phpunit")
2910 .arg("--filter")
2911 .arg(filter)
2912 .current_dir(root.unwrap_or(std::path::Path::new(".")))
2913 .output()
2914 .await;
2915
2916 let (success, message) = match output {
2917 Ok(out) => {
2918 let text = String::from_utf8_lossy(&out.stdout).into_owned()
2919 + &String::from_utf8_lossy(&out.stderr);
2920 let last_line = text
2921 .lines()
2922 .rev()
2923 .find(|l| !l.trim().is_empty())
2924 .unwrap_or("(no output)")
2925 .to_string();
2926 let ok = out.status.success();
2927 let msg = if ok {
2928 format!("✓ {filter}: {last_line}")
2929 } else {
2930 format!("✗ {filter}: {last_line}")
2931 };
2932 (ok, msg)
2933 }
2934 Err(e) => (
2935 false,
2936 format!("php-lsp.runTest: failed to spawn phpunit — {e}"),
2937 ),
2938 };
2939
2940 let msg_type = if success {
2941 MessageType::INFO
2942 } else {
2943 MessageType::ERROR
2944 };
2945 let mut actions = vec![MessageActionItem {
2946 title: "Run Again".to_string(),
2947 properties: Default::default(),
2948 }];
2949 if !success && file_uri.is_some() {
2950 actions.push(MessageActionItem {
2951 title: "Open File".to_string(),
2952 properties: Default::default(),
2953 });
2954 }
2955
2956 let chosen = client
2957 .show_message_request(msg_type, message, Some(actions))
2958 .await;
2959
2960 match chosen {
2961 Ok(Some(ref action)) if action.title == "Run Again" => {
2962 let output2 = tokio::process::Command::new("vendor/bin/phpunit")
2964 .arg("--filter")
2965 .arg(filter)
2966 .current_dir(root.unwrap_or(std::path::Path::new(".")))
2967 .output()
2968 .await;
2969 let msg2 = match output2 {
2970 Ok(out) => {
2971 let text = String::from_utf8_lossy(&out.stdout).into_owned()
2972 + &String::from_utf8_lossy(&out.stderr);
2973 let last_line = text
2974 .lines()
2975 .rev()
2976 .find(|l| !l.trim().is_empty())
2977 .unwrap_or("(no output)")
2978 .to_string();
2979 if out.status.success() {
2980 format!("✓ {filter}: {last_line}")
2981 } else {
2982 format!("✗ {filter}: {last_line}")
2983 }
2984 }
2985 Err(e) => format!("php-lsp.runTest: failed to spawn phpunit — {e}"),
2986 };
2987 client.show_message(MessageType::INFO, msg2).await;
2988 }
2989 Ok(Some(ref action)) if action.title == "Open File" => {
2990 if let Some(uri) = file_uri {
2991 client
2992 .show_document(ShowDocumentParams {
2993 uri: uri.clone(),
2994 external: Some(false),
2995 take_focus: Some(true),
2996 selection: None,
2997 })
2998 .await
2999 .ok();
3000 }
3001 }
3002 _ => {}
3003 }
3004}
3005
3006async fn send_refresh_requests(client: &Client) {
3010 client.send_request::<SemanticTokensRefresh>(()).await.ok();
3011 client.send_request::<CodeLensRefresh>(()).await.ok();
3012 client
3013 .send_request::<InlayHintRefreshRequest>(())
3014 .await
3015 .ok();
3016 client
3017 .send_request::<WorkspaceDiagnosticRefresh>(())
3018 .await
3019 .ok();
3020 client
3021 .send_request::<InlineValueRefreshRequest>(())
3022 .await
3023 .ok();
3024}
3025
3026const MAX_INDEXED_FILES: usize = 50_000;
3029
3030#[tracing::instrument(
3042 skip(docs, open_files, cache, exclude_paths),
3043 fields(root = %root.display())
3044)]
3045async fn scan_workspace(
3046 root: PathBuf,
3047 docs: Arc<DocumentStore>,
3048 open_files: OpenFiles,
3049 cache: Option<crate::cache::WorkspaceCache>,
3050 exclude_paths: &[String],
3051 max_files: usize,
3052) -> usize {
3053 let mut php_files: Vec<PathBuf> = Vec::new();
3055 let mut stack = vec![root];
3056
3057 'walk: while let Some(dir) = stack.pop() {
3058 let mut entries = match tokio::fs::read_dir(&dir).await {
3059 Ok(e) => e,
3060 Err(_) => continue,
3061 };
3062 while let Ok(Some(entry)) = entries.next_entry().await {
3063 let path = entry.path();
3064 let path_str = path.to_string_lossy().replace('\\', "/");
3067 if exclude_paths.iter().any(|pat| {
3069 let p = pat.trim_end_matches('*').trim_end_matches('/');
3070 path_str.contains(p)
3071 }) {
3072 continue;
3073 }
3074 let file_type = match entry.file_type().await {
3075 Ok(ft) => ft,
3076 Err(_) => continue,
3077 };
3078 if file_type.is_dir() {
3079 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
3080 if !name.starts_with('.') {
3082 stack.push(path);
3083 }
3084 } else if file_type.is_file() && path.extension().is_some_and(|e| e == "php") {
3085 php_files.push(path);
3086 if php_files.len() >= max_files {
3087 break 'walk;
3088 }
3089 }
3090 }
3091 }
3092
3093 let parallelism = std::thread::available_parallelism()
3095 .map(|n| n.get())
3096 .unwrap_or(4);
3097 let sem = Arc::new(tokio::sync::Semaphore::new(parallelism));
3098 let count = Arc::new(std::sync::atomic::AtomicUsize::new(0));
3099 let mut set: tokio::task::JoinSet<()> = tokio::task::JoinSet::new();
3100
3101 for path in php_files {
3102 let permit = Arc::clone(&sem).acquire_owned().await.unwrap();
3103 let docs = Arc::clone(&docs);
3104 let open_files = open_files.clone();
3105 let cache = cache.clone();
3106 let count = Arc::clone(&count);
3107 set.spawn(async move {
3108 let _permit = permit;
3109 let Ok(text) = tokio::fs::read_to_string(&path).await else {
3110 return;
3111 };
3112 let Ok(uri) = Url::from_file_path(&path) else {
3113 return;
3114 };
3115 tokio::task::spawn_blocking(move || {
3116 if open_files.contains(&uri) {
3120 return;
3121 }
3122
3123 let cache_key = cache
3130 .as_ref()
3131 .map(|_| crate::cache::WorkspaceCache::key_for(uri.as_str(), &text));
3132 if let (Some(cache), Some(key)) = (cache.as_ref(), cache_key.as_ref())
3133 && let Some(slice) = cache.read::<mir_codebase::storage::StubSlice>(key)
3134 {
3135 docs.mirror_text(&uri, &text);
3136 docs.seed_cached_slice(&uri, Arc::new(slice));
3137 count.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3138 return;
3139 }
3140
3141 let (doc, diags) = parse_document(&text);
3143 docs.index_from_doc(uri.clone(), &doc, diags);
3144 count.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3145
3146 if let (Some(cache), Some(key)) = (cache.as_ref(), cache_key.as_ref())
3154 && let Some(slice) = docs.slice_for(&uri)
3155 {
3156 let _ = cache.write(key, &*slice);
3157 }
3158 })
3159 .await
3160 .ok();
3161 });
3162 }
3163
3164 while set.join_next().await.is_some() {}
3165
3166 count.load(std::sync::atomic::Ordering::Relaxed)
3167}
3168
3169#[cfg(test)]
3170mod tests {
3171 use super::*;
3172 use crate::use_import::find_use_insert_line;
3173 use tower_lsp::lsp_types::{Position, Range, Url};
3174
3175 #[test]
3177 fn diagnostics_config_default_is_disabled() {
3178 let cfg = DiagnosticsConfig::default();
3179 assert!(!cfg.enabled);
3180 assert!(cfg.undefined_variables);
3183 assert!(cfg.undefined_functions);
3184 assert!(cfg.undefined_classes);
3185 assert!(cfg.arity_errors);
3186 assert!(cfg.type_errors);
3187 assert!(cfg.deprecated_calls);
3188 assert!(cfg.duplicate_declarations);
3189 }
3190
3191 #[test]
3192 fn diagnostics_config_from_empty_object_is_disabled() {
3193 let cfg = DiagnosticsConfig::from_value(&serde_json::json!({}));
3194 assert!(!cfg.enabled);
3195 assert!(cfg.undefined_variables);
3196 }
3197
3198 #[test]
3199 fn diagnostics_config_from_non_object_uses_defaults() {
3200 let cfg = DiagnosticsConfig::from_value(&serde_json::json!(null));
3201 assert!(!cfg.enabled);
3202 }
3203
3204 #[test]
3205 fn diagnostics_config_can_disable_individual_flags() {
3206 let cfg = DiagnosticsConfig::from_value(&serde_json::json!({
3207 "enabled": true,
3208 "undefinedVariables": false,
3209 "undefinedFunctions": false,
3210 "undefinedClasses": true,
3211 "arityErrors": false,
3212 "typeErrors": true,
3213 "deprecatedCalls": false,
3214 "duplicateDeclarations": true,
3215 }));
3216 assert!(cfg.enabled);
3217 assert!(!cfg.undefined_variables);
3218 assert!(!cfg.undefined_functions);
3219 assert!(cfg.undefined_classes);
3220 assert!(!cfg.arity_errors);
3221 assert!(cfg.type_errors);
3222 assert!(!cfg.deprecated_calls);
3223 assert!(cfg.duplicate_declarations);
3224 }
3225
3226 #[test]
3227 fn diagnostics_config_master_switch_disables_all() {
3228 let cfg = DiagnosticsConfig::from_value(&serde_json::json!({"enabled": false}));
3229 assert!(!cfg.enabled);
3230 assert!(cfg.undefined_variables);
3232 }
3233
3234 #[test]
3235 fn diagnostics_config_master_switch_enables_all() {
3236 let cfg = DiagnosticsConfig::from_value(&serde_json::json!({"enabled": true}));
3237 assert!(cfg.enabled);
3238 assert!(cfg.undefined_variables);
3239 }
3240
3241 #[test]
3243 fn lsp_config_default_is_empty() {
3244 let cfg = LspConfig::default();
3245 assert!(cfg.php_version.is_none());
3246 assert!(cfg.exclude_paths.is_empty());
3247 assert!(!cfg.diagnostics.enabled);
3248 }
3249
3250 #[test]
3251 fn lsp_config_parses_php_version() {
3252 let cfg =
3253 LspConfig::from_value(&serde_json::json!({"phpVersion": crate::autoload::PHP_8_2}));
3254 assert_eq!(cfg.php_version.as_deref(), Some(crate::autoload::PHP_8_2));
3255 }
3256
3257 #[test]
3258 fn lsp_config_parses_exclude_paths() {
3259 let cfg = LspConfig::from_value(&serde_json::json!({
3260 "excludePaths": ["cache/*", "generated/*"]
3261 }));
3262 assert_eq!(cfg.exclude_paths, vec!["cache/*", "generated/*"]);
3263 }
3264
3265 #[test]
3266 fn lsp_config_parses_diagnostics_section() {
3267 let cfg = LspConfig::from_value(&serde_json::json!({
3268 "diagnostics": {"enabled": false}
3269 }));
3270 assert!(!cfg.diagnostics.enabled);
3271 }
3272
3273 #[test]
3274 fn lsp_config_ignores_missing_fields() {
3275 let cfg = LspConfig::from_value(&serde_json::json!({}));
3276 assert!(cfg.php_version.is_none());
3277 assert!(cfg.exclude_paths.is_empty());
3278 }
3279
3280 #[test]
3281 fn lsp_config_parses_max_indexed_files() {
3282 let cfg = LspConfig::from_value(&serde_json::json!({"maxIndexedFiles": 5000}));
3283 assert_eq!(cfg.max_indexed_files, 5000);
3284 }
3285
3286 #[test]
3287 fn lsp_config_default_max_indexed_files() {
3288 let cfg = LspConfig::default();
3289 assert_eq!(cfg.max_indexed_files, MAX_INDEXED_FILES);
3290 }
3291
3292 #[test]
3294 fn find_use_insert_line_after_php_open_tag() {
3295 let src = "<?php\nfunction foo() {}";
3296 assert_eq!(find_use_insert_line(src), 1);
3297 }
3298
3299 #[test]
3300 fn find_use_insert_line_after_existing_use() {
3301 let src = "<?php\nuse Foo\\Bar;\nuse Baz\\Qux;\nclass Impl {}";
3302 assert_eq!(find_use_insert_line(src), 3);
3303 }
3304
3305 #[test]
3306 fn find_use_insert_line_after_namespace() {
3307 let src = "<?php\nnamespace App\\Services;\nclass Service {}";
3308 assert_eq!(find_use_insert_line(src), 2);
3309 }
3310
3311 #[test]
3312 fn find_use_insert_line_after_namespace_and_use() {
3313 let src = "<?php\nnamespace App;\nuse Foo\\Bar;\nclass Impl {}";
3314 assert_eq!(find_use_insert_line(src), 3);
3315 }
3316
3317 #[test]
3318 fn find_use_insert_line_empty_file() {
3319 assert_eq!(find_use_insert_line(""), 0);
3320 }
3321
3322 #[test]
3324 fn is_after_arrow_with_method_call() {
3325 let src = "<?php\n$obj->method();\n";
3326 let pos = Position {
3328 line: 1,
3329 character: 6,
3330 };
3331 assert!(is_after_arrow(src, pos));
3332 }
3333
3334 #[test]
3335 fn is_after_arrow_without_arrow() {
3336 let src = "<?php\n$obj->method();\n";
3337 let pos = Position {
3339 line: 1,
3340 character: 1,
3341 };
3342 assert!(!is_after_arrow(src, pos));
3343 }
3344
3345 #[test]
3346 fn is_after_arrow_on_standalone_identifier() {
3347 let src = "<?php\nfunction greet() {}\n";
3348 let pos = Position {
3349 line: 1,
3350 character: 10,
3351 };
3352 assert!(!is_after_arrow(src, pos));
3353 }
3354
3355 #[test]
3356 fn is_after_arrow_out_of_bounds_line() {
3357 let src = "<?php\n$x = 1;\n";
3358 let pos = Position {
3359 line: 99,
3360 character: 0,
3361 };
3362 assert!(!is_after_arrow(src, pos));
3363 }
3364
3365 #[test]
3366 fn is_after_arrow_at_start_of_property() {
3367 let src = "<?php\n$this->name;\n";
3368 let pos = Position {
3370 line: 1,
3371 character: 7,
3372 };
3373 assert!(is_after_arrow(src, pos));
3374 }
3375
3376 #[test]
3378 fn php_file_op_matches_php_files() {
3379 let op = php_file_op();
3380 assert_eq!(op.filters.len(), 1);
3381 let filter = &op.filters[0];
3382 assert_eq!(filter.scheme.as_deref(), Some("file"));
3383 assert_eq!(filter.pattern.glob, "**/*.php");
3384 }
3385
3386 #[test]
3388 fn defer_actions_strips_edit_and_adds_data() {
3389 let uri = Url::parse("file:///test.php").unwrap();
3390 let range = Range {
3391 start: Position {
3392 line: 0,
3393 character: 0,
3394 },
3395 end: Position {
3396 line: 0,
3397 character: 5,
3398 },
3399 };
3400 let actions = vec![CodeActionOrCommand::CodeAction(CodeAction {
3401 title: "My Action".to_string(),
3402 kind: Some(CodeActionKind::REFACTOR),
3403 edit: Some(WorkspaceEdit::default()),
3404 data: None,
3405 ..Default::default()
3406 })];
3407 let deferred = defer_actions(actions, "test_kind", &uri, range);
3408 assert_eq!(deferred.len(), 1);
3409 if let CodeActionOrCommand::CodeAction(ca) = &deferred[0] {
3410 assert!(ca.edit.is_none(), "edit should be stripped");
3411 assert!(ca.data.is_some(), "data payload should be set");
3412 let data = ca.data.as_ref().unwrap();
3413 assert_eq!(data["php_lsp_resolve"], "test_kind");
3414 assert_eq!(data["uri"], uri.to_string());
3415 } else {
3416 panic!("expected CodeAction");
3417 }
3418 }
3419
3420 #[test]
3422 fn build_use_import_edit_inserts_after_php_tag() {
3423 let src = "<?php\nclass Foo {}";
3424 let uri = Url::parse("file:///test.php").unwrap();
3425 let edit = build_use_import_edit(src, &uri, "App\\Services\\Bar");
3426 let changes = edit.changes.unwrap();
3427 let edits = changes.get(&uri).unwrap();
3428 assert_eq!(edits.len(), 1);
3429 assert_eq!(edits[0].new_text, "use App\\Services\\Bar;\n");
3430 assert_eq!(edits[0].range.start.line, 1);
3431 }
3432
3433 #[test]
3434 fn build_use_import_edit_inserts_after_existing_use() {
3435 let src = "<?php\nuse Foo\\Bar;\nclass Impl {}";
3436 let uri = Url::parse("file:///test.php").unwrap();
3437 let edit = build_use_import_edit(src, &uri, "Baz\\Qux");
3438 let changes = edit.changes.unwrap();
3439 let edits = changes.get(&uri).unwrap();
3440 assert_eq!(edits[0].range.start.line, 2);
3441 assert_eq!(edits[0].new_text, "use Baz\\Qux;\n");
3442 }
3443
3444 #[test]
3446 fn undefined_class_name_extracted_from_message() {
3447 let msg = "Class MyService does not exist";
3448 let name = msg
3449 .strip_prefix("Class ")
3450 .and_then(|s| s.strip_suffix(" does not exist"))
3451 .unwrap_or("")
3452 .trim();
3453 assert_eq!(name, "MyService");
3454 }
3455
3456 #[test]
3457 fn undefined_function_message_not_matched_by_extraction() {
3458 let msg = "Function myHelper() is not defined";
3461 let name = msg
3462 .strip_prefix("Class ")
3463 .and_then(|s| s.strip_suffix(" does not exist"))
3464 .unwrap_or("")
3465 .trim();
3466 assert!(
3467 name.is_empty(),
3468 "function diagnostic should not extract a class name"
3469 );
3470 }
3471
3472 #[test]
3475 fn position_to_offset_first_line() {
3476 let src = "<?php\nfoo();";
3477 assert_eq!(
3479 position_to_offset(
3480 src,
3481 Position {
3482 line: 0,
3483 character: 0
3484 }
3485 ),
3486 Some(0)
3487 );
3488 assert_eq!(
3490 position_to_offset(
3491 src,
3492 Position {
3493 line: 0,
3494 character: 4
3495 }
3496 ),
3497 Some(4)
3498 );
3499 assert_eq!(
3501 position_to_offset(
3502 src,
3503 Position {
3504 line: 0,
3505 character: 5
3506 }
3507 ),
3508 Some(5)
3509 );
3510 }
3511
3512 #[test]
3513 fn position_to_offset_second_line() {
3514 let src = "<?php\nfoo();";
3515 assert_eq!(
3517 position_to_offset(
3518 src,
3519 Position {
3520 line: 1,
3521 character: 0
3522 }
3523 ),
3524 Some(6)
3525 );
3526 assert_eq!(
3528 position_to_offset(
3529 src,
3530 Position {
3531 line: 1,
3532 character: 3
3533 }
3534 ),
3535 Some(9)
3536 );
3537 }
3538
3539 #[test]
3540 fn position_to_offset_line_boundary_returns_none() {
3541 let src = "<?php";
3543 assert_eq!(
3544 position_to_offset(
3545 src,
3546 Position {
3547 line: 1,
3548 character: 0
3549 }
3550 ),
3551 None
3552 );
3553 assert_eq!(
3554 position_to_offset(
3555 src,
3556 Position {
3557 line: 5,
3558 character: 0
3559 }
3560 ),
3561 None
3562 );
3563 }
3564
3565 #[test]
3568 fn cursor_on_method_decl_name_returns_true() {
3569 let doc = ParsedDoc::parse("<?php\nclass C {\n public function add() {}\n}".to_string());
3572 let source = doc.source();
3573 let stmts = &doc.program().stmts;
3574 for col in 20u32..=22 {
3576 assert!(
3577 cursor_is_on_method_decl(
3578 source,
3579 stmts,
3580 Position {
3581 line: 2,
3582 character: col
3583 }
3584 ),
3585 "expected true at col {col}"
3586 );
3587 }
3588 assert!(!cursor_is_on_method_decl(
3590 source,
3591 stmts,
3592 Position {
3593 line: 2,
3594 character: 19
3595 }
3596 ));
3597 assert!(!cursor_is_on_method_decl(
3598 source,
3599 stmts,
3600 Position {
3601 line: 2,
3602 character: 23
3603 }
3604 ));
3605 }
3606
3607 #[test]
3608 fn cursor_on_free_function_decl_returns_false() {
3609 let doc = ParsedDoc::parse("<?php\nfunction add() {}".to_string());
3611 let source = doc.source();
3612 let stmts = &doc.program().stmts;
3613 assert!(!cursor_is_on_method_decl(
3614 source,
3615 stmts,
3616 Position {
3617 line: 1,
3618 character: 9
3619 }
3620 ));
3621 }
3622
3623 #[test]
3624 fn cursor_on_method_call_site_returns_false() {
3625 let doc = ParsedDoc::parse(
3627 "<?php\nclass C { public function add() {} }\n$c = new C();\n$c->add();".to_string(),
3628 );
3629 let source = doc.source();
3630 let stmts = &doc.program().stmts;
3631 assert!(!cursor_is_on_method_decl(
3632 source,
3633 stmts,
3634 Position {
3635 line: 3,
3636 character: 4
3637 }
3638 ));
3639 }
3640
3641 #[test]
3642 fn cursor_on_interface_method_decl_returns_true() {
3643 let doc = ParsedDoc::parse(
3645 "<?php\ninterface I {\n public function add(): void;\n}".to_string(),
3646 );
3647 let source = doc.source();
3648 let stmts = &doc.program().stmts;
3649 assert!(cursor_is_on_method_decl(
3650 source,
3651 stmts,
3652 Position {
3653 line: 2,
3654 character: 20
3655 }
3656 ));
3657 }
3658
3659 #[test]
3660 fn cursor_on_trait_method_decl_returns_true() {
3661 let doc = ParsedDoc::parse("<?php\ntrait T {\n public function add() {}\n}".to_string());
3663 let source = doc.source();
3664 let stmts = &doc.program().stmts;
3665 assert!(cursor_is_on_method_decl(
3666 source,
3667 stmts,
3668 Position {
3669 line: 2,
3670 character: 20
3671 }
3672 ));
3673 }
3674
3675 #[test]
3676 fn cursor_on_enum_method_decl_returns_true() {
3677 let doc = ParsedDoc::parse(
3679 "<?php\nenum Status {\n public function label(): string { return 'x'; }\n}"
3680 .to_string(),
3681 );
3682 let source = doc.source();
3683 let stmts = &doc.program().stmts;
3684 assert!(cursor_is_on_method_decl(
3685 source,
3686 stmts,
3687 Position {
3688 line: 2,
3689 character: 20
3690 }
3691 ));
3692 }
3693
3694 #[test]
3695 fn cursor_on_method_decl_in_unbraced_namespace_returns_true() {
3696 let doc = ParsedDoc::parse(
3705 "<?php\nnamespace App;\nclass C {\n public function add() {}\n}".to_string(),
3706 );
3707 let source = doc.source();
3708 let stmts = &doc.program().stmts;
3709 assert!(
3710 cursor_is_on_method_decl(
3711 source,
3712 stmts,
3713 Position {
3714 line: 3,
3715 character: 20
3716 }
3717 ),
3718 "method in unbraced namespace must be detected"
3719 );
3720 }
3721
3722 #[test]
3723 fn cursor_on_method_decl_in_braced_namespace_returns_true() {
3724 let doc = ParsedDoc::parse(
3733 "<?php\nnamespace App {\n class C {\n public function add() {}\n }\n}"
3734 .to_string(),
3735 );
3736 let source = doc.source();
3737 let stmts = &doc.program().stmts;
3738 assert!(
3739 cursor_is_on_method_decl(
3740 source,
3741 stmts,
3742 Position {
3743 line: 3,
3744 character: 24
3745 }
3746 ),
3747 "method in braced namespace must be detected"
3748 );
3749 }
3750
3751 #[test]
3754 fn merge_file_only_uses_file_values() {
3755 let file = serde_json::json!({
3756 "phpVersion": "8.1",
3757 "excludePaths": ["vendor/*"],
3758 "maxIndexedFiles": 500,
3759 });
3760 let merged = LspConfig::merge_project_configs(Some(&file), None);
3761 let cfg = LspConfig::from_value(&merged);
3762 assert_eq!(cfg.php_version, Some("8.1".to_string()));
3763 assert_eq!(cfg.exclude_paths, vec!["vendor/*"]);
3764 assert_eq!(cfg.max_indexed_files, 500);
3765 }
3766
3767 #[test]
3768 fn merge_editor_wins_per_key_over_file() {
3769 let file = serde_json::json!({"phpVersion": "8.1", "maxIndexedFiles": 100});
3770 let editor = serde_json::json!({"phpVersion": "8.3", "maxIndexedFiles": 200});
3771 let merged = LspConfig::merge_project_configs(Some(&file), Some(&editor));
3772 let cfg = LspConfig::from_value(&merged);
3773 assert_eq!(cfg.php_version, Some("8.3".to_string()));
3774 assert_eq!(cfg.max_indexed_files, 200);
3775 }
3776
3777 #[test]
3778 fn merge_exclude_paths_concat_not_replace() {
3779 let file = serde_json::json!({"excludePaths": ["cache/*"]});
3780 let editor = serde_json::json!({"excludePaths": ["logs/*"]});
3781 let merged = LspConfig::merge_project_configs(Some(&file), Some(&editor));
3782 let cfg = LspConfig::from_value(&merged);
3783 assert_eq!(cfg.exclude_paths, vec!["cache/*", "logs/*"]);
3785 }
3786
3787 #[test]
3788 fn merge_no_file_uses_editor_only() {
3789 let editor = serde_json::json!({"phpVersion": "8.2", "excludePaths": ["tmp/*"]});
3790 let merged = LspConfig::merge_project_configs(None, Some(&editor));
3791 let cfg = LspConfig::from_value(&merged);
3792 assert_eq!(cfg.php_version, Some("8.2".to_string()));
3793 assert_eq!(cfg.exclude_paths, vec!["tmp/*"]);
3794 }
3795
3796 #[test]
3797 fn merge_both_none_returns_defaults() {
3798 let merged = LspConfig::merge_project_configs(None, None);
3799 let cfg = LspConfig::from_value(&merged);
3800 assert!(cfg.php_version.is_none());
3801 assert!(cfg.exclude_paths.is_empty());
3802 assert_eq!(cfg.max_indexed_files, MAX_INDEXED_FILES);
3803 }
3804
3805 #[test]
3806 fn merge_file_editor_both_have_exclude_paths_all_present() {
3807 let file = serde_json::json!({"excludePaths": ["a/*", "b/*"]});
3808 let editor = serde_json::json!({"excludePaths": ["c/*"]});
3809 let merged = LspConfig::merge_project_configs(Some(&file), Some(&editor));
3810 let cfg = LspConfig::from_value(&merged);
3811 assert_eq!(cfg.exclude_paths, vec!["a/*", "b/*", "c/*"]);
3812 }
3813}