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
326pub struct Backend {
327 client: Client,
328 docs: Arc<DocumentStore>,
329 open_files: OpenFiles,
333 root_paths: Arc<RwLock<Vec<PathBuf>>>,
334 psr4: Arc<RwLock<Psr4Map>>,
335 meta: Arc<RwLock<PhpStormMeta>>,
336 config: Arc<RwLock<LspConfig>>,
337}
338
339impl Backend {
340 pub fn new(client: Client) -> Self {
341 Backend {
346 client,
347 docs: Arc::new(DocumentStore::new()),
348 open_files: OpenFiles::new(),
349 root_paths: Arc::new(RwLock::new(Vec::new())),
350 psr4: Arc::new(RwLock::new(Psr4Map::empty())),
351 meta: Arc::new(RwLock::new(PhpStormMeta::default())),
352 config: Arc::new(RwLock::new(LspConfig::default())),
353 }
354 }
355
356 fn set_open_text(&self, uri: Url, text: String) -> u64 {
359 self.open_files.set_open_text(&self.docs, uri, text)
360 }
361
362 fn close_open_file(&self, uri: &Url) {
363 self.open_files.close(&self.docs, uri);
364 }
365
366 fn index_if_not_open(&self, uri: Url, text: &str) {
370 if !self.open_files.contains(&uri) {
371 self.docs.index(uri, text);
372 }
373 }
374
375 fn index_from_doc_if_not_open(&self, uri: Url, doc: &ParsedDoc, diags: Vec<Diagnostic>) {
377 if !self.open_files.contains(&uri) {
378 self.docs.index_from_doc(uri, doc, diags);
379 }
380 }
381
382 fn get_open_text(&self, uri: &Url) -> Option<String> {
383 self.open_files.text(uri)
384 }
385
386 fn set_parse_diagnostics(&self, uri: &Url, diagnostics: Vec<Diagnostic>) {
387 self.open_files.set_parse_diagnostics(uri, diagnostics);
388 }
389
390 fn get_parse_diagnostics(&self, uri: &Url) -> Option<Vec<Diagnostic>> {
391 self.open_files.parse_diagnostics(uri)
392 }
393
394 fn all_open_files_with_diagnostics(&self) -> Vec<(Url, Vec<Diagnostic>, Option<i64>)> {
395 self.open_files.all_with_diagnostics()
396 }
397
398 fn open_urls(&self) -> Vec<Url> {
399 self.open_files.urls()
400 }
401
402 fn get_doc(&self, uri: &Url) -> Option<Arc<ParsedDoc>> {
403 self.open_files.get_doc(&self.docs, uri)
404 }
405
406 fn codebase(&self) -> Arc<mir_codebase::Codebase> {
411 self.docs.get_codebase_salsa()
412 }
413
414 fn file_imports(&self, uri: &Url) -> std::collections::HashMap<String, String> {
416 self.codebase()
417 .file_imports
418 .get(uri.as_str())
419 .map(|r| r.clone())
420 .unwrap_or_default()
421 }
422
423 fn resolve_php_version(&self, explicit: Option<&str>) -> (String, &'static str) {
426 let roots = self.root_paths.read().unwrap().clone();
427 crate::autoload::resolve_php_version_from_roots(&roots, explicit)
428 }
429}
430
431#[async_trait]
432impl LanguageServer for Backend {
433 async fn initialize(&self, params: InitializeParams) -> Result<InitializeResult> {
434 {
437 let mut roots: Vec<PathBuf> = params
438 .workspace_folders
439 .as_deref()
440 .unwrap_or(&[])
441 .iter()
442 .filter_map(|f| f.uri.to_file_path().ok())
443 .collect();
444 if roots.is_empty()
445 && let Some(path) = params.root_uri.as_ref().and_then(|u| u.to_file_path().ok())
446 {
447 roots.push(path);
448 }
449 *self.root_paths.write().unwrap() = roots;
450 }
451
452 {
454 let opts = params.initialization_options.as_ref();
455 let roots = self.root_paths.read().unwrap().clone();
456
457 let file_cfg = crate::autoload::load_project_config_json(&roots);
459
460 if matches!(file_cfg, Some(serde_json::Value::Null)) {
462 self.client
463 .log_message(
464 tower_lsp::lsp_types::MessageType::WARNING,
465 "php-lsp: .php-lsp.json contains invalid JSON — ignoring",
466 )
467 .await;
468 }
469
470 if let Some(serde_json::Value::Object(ref obj)) = file_cfg
472 && let Some(ver) = obj.get("phpVersion").and_then(|v| v.as_str())
473 && !crate::autoload::is_valid_php_version(ver)
474 {
475 self.client
476 .log_message(
477 tower_lsp::lsp_types::MessageType::WARNING,
478 format!(
479 "php-lsp: .php-lsp.json unsupported phpVersion {ver:?} — valid values: {}",
480 crate::autoload::SUPPORTED_PHP_VERSIONS.join(", ")
481 ),
482 )
483 .await;
484 }
485
486 if let Some(ver) = opts
488 .and_then(|o| o.get("phpVersion"))
489 .and_then(|v| v.as_str())
490 && !crate::autoload::is_valid_php_version(ver)
491 {
492 self.client
493 .log_message(
494 tower_lsp::lsp_types::MessageType::WARNING,
495 format!(
496 "php-lsp: unsupported phpVersion {ver:?} — valid values: {}",
497 crate::autoload::SUPPORTED_PHP_VERSIONS.join(", ")
498 ),
499 )
500 .await;
501 }
502
503 let file_obj = file_cfg.as_ref().filter(|v| v.is_object());
506 let merged = LspConfig::merge_project_configs(file_obj, opts);
507 let mut cfg = LspConfig::from_value(&merged);
508
509 let (ver, source) = self.resolve_php_version(cfg.php_version.as_deref());
514 self.client
515 .log_message(
516 tower_lsp::lsp_types::MessageType::INFO,
517 format!("php-lsp: using PHP {ver} ({source})"),
518 )
519 .await;
520 if source != "set by editor" && !crate::autoload::is_valid_php_version(&ver) {
525 self.client
526 .show_message(
527 tower_lsp::lsp_types::MessageType::WARNING,
528 format!(
529 "php-lsp: detected PHP {ver} is outside the supported range ({}); \
530 analysis may be inaccurate",
531 crate::autoload::SUPPORTED_PHP_VERSIONS.join(", ")
532 ),
533 )
534 .await;
535 }
536 cfg.php_version = Some(ver.clone());
537 if let Ok(pv) = ver.parse::<mir_analyzer::PhpVersion>() {
538 self.docs.set_php_version(pv);
539 }
540 *self.config.write().unwrap() = cfg;
541 }
542
543 Ok(InitializeResult {
544 capabilities: ServerCapabilities {
545 text_document_sync: Some(TextDocumentSyncCapability::Options(
546 TextDocumentSyncOptions {
547 open_close: Some(true),
548 change: Some(TextDocumentSyncKind::FULL),
549 will_save: Some(true),
550 will_save_wait_until: Some(true),
551 save: Some(TextDocumentSyncSaveOptions::SaveOptions(SaveOptions {
552 include_text: Some(false),
553 })),
554 },
555 )),
556 completion_provider: Some(CompletionOptions {
557 trigger_characters: Some(vec![
558 "$".to_string(),
559 ">".to_string(),
560 ":".to_string(),
561 "(".to_string(),
562 "[".to_string(),
563 ]),
564 resolve_provider: Some(true),
565 ..Default::default()
566 }),
567 hover_provider: Some(HoverProviderCapability::Simple(true)),
568 definition_provider: Some(OneOf::Left(true)),
569 references_provider: Some(OneOf::Left(true)),
570 document_symbol_provider: Some(OneOf::Left(true)),
571 workspace_symbol_provider: Some(OneOf::Right(WorkspaceSymbolOptions {
572 resolve_provider: Some(true),
573 work_done_progress_options: Default::default(),
574 })),
575 rename_provider: Some(OneOf::Right(RenameOptions {
576 prepare_provider: Some(true),
577 work_done_progress_options: Default::default(),
578 })),
579 signature_help_provider: Some(SignatureHelpOptions {
580 trigger_characters: Some(vec!["(".to_string(), ",".to_string()]),
581 retrigger_characters: None,
582 work_done_progress_options: Default::default(),
583 }),
584 inlay_hint_provider: Some(OneOf::Right(InlayHintServerCapabilities::Options(
585 InlayHintOptions {
586 resolve_provider: Some(true),
587 work_done_progress_options: Default::default(),
588 },
589 ))),
590 folding_range_provider: Some(FoldingRangeProviderCapability::Simple(true)),
591 semantic_tokens_provider: Some(
592 SemanticTokensServerCapabilities::SemanticTokensOptions(
593 SemanticTokensOptions {
594 legend: legend(),
595 full: Some(SemanticTokensFullOptions::Delta { delta: Some(true) }),
596 range: Some(true),
597 ..Default::default()
598 },
599 ),
600 ),
601 selection_range_provider: Some(SelectionRangeProviderCapability::Simple(true)),
602 call_hierarchy_provider: Some(CallHierarchyServerCapability::Simple(true)),
603 document_highlight_provider: Some(OneOf::Left(true)),
604 implementation_provider: Some(ImplementationProviderCapability::Simple(true)),
605 code_action_provider: Some(CodeActionProviderCapability::Options(
606 CodeActionOptions {
607 resolve_provider: Some(true),
608 ..Default::default()
609 },
610 )),
611 declaration_provider: Some(DeclarationCapability::Simple(true)),
612 type_definition_provider: Some(TypeDefinitionProviderCapability::Simple(true)),
613 code_lens_provider: Some(CodeLensOptions {
614 resolve_provider: Some(true),
615 }),
616 document_formatting_provider: Some(OneOf::Left(true)),
617 document_range_formatting_provider: Some(OneOf::Left(true)),
618 document_on_type_formatting_provider: Some(DocumentOnTypeFormattingOptions {
619 first_trigger_character: "}".to_string(),
620 more_trigger_character: Some(vec!["\n".to_string()]),
621 }),
622 document_link_provider: Some(DocumentLinkOptions {
623 resolve_provider: Some(true),
624 work_done_progress_options: Default::default(),
625 }),
626 execute_command_provider: Some(ExecuteCommandOptions {
627 commands: vec!["php-lsp.runTest".to_string()],
628 work_done_progress_options: Default::default(),
629 }),
630 diagnostic_provider: Some(DiagnosticServerCapabilities::Options(
631 DiagnosticOptions {
632 identifier: None,
633 inter_file_dependencies: true,
634 workspace_diagnostics: true,
635 work_done_progress_options: Default::default(),
636 },
637 )),
638 workspace: Some(WorkspaceServerCapabilities {
639 workspace_folders: Some(WorkspaceFoldersServerCapabilities {
640 supported: Some(true),
641 change_notifications: Some(OneOf::Left(true)),
642 }),
643 file_operations: Some(WorkspaceFileOperationsServerCapabilities {
644 will_rename: Some(php_file_op()),
645 did_rename: Some(php_file_op()),
646 did_create: Some(php_file_op()),
647 will_delete: Some(php_file_op()),
648 did_delete: Some(php_file_op()),
649 ..Default::default()
650 }),
651 }),
652 linked_editing_range_provider: Some(LinkedEditingRangeServerCapabilities::Simple(
653 true,
654 )),
655 moniker_provider: Some(OneOf::Left(true)),
656 inline_value_provider: Some(OneOf::Right(InlineValueServerCapabilities::Options(
657 InlineValueOptions {
658 work_done_progress_options: Default::default(),
659 },
660 ))),
661 ..Default::default()
662 },
663 ..Default::default()
664 })
665 }
666
667 async fn initialized(&self, _params: InitializedParams) {
668 let php_selector = serde_json::json!([{"language": "php"}]);
670 let registrations = vec![
671 Registration {
672 id: "php-lsp-file-watcher".to_string(),
673 method: "workspace/didChangeWatchedFiles".to_string(),
674 register_options: Some(serde_json::json!({
675 "watchers": [{"globPattern": "**/*.php"}]
676 })),
677 },
678 Registration {
681 id: "php-lsp-type-hierarchy".to_string(),
682 method: "textDocument/prepareTypeHierarchy".to_string(),
683 register_options: Some(serde_json::json!({"documentSelector": php_selector})),
684 },
685 Registration {
687 id: "php-lsp-config-change".to_string(),
688 method: "workspace/didChangeConfiguration".to_string(),
689 register_options: Some(serde_json::json!({"section": "php-lsp"})),
690 },
691 ];
692 self.client.register_capability(registrations).await.ok();
693
694 let roots = self.root_paths.read().unwrap().clone();
697 if !roots.is_empty() {
698 {
700 let mut merged = Psr4Map::empty();
701 for root in &roots {
702 merged.extend(Psr4Map::load(root));
703 }
704 *self.psr4.write().unwrap() = merged;
705 }
706 *self.meta.write().unwrap() = PhpStormMeta::load(&roots[0]);
708
709 let token = NumberOrString::String("php-lsp/indexing".to_string());
711 self.client
712 .send_request::<WorkDoneProgressCreate>(WorkDoneProgressCreateParams {
713 token: token.clone(),
714 })
715 .await
716 .ok();
717
718 let docs = Arc::clone(&self.docs);
719 let open_files = self.open_files.clone();
720 let client = self.client.clone();
721 let (exclude_paths, max_indexed_files) = {
722 let cfg = self.config.read().unwrap();
723 (cfg.exclude_paths.clone(), cfg.max_indexed_files)
724 };
725 tokio::spawn(async move {
726 client
727 .send_notification::<ProgressNotification>(ProgressParams {
728 token: token.clone(),
729 value: ProgressParamsValue::WorkDone(WorkDoneProgress::Begin(
730 WorkDoneProgressBegin {
731 title: "php-lsp: indexing workspace".to_string(),
732 cancellable: Some(false),
733 message: None,
734 percentage: None,
735 },
736 )),
737 })
738 .await;
739
740 let mut total = 0usize;
741 for root in roots {
742 let cache = crate::cache::WorkspaceCache::new(&root);
748 total += scan_workspace(
749 root,
750 Arc::clone(&docs),
751 open_files.clone(),
752 cache,
753 &exclude_paths,
754 max_indexed_files,
755 )
756 .await;
757 }
758
759 client
760 .send_notification::<ProgressNotification>(ProgressParams {
761 token,
762 value: ProgressParamsValue::WorkDone(WorkDoneProgress::End(
763 WorkDoneProgressEnd {
764 message: Some(format!("Indexed {total} files")),
765 },
766 )),
767 })
768 .await;
769
770 client
771 .log_message(
772 MessageType::INFO,
773 format!("php-lsp: indexed {total} workspace files"),
774 )
775 .await;
776
777 send_refresh_requests(&client).await;
781
782 let warm_docs = Arc::clone(&docs);
796 tokio::task::spawn_blocking(move || {
797 warm_docs.warm_reference_index();
798 });
799 drop(docs);
800 client.send_notification::<IndexReadyNotification>(()).await;
801 });
802 }
803
804 self.client
805 .log_message(MessageType::INFO, "php-lsp ready")
806 .await;
807 }
808
809 async fn did_change_configuration(&self, _params: DidChangeConfigurationParams) {
810 let items = vec![ConfigurationItem {
813 scope_uri: None,
814 section: Some("php-lsp".to_string()),
815 }];
816 if let Ok(values) = self.client.configuration(items).await
817 && let Some(value) = values.into_iter().next()
818 {
819 let roots = self.root_paths.read().unwrap().clone();
820
821 let file_cfg = crate::autoload::load_project_config_json(&roots);
824
825 if let Some(ver) = value.get("phpVersion").and_then(|v| v.as_str())
826 && !crate::autoload::is_valid_php_version(ver)
827 {
828 self.client
829 .log_message(
830 tower_lsp::lsp_types::MessageType::WARNING,
831 format!(
832 "php-lsp: unsupported phpVersion {ver:?} — valid values: {}",
833 crate::autoload::SUPPORTED_PHP_VERSIONS.join(", ")
834 ),
835 )
836 .await;
837 }
838
839 let file_obj = file_cfg.as_ref().filter(|v| v.is_object());
840 let merged = LspConfig::merge_project_configs(file_obj, Some(&value));
841 let mut cfg = LspConfig::from_value(&merged);
842
843 let (ver, source) = self.resolve_php_version(cfg.php_version.as_deref());
845 self.client
846 .log_message(
847 tower_lsp::lsp_types::MessageType::INFO,
848 format!("php-lsp: using PHP {ver} ({source})"),
849 )
850 .await;
851 if source != "set by editor" && !crate::autoload::is_valid_php_version(&ver) {
854 self.client
855 .show_message(
856 tower_lsp::lsp_types::MessageType::WARNING,
857 format!(
858 "php-lsp: detected PHP {ver} is outside the supported range ({}); \
859 analysis may be inaccurate",
860 crate::autoload::SUPPORTED_PHP_VERSIONS.join(", ")
861 ),
862 )
863 .await;
864 }
865 cfg.php_version = Some(ver.clone());
866 if let Ok(pv) = ver.parse::<mir_analyzer::PhpVersion>() {
867 self.docs.set_php_version(pv);
868 }
869 *self.config.write().unwrap() = cfg;
870 send_refresh_requests(&self.client).await;
871 }
872 }
873
874 async fn did_change_workspace_folders(&self, params: DidChangeWorkspaceFoldersParams) {
875 {
877 let mut roots = self.root_paths.write().unwrap();
878 for removed in ¶ms.event.removed {
879 if let Ok(path) = removed.uri.to_file_path() {
880 roots.retain(|r| r != &path);
881 }
882 }
883 }
884
885 let (exclude_paths, max_indexed_files) = {
887 let cfg = self.config.read().unwrap();
888 (cfg.exclude_paths.clone(), cfg.max_indexed_files)
889 };
890 for added in ¶ms.event.added {
891 if let Ok(path) = added.uri.to_file_path() {
892 {
893 let mut roots = self.root_paths.write().unwrap();
894 if !roots.contains(&path) {
895 roots.push(path.clone());
896 }
897 }
898 let docs = Arc::clone(&self.docs);
899 let open_files = self.open_files.clone();
900 let ex = exclude_paths.clone();
901 let path_clone = path.clone();
902 let client = self.client.clone();
903 tokio::spawn(async move {
904 let cache = crate::cache::WorkspaceCache::new(&path_clone);
905 scan_workspace(path_clone, docs, open_files, cache, &ex, max_indexed_files)
906 .await;
907 send_refresh_requests(&client).await;
908 });
909 }
910 }
911 }
912
913 async fn shutdown(&self) -> Result<()> {
914 Ok(())
915 }
916
917 async fn did_open(&self, params: DidOpenTextDocumentParams) {
918 let uri = params.text_document.uri;
919 let text = params.text_document.text;
920
921 self.set_open_text(uri.clone(), text.clone());
925
926 let docs_for_spawn = Arc::clone(&self.docs);
927 let diag_cfg = self.config.read().unwrap().diagnostics.clone();
928
929 let uri_sem = uri.clone();
934 let (parse_diags, sem_issues) = tokio::task::spawn_blocking(move || {
935 let (_doc, parse_diags) = parse_document(&text);
936 let sem_issues = docs_for_spawn.get_semantic_issues_salsa(&uri_sem);
937 (parse_diags, sem_issues)
938 })
939 .await
940 .unwrap_or_else(|_| (vec![], None));
941
942 self.set_parse_diagnostics(&uri, parse_diags.clone());
943 let stored_source = self.get_open_text(&uri).unwrap_or_default();
944 let doc2 = self.get_doc(&uri);
945 let mut all_diags = parse_diags;
946 if let Some(ref d) = doc2 {
947 let dup_diags = duplicate_declaration_diagnostics(&stored_source, d, &diag_cfg);
948 all_diags.extend(dup_diags);
949 }
950 if let Some(issues) = sem_issues {
951 all_diags.extend(crate::semantic_diagnostics::issues_to_diagnostics(
952 &issues, &uri, &diag_cfg,
953 ));
954 }
955 self.client.publish_diagnostics(uri, all_diags, None).await;
956 }
957
958 async fn did_change(&self, params: DidChangeTextDocumentParams) {
959 let uri = params.text_document.uri;
960 let text = match params.content_changes.into_iter().last() {
961 Some(c) => c.text,
962 None => return,
963 };
964
965 let version = self.set_open_text(uri.clone(), text.clone());
969
970 let docs = Arc::clone(&self.docs);
971 let open_files = self.open_files.clone();
972 let client = self.client.clone();
973 let diag_cfg = self.config.read().unwrap().diagnostics.clone();
974 tokio::spawn(async move {
975 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
978
979 let (_doc, diagnostics) = tokio::task::spawn_blocking(move || parse_document(&text))
980 .await
981 .unwrap_or_else(|_| (ParsedDoc::default(), vec![]));
982
983 if open_files.current_version(&uri) == Some(version) {
986 open_files.set_parse_diagnostics(&uri, diagnostics.clone());
987
988 let docs_sem = Arc::clone(&docs);
994 let open_files_sem = open_files.clone();
995 let uri_sem = uri.clone();
996 let diag_cfg_sem = diag_cfg.clone();
997 let extra = tokio::task::spawn_blocking(move || {
998 let Some(d) = open_files_sem.get_doc(&docs_sem, &uri_sem) else {
999 return Vec::<Diagnostic>::new();
1000 };
1001 let source = open_files_sem.text(&uri_sem).unwrap_or_default();
1002 let mut out = Vec::new();
1003 if let Some(issues) = docs_sem.get_semantic_issues_salsa(&uri_sem) {
1004 out.extend(crate::semantic_diagnostics::issues_to_diagnostics(
1005 &issues,
1006 &uri_sem,
1007 &diag_cfg_sem,
1008 ));
1009 }
1010 out.extend(duplicate_declaration_diagnostics(
1011 &source,
1012 &d,
1013 &diag_cfg_sem,
1014 ));
1015 out
1016 })
1017 .await
1018 .unwrap_or_default();
1019
1020 let mut all_diags = diagnostics;
1021 all_diags.extend(extra);
1022 client.publish_diagnostics(uri, all_diags, None).await;
1023 }
1024 });
1025 }
1026
1027 async fn did_close(&self, params: DidCloseTextDocumentParams) {
1028 let uri = params.text_document.uri;
1029 self.close_open_file(&uri);
1030 self.client.publish_diagnostics(uri, vec![], None).await;
1032 }
1033
1034 async fn will_save(&self, _params: WillSaveTextDocumentParams) {}
1035
1036 async fn will_save_wait_until(
1037 &self,
1038 params: WillSaveTextDocumentParams,
1039 ) -> Result<Option<Vec<TextEdit>>> {
1040 let source = self
1041 .get_open_text(¶ms.text_document.uri)
1042 .unwrap_or_default();
1043 Ok(format_document(&source))
1044 }
1045
1046 async fn did_save(&self, params: DidSaveTextDocumentParams) {
1047 let uri = params.text_document.uri;
1048 let source = self.get_open_text(&uri).unwrap_or_default();
1051 let doc = self.get_doc(&uri);
1052 if let Some(ref d) = doc {
1053 let diag_cfg = self.config.read().unwrap().diagnostics.clone();
1054 let parse_diags = self.get_parse_diagnostics(&uri).unwrap_or_default();
1055 let dup_diags = duplicate_declaration_diagnostics(&source, d, &diag_cfg);
1056 let mut all = parse_diags;
1057 all.extend(dup_diags);
1058 self.client.publish_diagnostics(uri, all, None).await;
1059 }
1060 }
1061
1062 async fn did_change_watched_files(&self, params: DidChangeWatchedFilesParams) {
1063 for change in params.changes {
1064 match change.typ {
1065 FileChangeType::CREATED | FileChangeType::CHANGED => {
1066 if let Ok(path) = change.uri.to_file_path()
1067 && let Ok(text) = tokio::fs::read_to_string(&path).await
1068 {
1069 let (doc, diags) = parse_document(&text);
1074 self.index_from_doc_if_not_open(change.uri.clone(), &doc, diags);
1075 }
1076 }
1077 FileChangeType::DELETED => {
1078 self.docs.remove(&change.uri);
1079 }
1080 _ => {}
1081 }
1082 }
1083 send_refresh_requests(&self.client).await;
1085 }
1086
1087 async fn completion(&self, params: CompletionParams) -> Result<Option<CompletionResponse>> {
1088 let uri = ¶ms.text_document_position.text_document.uri;
1089 let position = params.text_document_position.position;
1090 let source = self.get_open_text(uri).unwrap_or_default();
1091 let doc = match self.get_doc(uri) {
1093 Some(d) => d,
1094 None => return Ok(Some(CompletionResponse::Array(vec![]))),
1095 };
1096 let other_with_returns = self.docs.other_docs_with_returns(uri, &self.open_urls());
1097 let other_docs: Vec<Arc<ParsedDoc>> = other_with_returns
1098 .iter()
1099 .map(|(_, d, _)| d.clone())
1100 .collect();
1101 let other_returns: Vec<Arc<crate::ast::MethodReturnsMap>> = other_with_returns
1102 .iter()
1103 .map(|(_, _, r)| r.clone())
1104 .collect();
1105 let doc_returns = self.docs.get_method_returns_salsa(uri);
1106 let trigger = params
1107 .context
1108 .as_ref()
1109 .and_then(|c| c.trigger_character.as_deref());
1110 let meta_guard = self.meta.read().unwrap();
1111 let meta_opt = if meta_guard.is_empty() {
1112 None
1113 } else {
1114 Some(&*meta_guard)
1115 };
1116 let imports = self.file_imports(uri);
1117 let ctx = CompletionCtx {
1118 source: Some(&source),
1119 position: Some(position),
1120 meta: meta_opt,
1121 doc_uri: Some(uri),
1122 file_imports: Some(&imports),
1123 doc_returns: doc_returns.as_deref(),
1124 other_returns: Some(&other_returns),
1125 };
1126 Ok(Some(CompletionResponse::Array(filtered_completions_at(
1127 &doc,
1128 &other_docs,
1129 trigger,
1130 &ctx,
1131 ))))
1132 }
1133
1134 async fn completion_resolve(&self, mut item: CompletionItem) -> Result<CompletionItem> {
1135 if item.documentation.is_some() && item.detail.is_some() {
1136 return Ok(item);
1137 }
1138 let name = item.label.trim_end_matches(':');
1140 let all_indexes = self.docs.all_indexes();
1141 if item.detail.is_none()
1142 && let Some(sig) = signature_for_symbol_from_index(name, &all_indexes)
1143 {
1144 item.detail = Some(sig);
1145 }
1146 if item.documentation.is_none()
1147 && let Some(md) = docs_for_symbol_from_index(name, &all_indexes)
1148 {
1149 item.documentation = Some(Documentation::MarkupContent(MarkupContent {
1150 kind: MarkupKind::Markdown,
1151 value: md,
1152 }));
1153 }
1154 Ok(item)
1155 }
1156
1157 async fn goto_definition(
1158 &self,
1159 params: GotoDefinitionParams,
1160 ) -> Result<Option<GotoDefinitionResponse>> {
1161 let uri = ¶ms.text_document_position_params.text_document.uri;
1162 let position = params.text_document_position_params.position;
1163 let source = self.get_open_text(uri).unwrap_or_default();
1164 let doc = match self.get_doc(uri) {
1165 Some(d) => d,
1166 None => return Ok(None),
1167 };
1168 let empty_other_docs: Vec<(Url, Arc<ParsedDoc>)> = vec![];
1170 if let Some(loc) = goto_definition(uri, &source, &doc, &empty_other_docs, position) {
1171 return Ok(Some(GotoDefinitionResponse::Scalar(loc)));
1172 }
1173 let other_indexes = self.docs.other_indexes(uri);
1175 if let Some(word) = crate::util::word_at(&source, position)
1176 && let Some(loc) = find_in_indexes(&word, &other_indexes)
1177 {
1178 return Ok(Some(GotoDefinitionResponse::Scalar(loc)));
1179 }
1180
1181 if let Some(word) = word_at(&source, position)
1183 && word.contains('\\')
1184 && let Some(loc) = self.psr4_goto(&word).await
1185 {
1186 return Ok(Some(GotoDefinitionResponse::Scalar(loc)));
1187 }
1188
1189 Ok(None)
1190 }
1191
1192 async fn references(&self, params: ReferenceParams) -> Result<Option<Vec<Location>>> {
1193 let uri = ¶ms.text_document_position.text_document.uri;
1194 let position = params.text_document_position.position;
1195 let source = self.get_open_text(uri).unwrap_or_default();
1196 let word = match word_at(&source, position) {
1197 Some(w) => w,
1198 None => return Ok(None),
1199 };
1200 if word == "__construct"
1207 && let Some(doc) = self.get_doc(uri)
1208 && let Some(class_name) =
1209 class_name_at_construct_decl(doc.source(), &doc.program().stmts, position)
1210 {
1211 let all_docs = self.docs.all_docs_for_scan();
1212 let include_declaration = params.context.include_declaration;
1213 let short_name = class_name
1219 .rsplit('\\')
1220 .next()
1221 .unwrap_or(class_name.as_str())
1222 .to_owned();
1223 let class_fqn = if class_name.contains('\\') {
1224 Some(class_name.as_str())
1225 } else {
1226 None
1227 };
1228 let mut locations = find_constructor_references(&short_name, &all_docs, class_fqn);
1232 if include_declaration {
1233 let end = Position {
1239 line: position.line,
1240 character: position.character + "__construct".len() as u32,
1241 };
1242 locations.push(Location {
1243 uri: uri.clone(),
1244 range: Range {
1245 start: position,
1246 end,
1247 },
1248 });
1249 }
1250 return Ok(if locations.is_empty() {
1251 None
1252 } else {
1253 Some(locations)
1254 });
1255 }
1256
1257 let doc_opt = self.get_doc(uri);
1258 let (word, kind) = if let Some(doc) = &doc_opt
1262 && let Some(prop_name) =
1263 promoted_property_at_cursor(doc.source(), &doc.program().stmts, position)
1264 {
1265 (prop_name, Some(SymbolKind::Property))
1266 } else {
1267 let k = if let Some(doc) = &doc_opt
1268 && cursor_is_on_method_decl(doc.source(), &doc.program().stmts, position)
1269 {
1270 Some(SymbolKind::Method)
1271 } else {
1272 symbol_kind_at(&source, position, &word)
1273 };
1274 (word, k)
1275 };
1276 let all_docs = self.docs.all_docs_for_scan();
1277 let include_declaration = params.context.include_declaration;
1278
1279 let target_fqn: Option<String> = doc_opt.as_ref().and_then(|doc| {
1284 let imports = self.file_imports(uri);
1285 match kind {
1286 Some(SymbolKind::Function) | Some(SymbolKind::Class) => {
1287 let resolved = crate::moniker::resolve_fqn(doc, &word, &imports);
1288 if resolved.contains('\\') {
1289 Some(resolved)
1290 } else {
1291 None
1292 }
1293 }
1294 Some(SymbolKind::Method) => {
1295 let short_owner =
1297 crate::type_map::enclosing_class_at(doc.source(), doc, position)?;
1298 Some(crate::moniker::resolve_fqn(doc, &short_owner, &imports))
1300 }
1301 _ => None,
1302 }
1303 });
1304
1305 let locations = {
1310 let cb = self.codebase();
1311 let docs = Arc::clone(&self.docs);
1312 let lookup = move |key: &str| docs.get_symbol_refs_salsa(key);
1313 find_references_codebase_with_target(
1314 &word,
1315 &all_docs,
1316 include_declaration,
1317 kind,
1318 target_fqn.as_deref(),
1319 &cb,
1320 &lookup,
1321 )
1322 .unwrap_or_else(|| match target_fqn.as_deref() {
1323 Some(t) => {
1324 find_references_with_target(&word, &all_docs, include_declaration, kind, t)
1325 }
1326 None => find_references(&word, &all_docs, include_declaration, kind),
1327 })
1328 };
1329
1330 Ok(if locations.is_empty() {
1331 None
1332 } else {
1333 Some(locations)
1334 })
1335 }
1336
1337 async fn prepare_rename(
1338 &self,
1339 params: TextDocumentPositionParams,
1340 ) -> Result<Option<PrepareRenameResponse>> {
1341 let uri = ¶ms.text_document.uri;
1342 let source = self.get_open_text(uri).unwrap_or_default();
1343 Ok(prepare_rename(&source, params.position).map(PrepareRenameResponse::Range))
1344 }
1345
1346 async fn rename(&self, params: RenameParams) -> Result<Option<WorkspaceEdit>> {
1347 let uri = ¶ms.text_document_position.text_document.uri;
1348 let position = params.text_document_position.position;
1349 let source = self.get_open_text(uri).unwrap_or_default();
1350 let word = match word_at(&source, position) {
1351 Some(w) => w,
1352 None => return Ok(None),
1353 };
1354 if word.starts_with('$') {
1355 let doc = match self.get_doc(uri) {
1356 Some(d) => d,
1357 None => return Ok(None),
1358 };
1359 Ok(Some(rename_variable(
1360 &word,
1361 ¶ms.new_name,
1362 uri,
1363 &doc,
1364 position,
1365 )))
1366 } else if is_after_arrow(&source, position) {
1367 let all_docs = self.docs.all_docs_for_scan();
1368 Ok(Some(rename_property(&word, ¶ms.new_name, &all_docs)))
1369 } else {
1370 let all_docs = self.docs.all_docs_for_scan();
1371 Ok(Some(rename(&word, ¶ms.new_name, &all_docs)))
1372 }
1373 }
1374
1375 async fn signature_help(&self, params: SignatureHelpParams) -> Result<Option<SignatureHelp>> {
1376 let uri = ¶ms.text_document_position_params.text_document.uri;
1377 let position = params.text_document_position_params.position;
1378 let source = self.get_open_text(uri).unwrap_or_default();
1379 let doc = match self.get_doc(uri) {
1380 Some(d) => d,
1381 None => return Ok(None),
1382 };
1383 Ok(signature_help(&source, &doc, position))
1384 }
1385
1386 async fn hover(&self, params: HoverParams) -> Result<Option<Hover>> {
1387 let uri = ¶ms.text_document_position_params.text_document.uri;
1388 let position = params.text_document_position_params.position;
1389 let source = self.get_open_text(uri).unwrap_or_default();
1390 let doc = match self.get_doc(uri) {
1391 Some(d) => d,
1392 None => return Ok(None),
1393 };
1394 let doc_returns = self
1395 .docs
1396 .get_method_returns_salsa(uri)
1397 .unwrap_or_else(|| std::sync::Arc::new(Default::default()));
1398 let other_docs = self.docs.other_docs_with_returns(uri, &self.open_urls());
1399 let result = hover_info(&source, &doc, &doc_returns, position, &other_docs);
1400 if result.is_some() {
1401 return Ok(result);
1402 }
1403 let all_indexes = self.docs.all_indexes();
1407 if let Some(word) = crate::util::word_at(&source, position)
1408 && let Some(h) = class_hover_from_index(&word, &all_indexes)
1409 {
1410 return Ok(Some(h));
1411 }
1412 Ok(None)
1413 }
1414
1415 async fn document_symbol(
1416 &self,
1417 params: DocumentSymbolParams,
1418 ) -> Result<Option<DocumentSymbolResponse>> {
1419 let uri = ¶ms.text_document.uri;
1420 let doc = match self.get_doc(uri) {
1421 Some(d) => d,
1422 None => return Ok(None),
1423 };
1424 Ok(Some(DocumentSymbolResponse::Nested(document_symbols(
1425 doc.source(),
1426 &doc,
1427 ))))
1428 }
1429
1430 async fn folding_range(&self, params: FoldingRangeParams) -> Result<Option<Vec<FoldingRange>>> {
1431 let uri = ¶ms.text_document.uri;
1432 let doc = match self.get_doc(uri) {
1433 Some(d) => d,
1434 None => return Ok(None),
1435 };
1436 let ranges = folding_ranges(doc.source(), &doc);
1437 Ok(if ranges.is_empty() {
1438 None
1439 } else {
1440 Some(ranges)
1441 })
1442 }
1443
1444 async fn inlay_hint(&self, params: InlayHintParams) -> Result<Option<Vec<InlayHint>>> {
1445 let uri = ¶ms.text_document.uri;
1446 let doc = match self.get_doc(uri) {
1447 Some(d) => d,
1448 None => return Ok(None),
1449 };
1450 let doc_returns = self.docs.get_method_returns_salsa(uri);
1451 Ok(Some(inlay_hints(
1452 doc.source(),
1453 &doc,
1454 doc_returns.as_deref(),
1455 params.range,
1456 )))
1457 }
1458
1459 async fn inlay_hint_resolve(&self, mut item: InlayHint) -> Result<InlayHint> {
1460 if item.tooltip.is_some() {
1461 return Ok(item);
1462 }
1463 let func_name = item
1464 .data
1465 .as_ref()
1466 .and_then(|d| d.get("php_lsp_fn"))
1467 .and_then(|v| v.as_str())
1468 .map(str::to_string);
1469 if let Some(name) = func_name {
1470 let all_indexes = self.docs.all_indexes();
1471 if let Some(md) = docs_for_symbol_from_index(&name, &all_indexes) {
1472 item.tooltip = Some(InlayHintTooltip::MarkupContent(MarkupContent {
1473 kind: MarkupKind::Markdown,
1474 value: md,
1475 }));
1476 }
1477 }
1478 Ok(item)
1479 }
1480
1481 async fn symbol(
1482 &self,
1483 params: WorkspaceSymbolParams,
1484 ) -> Result<Option<Vec<SymbolInformation>>> {
1485 let wi = self.docs.get_workspace_index_salsa();
1489 let results = workspace_symbols_from_workspace(¶ms.query, &wi);
1490 Ok(if results.is_empty() {
1491 None
1492 } else {
1493 Some(results)
1494 })
1495 }
1496
1497 async fn symbol_resolve(&self, params: WorkspaceSymbol) -> Result<WorkspaceSymbol> {
1498 let docs = self.docs.docs_for(&self.open_urls());
1500 Ok(resolve_workspace_symbol(params, &docs))
1501 }
1502
1503 async fn semantic_tokens_full(
1504 &self,
1505 params: SemanticTokensParams,
1506 ) -> Result<Option<SemanticTokensResult>> {
1507 let uri = ¶ms.text_document.uri;
1508 let doc = match self.get_doc(uri) {
1509 Some(d) => d,
1510 None => {
1511 return Ok(Some(SemanticTokensResult::Tokens(SemanticTokens {
1512 result_id: None,
1513 data: vec![],
1514 })));
1515 }
1516 };
1517 let tokens = semantic_tokens(doc.source(), &doc);
1518 let result_id = token_hash(&tokens);
1519 self.docs
1520 .store_token_cache(uri, result_id.clone(), tokens.clone());
1521 Ok(Some(SemanticTokensResult::Tokens(SemanticTokens {
1522 result_id: Some(result_id),
1523 data: tokens,
1524 })))
1525 }
1526
1527 async fn semantic_tokens_range(
1528 &self,
1529 params: SemanticTokensRangeParams,
1530 ) -> Result<Option<SemanticTokensRangeResult>> {
1531 let uri = ¶ms.text_document.uri;
1532 let doc = match self.get_doc(uri) {
1533 Some(d) => d,
1534 None => {
1535 return Ok(Some(SemanticTokensRangeResult::Tokens(SemanticTokens {
1536 result_id: None,
1537 data: vec![],
1538 })));
1539 }
1540 };
1541 let tokens = semantic_tokens_range(doc.source(), &doc, params.range);
1542 Ok(Some(SemanticTokensRangeResult::Tokens(SemanticTokens {
1543 result_id: None,
1544 data: tokens,
1545 })))
1546 }
1547
1548 async fn semantic_tokens_full_delta(
1549 &self,
1550 params: SemanticTokensDeltaParams,
1551 ) -> Result<Option<SemanticTokensFullDeltaResult>> {
1552 let uri = ¶ms.text_document.uri;
1553 let doc = match self.get_doc(uri) {
1554 Some(d) => d,
1555 None => return Ok(None),
1556 };
1557
1558 let new_tokens = semantic_tokens(doc.source(), &doc);
1559 let new_result_id = token_hash(&new_tokens);
1560 let prev_id = ¶ms.previous_result_id;
1561
1562 let result = match self.docs.get_token_cache(uri, prev_id) {
1563 Some(old_tokens) => {
1564 let edits = compute_token_delta(&old_tokens, &new_tokens);
1565 SemanticTokensFullDeltaResult::TokensDelta(SemanticTokensDelta {
1566 result_id: Some(new_result_id.clone()),
1567 edits,
1568 })
1569 }
1570 None => SemanticTokensFullDeltaResult::Tokens(SemanticTokens {
1572 result_id: Some(new_result_id.clone()),
1573 data: new_tokens.clone(),
1574 }),
1575 };
1576
1577 self.docs.store_token_cache(uri, new_result_id, new_tokens);
1578 Ok(Some(result))
1579 }
1580
1581 async fn selection_range(
1582 &self,
1583 params: SelectionRangeParams,
1584 ) -> Result<Option<Vec<SelectionRange>>> {
1585 let uri = ¶ms.text_document.uri;
1586 let doc = match self.get_doc(uri) {
1587 Some(d) => d,
1588 None => return Ok(None),
1589 };
1590 let ranges = selection_ranges(doc.source(), &doc, ¶ms.positions);
1591 Ok(if ranges.is_empty() {
1592 None
1593 } else {
1594 Some(ranges)
1595 })
1596 }
1597
1598 async fn prepare_call_hierarchy(
1599 &self,
1600 params: CallHierarchyPrepareParams,
1601 ) -> Result<Option<Vec<CallHierarchyItem>>> {
1602 let uri = ¶ms.text_document_position_params.text_document.uri;
1603 let position = params.text_document_position_params.position;
1604 let source = self.get_open_text(uri).unwrap_or_default();
1605 let word = match word_at(&source, position) {
1606 Some(w) => w,
1607 None => return Ok(None),
1608 };
1609 let all_docs = self.docs.all_docs_for_scan();
1610 Ok(prepare_call_hierarchy(&word, &all_docs).map(|item| vec![item]))
1611 }
1612
1613 async fn incoming_calls(
1614 &self,
1615 params: CallHierarchyIncomingCallsParams,
1616 ) -> Result<Option<Vec<CallHierarchyIncomingCall>>> {
1617 let all_docs = self.docs.all_docs_for_scan();
1618 let calls = incoming_calls(¶ms.item, &all_docs);
1619 Ok(if calls.is_empty() { None } else { Some(calls) })
1620 }
1621
1622 async fn outgoing_calls(
1623 &self,
1624 params: CallHierarchyOutgoingCallsParams,
1625 ) -> Result<Option<Vec<CallHierarchyOutgoingCall>>> {
1626 let all_docs = self.docs.all_docs_for_scan();
1627 let calls = outgoing_calls(¶ms.item, &all_docs);
1628 Ok(if calls.is_empty() { None } else { Some(calls) })
1629 }
1630
1631 async fn document_highlight(
1632 &self,
1633 params: DocumentHighlightParams,
1634 ) -> Result<Option<Vec<DocumentHighlight>>> {
1635 let uri = ¶ms.text_document_position_params.text_document.uri;
1636 let position = params.text_document_position_params.position;
1637 let source = self.get_open_text(uri).unwrap_or_default();
1638 let doc = match self.get_doc(uri) {
1639 Some(d) => d,
1640 None => return Ok(None),
1641 };
1642 let highlights = document_highlights(&source, &doc, position);
1643 Ok(if highlights.is_empty() {
1644 None
1645 } else {
1646 Some(highlights)
1647 })
1648 }
1649
1650 async fn linked_editing_range(
1651 &self,
1652 params: LinkedEditingRangeParams,
1653 ) -> Result<Option<LinkedEditingRanges>> {
1654 let uri = ¶ms.text_document_position_params.text_document.uri;
1655 let position = params.text_document_position_params.position;
1656 let source = self.get_open_text(uri).unwrap_or_default();
1657 let doc = match self.get_doc(uri) {
1658 Some(d) => d,
1659 None => return Ok(None),
1660 };
1661 let highlights = document_highlights(&source, &doc, position);
1663 if highlights.is_empty() {
1664 return Ok(None);
1665 }
1666 let ranges: Vec<Range> = highlights.into_iter().map(|h| h.range).collect();
1667 Ok(Some(LinkedEditingRanges {
1668 ranges,
1669 word_pattern: Some(r"[$a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*".to_string()),
1671 }))
1672 }
1673
1674 async fn goto_implementation(
1675 &self,
1676 params: tower_lsp::lsp_types::request::GotoImplementationParams,
1677 ) -> Result<Option<tower_lsp::lsp_types::request::GotoImplementationResponse>> {
1678 let uri = ¶ms.text_document_position_params.text_document.uri;
1679 let position = params.text_document_position_params.position;
1680 let source = self.get_open_text(uri).unwrap_or_default();
1681 let imports = self.file_imports(uri);
1682 let word = crate::util::word_at(&source, position).unwrap_or_default();
1683 let fqn = imports.get(&word).map(|s| s.as_str());
1684 let open_docs = self.docs.docs_for(&self.open_urls());
1686 let mut locs = find_implementations(&word, fqn, &open_docs);
1687 if locs.is_empty() {
1688 let wi = self.docs.get_workspace_index_salsa();
1691 locs = find_implementations_from_workspace(&word, fqn, &wi);
1692 }
1693 if locs.is_empty() {
1694 Ok(None)
1695 } else {
1696 Ok(Some(GotoDefinitionResponse::Array(locs)))
1697 }
1698 }
1699
1700 async fn goto_declaration(
1701 &self,
1702 params: tower_lsp::lsp_types::request::GotoDeclarationParams,
1703 ) -> Result<Option<tower_lsp::lsp_types::request::GotoDeclarationResponse>> {
1704 let uri = ¶ms.text_document_position_params.text_document.uri;
1705 let position = params.text_document_position_params.position;
1706 let source = self.get_open_text(uri).unwrap_or_default();
1707 let open_docs = self.docs.docs_for(&self.open_urls());
1709 if let Some(loc) = goto_declaration(&source, &open_docs, position) {
1710 return Ok(Some(GotoDefinitionResponse::Scalar(loc)));
1711 }
1712 let all_indexes = self.docs.all_indexes();
1714 Ok(goto_declaration_from_index(&source, &all_indexes, position)
1715 .map(GotoDefinitionResponse::Scalar))
1716 }
1717
1718 async fn goto_type_definition(
1719 &self,
1720 params: tower_lsp::lsp_types::request::GotoTypeDefinitionParams,
1721 ) -> Result<Option<tower_lsp::lsp_types::request::GotoTypeDefinitionResponse>> {
1722 let uri = ¶ms.text_document_position_params.text_document.uri;
1723 let position = params.text_document_position_params.position;
1724 let source = self.get_open_text(uri).unwrap_or_default();
1725 let doc = match self.get_doc(uri) {
1726 Some(d) => d,
1727 None => return Ok(None),
1728 };
1729 let doc_returns = self.docs.get_method_returns_salsa(uri);
1730 let open_docs = self.docs.docs_for(&self.open_urls());
1732 if let Some(loc) =
1733 goto_type_definition(&source, &doc, doc_returns.as_deref(), &open_docs, position)
1734 {
1735 return Ok(Some(GotoDefinitionResponse::Scalar(loc)));
1736 }
1737 let all_indexes = self.docs.all_indexes();
1739 Ok(goto_type_definition_from_index(
1740 &source,
1741 &doc,
1742 doc_returns.as_deref(),
1743 &all_indexes,
1744 position,
1745 )
1746 .map(GotoDefinitionResponse::Scalar))
1747 }
1748
1749 async fn prepare_type_hierarchy(
1750 &self,
1751 params: TypeHierarchyPrepareParams,
1752 ) -> Result<Option<Vec<TypeHierarchyItem>>> {
1753 let uri = ¶ms.text_document_position_params.text_document.uri;
1754 let position = params.text_document_position_params.position;
1755 let source = self.get_open_text(uri).unwrap_or_default();
1756 let wi = self.docs.get_workspace_index_salsa();
1758 Ok(prepare_type_hierarchy_from_workspace(&source, &wi, position).map(|item| vec![item]))
1759 }
1760
1761 async fn supertypes(
1762 &self,
1763 params: TypeHierarchySupertypesParams,
1764 ) -> Result<Option<Vec<TypeHierarchyItem>>> {
1765 let wi = self.docs.get_workspace_index_salsa();
1767 let result = supertypes_of_from_workspace(¶ms.item, &wi);
1768 Ok(if result.is_empty() {
1769 None
1770 } else {
1771 Some(result)
1772 })
1773 }
1774
1775 async fn subtypes(
1776 &self,
1777 params: TypeHierarchySubtypesParams,
1778 ) -> Result<Option<Vec<TypeHierarchyItem>>> {
1779 let wi = self.docs.get_workspace_index_salsa();
1781 let result = subtypes_of_from_workspace(¶ms.item, &wi);
1782 Ok(if result.is_empty() {
1783 None
1784 } else {
1785 Some(result)
1786 })
1787 }
1788
1789 async fn code_lens(&self, params: CodeLensParams) -> Result<Option<Vec<CodeLens>>> {
1790 let uri = ¶ms.text_document.uri;
1791 let doc = match self.get_doc(uri) {
1792 Some(d) => d,
1793 None => return Ok(None),
1794 };
1795 let all_docs = self.docs.all_docs_for_scan();
1796 let lenses = code_lenses(uri, &doc, &all_docs);
1797 Ok(if lenses.is_empty() {
1798 None
1799 } else {
1800 Some(lenses)
1801 })
1802 }
1803
1804 async fn code_lens_resolve(&self, params: CodeLens) -> Result<CodeLens> {
1805 Ok(params)
1807 }
1808
1809 async fn document_link(&self, params: DocumentLinkParams) -> Result<Option<Vec<DocumentLink>>> {
1810 let uri = ¶ms.text_document.uri;
1811 let doc = match self.get_doc(uri) {
1812 Some(d) => d,
1813 None => return Ok(None),
1814 };
1815 let links = document_links(uri, &doc, doc.source());
1816 Ok(if links.is_empty() { None } else { Some(links) })
1817 }
1818
1819 async fn document_link_resolve(&self, params: DocumentLink) -> Result<DocumentLink> {
1820 Ok(params)
1822 }
1823
1824 async fn formatting(&self, params: DocumentFormattingParams) -> Result<Option<Vec<TextEdit>>> {
1825 let uri = ¶ms.text_document.uri;
1826 let source = self.get_open_text(uri).unwrap_or_default();
1827 Ok(format_document(&source))
1828 }
1829
1830 async fn range_formatting(
1831 &self,
1832 params: DocumentRangeFormattingParams,
1833 ) -> Result<Option<Vec<TextEdit>>> {
1834 let uri = ¶ms.text_document.uri;
1835 let source = self.get_open_text(uri).unwrap_or_default();
1836 Ok(format_range(&source, params.range))
1837 }
1838
1839 async fn on_type_formatting(
1840 &self,
1841 params: DocumentOnTypeFormattingParams,
1842 ) -> Result<Option<Vec<TextEdit>>> {
1843 let uri = ¶ms.text_document_position.text_document.uri;
1844 let source = self.get_open_text(uri).unwrap_or_default();
1845 let edits = on_type_format(
1846 &source,
1847 params.text_document_position.position,
1848 ¶ms.ch,
1849 ¶ms.options,
1850 );
1851 Ok(if edits.is_empty() { None } else { Some(edits) })
1852 }
1853
1854 async fn execute_command(
1855 &self,
1856 params: ExecuteCommandParams,
1857 ) -> Result<Option<serde_json::Value>> {
1858 match params.command.as_str() {
1859 "php-lsp.runTest" => {
1860 let file_uri = params
1862 .arguments
1863 .first()
1864 .and_then(|v| v.as_str())
1865 .and_then(|s| Url::parse(s).ok());
1866 let filter = params
1867 .arguments
1868 .get(1)
1869 .and_then(|v| v.as_str())
1870 .unwrap_or("")
1871 .to_string();
1872
1873 let root = self.root_paths.read().unwrap().first().cloned();
1874 let client = self.client.clone();
1875
1876 tokio::spawn(async move {
1877 run_phpunit(&client, &filter, root.as_deref(), file_uri.as_ref()).await;
1878 });
1879
1880 Ok(None)
1881 }
1882 _ => Ok(None),
1883 }
1884 }
1885
1886 async fn will_rename_files(&self, params: RenameFilesParams) -> Result<Option<WorkspaceEdit>> {
1887 let psr4 = self.psr4.read().unwrap();
1888 let all_docs = self.docs.all_docs_for_scan();
1889 let mut merged_changes: std::collections::HashMap<
1890 tower_lsp::lsp_types::Url,
1891 Vec<tower_lsp::lsp_types::TextEdit>,
1892 > = std::collections::HashMap::new();
1893
1894 for file_rename in ¶ms.files {
1895 let old_path = tower_lsp::lsp_types::Url::parse(&file_rename.old_uri)
1896 .ok()
1897 .and_then(|u| u.to_file_path().ok());
1898 let new_path = tower_lsp::lsp_types::Url::parse(&file_rename.new_uri)
1899 .ok()
1900 .and_then(|u| u.to_file_path().ok());
1901
1902 let (Some(old_path), Some(new_path)) = (old_path, new_path) else {
1903 continue;
1904 };
1905
1906 let old_fqn = psr4.file_to_fqn(&old_path);
1907 let new_fqn = psr4.file_to_fqn(&new_path);
1908
1909 let (Some(old_fqn), Some(new_fqn)) = (old_fqn, new_fqn) else {
1910 continue;
1911 };
1912
1913 let edit = use_edits_for_rename(&old_fqn, &new_fqn, &all_docs);
1914 if let Some(changes) = edit.changes {
1915 for (uri, edits) in changes {
1916 merged_changes.entry(uri).or_default().extend(edits);
1917 }
1918 }
1919 }
1920
1921 Ok(if merged_changes.is_empty() {
1922 None
1923 } else {
1924 Some(WorkspaceEdit {
1925 changes: Some(merged_changes),
1926 ..Default::default()
1927 })
1928 })
1929 }
1930
1931 async fn did_rename_files(&self, params: RenameFilesParams) {
1932 for file_rename in ¶ms.files {
1933 if let Ok(old_uri) = tower_lsp::lsp_types::Url::parse(&file_rename.old_uri) {
1935 self.docs.remove(&old_uri);
1936 }
1937 if let Ok(new_uri) = tower_lsp::lsp_types::Url::parse(&file_rename.new_uri)
1939 && let Ok(path) = new_uri.to_file_path()
1940 && let Ok(text) = tokio::fs::read_to_string(&path).await
1941 {
1942 self.index_if_not_open(new_uri, &text);
1943 }
1944 }
1945 }
1946
1947 async fn will_create_files(&self, params: CreateFilesParams) -> Result<Option<WorkspaceEdit>> {
1950 let psr4 = self.psr4.read().unwrap();
1951 let mut changes: std::collections::HashMap<Url, Vec<TextEdit>> =
1952 std::collections::HashMap::new();
1953
1954 for file in ¶ms.files {
1955 let Ok(uri) = Url::parse(&file.uri) else {
1956 continue;
1957 };
1958 if !uri.path().ends_with(".php") {
1961 continue;
1962 }
1963
1964 let stub = if let Ok(path) = uri.to_file_path()
1965 && let Some(fqn) = psr4.file_to_fqn(&path)
1966 {
1967 let (ns, class_name) = match fqn.rfind('\\') {
1968 Some(pos) => (&fqn[..pos], &fqn[pos + 1..]),
1969 None => ("", fqn.as_str()),
1970 };
1971 if ns.is_empty() {
1972 format!("<?php\n\ndeclare(strict_types=1);\n\nclass {class_name}\n{{\n}}\n")
1973 } else {
1974 format!(
1975 "<?php\n\ndeclare(strict_types=1);\n\nnamespace {ns};\n\nclass {class_name}\n{{\n}}\n"
1976 )
1977 }
1978 } else {
1979 "<?php\n\n".to_string()
1980 };
1981
1982 changes.insert(
1983 uri,
1984 vec![TextEdit {
1985 range: Range {
1986 start: Position {
1987 line: 0,
1988 character: 0,
1989 },
1990 end: Position {
1991 line: 0,
1992 character: 0,
1993 },
1994 },
1995 new_text: stub,
1996 }],
1997 );
1998 }
1999
2000 Ok(if changes.is_empty() {
2001 None
2002 } else {
2003 Some(WorkspaceEdit {
2004 changes: Some(changes),
2005 ..Default::default()
2006 })
2007 })
2008 }
2009
2010 async fn did_create_files(&self, params: CreateFilesParams) {
2011 for file in ¶ms.files {
2012 if let Ok(uri) = Url::parse(&file.uri)
2013 && let Ok(path) = uri.to_file_path()
2014 && let Ok(text) = tokio::fs::read_to_string(&path).await
2015 {
2016 self.index_if_not_open(uri, &text);
2017 }
2018 }
2019 send_refresh_requests(&self.client).await;
2020 }
2021
2022 async fn will_delete_files(&self, params: DeleteFilesParams) -> Result<Option<WorkspaceEdit>> {
2027 let psr4 = self.psr4.read().unwrap();
2028 let all_docs = self.docs.all_docs_for_scan();
2029 let mut merged_changes: std::collections::HashMap<Url, Vec<TextEdit>> =
2030 std::collections::HashMap::new();
2031
2032 for file in ¶ms.files {
2033 let path = Url::parse(&file.uri)
2034 .ok()
2035 .and_then(|u| u.to_file_path().ok());
2036 let Some(path) = path else { continue };
2037 let Some(fqn) = psr4.file_to_fqn(&path) else {
2038 continue;
2039 };
2040
2041 let edit = use_edits_for_delete(&fqn, &all_docs);
2042 if let Some(changes) = edit.changes {
2043 for (uri, edits) in changes {
2044 merged_changes.entry(uri).or_default().extend(edits);
2045 }
2046 }
2047 }
2048
2049 Ok(if merged_changes.is_empty() {
2050 None
2051 } else {
2052 Some(WorkspaceEdit {
2053 changes: Some(merged_changes),
2054 ..Default::default()
2055 })
2056 })
2057 }
2058
2059 async fn did_delete_files(&self, params: DeleteFilesParams) {
2060 for file in ¶ms.files {
2061 if let Ok(uri) = Url::parse(&file.uri) {
2062 self.docs.remove(&uri);
2063 self.client.publish_diagnostics(uri, vec![], None).await;
2065 }
2066 }
2067 send_refresh_requests(&self.client).await;
2068 }
2069
2070 async fn moniker(&self, params: MonikerParams) -> Result<Option<Vec<Moniker>>> {
2073 let uri = ¶ms.text_document_position_params.text_document.uri;
2074 let position = params.text_document_position_params.position;
2075 let source = self.get_open_text(uri).unwrap_or_default();
2076 let doc = match self.get_doc(uri) {
2077 Some(d) => d,
2078 None => return Ok(None),
2079 };
2080 let imports = self.file_imports(uri);
2081 Ok(moniker_at(&source, &doc, position, &imports).map(|m| vec![m]))
2082 }
2083
2084 async fn inline_value(&self, params: InlineValueParams) -> Result<Option<Vec<InlineValue>>> {
2087 let uri = ¶ms.text_document.uri;
2088 let source = self.get_open_text(uri).unwrap_or_default();
2089 let values = inline_values_in_range(&source, params.range);
2090 Ok(if values.is_empty() {
2091 None
2092 } else {
2093 Some(values)
2094 })
2095 }
2096
2097 async fn diagnostic(
2098 &self,
2099 params: DocumentDiagnosticParams,
2100 ) -> Result<DocumentDiagnosticReportResult> {
2101 let uri = ¶ms.text_document.uri;
2102 let source = self.get_open_text(uri).unwrap_or_default();
2103
2104 let parse_diags = self.get_parse_diagnostics(uri).unwrap_or_default();
2105 let doc = match self.get_doc(uri) {
2106 Some(d) => d,
2107 None => {
2108 return Ok(DocumentDiagnosticReportResult::Report(
2109 DocumentDiagnosticReport::Full(RelatedFullDocumentDiagnosticReport {
2110 related_documents: None,
2111 full_document_diagnostic_report: FullDocumentDiagnosticReport {
2112 result_id: None,
2113 items: parse_diags,
2114 },
2115 }),
2116 ));
2117 }
2118 };
2119 let (diag_cfg, php_version) = {
2120 let cfg = self.config.read().unwrap();
2121 (cfg.diagnostics.clone(), cfg.php_version.clone())
2122 };
2123 let _ = php_version.as_deref();
2124 let docs = Arc::clone(&self.docs);
2126 let uri_owned = uri.clone();
2127 let diag_cfg_sem = diag_cfg.clone();
2128 let sem_diags = tokio::task::spawn_blocking(move || {
2129 docs.get_semantic_issues_salsa(&uri_owned)
2130 .map(|issues| {
2131 crate::semantic_diagnostics::issues_to_diagnostics(
2132 &issues,
2133 &uri_owned,
2134 &diag_cfg_sem,
2135 )
2136 })
2137 .unwrap_or_default()
2138 })
2139 .await
2140 .unwrap_or_default();
2141 let dup_diags = duplicate_declaration_diagnostics(&source, &doc, &diag_cfg);
2142
2143 let mut items = parse_diags;
2144 items.extend(sem_diags);
2145 items.extend(dup_diags);
2146
2147 Ok(DocumentDiagnosticReportResult::Report(
2148 DocumentDiagnosticReport::Full(RelatedFullDocumentDiagnosticReport {
2149 related_documents: None,
2150 full_document_diagnostic_report: FullDocumentDiagnosticReport {
2151 result_id: None,
2152 items,
2153 },
2154 }),
2155 ))
2156 }
2157
2158 async fn workspace_diagnostic(
2159 &self,
2160 _params: WorkspaceDiagnosticParams,
2161 ) -> Result<WorkspaceDiagnosticReportResult> {
2162 let all_parse_diags = self.all_open_files_with_diagnostics();
2163 let (diag_cfg, php_version) = {
2164 let cfg = self.config.read().unwrap();
2165 (cfg.diagnostics.clone(), cfg.php_version.clone())
2166 };
2167
2168 let _ = php_version.as_deref();
2176 let docs = Arc::clone(&self.docs);
2177 let diag_cfg_sweep = diag_cfg.clone();
2178 let items = tokio::task::spawn_blocking(move || {
2179 all_parse_diags
2180 .into_iter()
2181 .filter_map(|(uri, parse_diags, version)| {
2182 let doc = docs.get_doc_salsa(&uri)?;
2183
2184 let source = doc.source().to_string();
2185 let sem_diags = docs
2186 .get_semantic_issues_salsa(&uri)
2187 .map(|issues| {
2188 crate::semantic_diagnostics::issues_to_diagnostics(
2189 &issues,
2190 &uri,
2191 &diag_cfg_sweep,
2192 )
2193 })
2194 .unwrap_or_default();
2195 let dup_diags =
2196 duplicate_declaration_diagnostics(&source, &doc, &diag_cfg_sweep);
2197
2198 let mut all_diags = parse_diags;
2199 all_diags.extend(sem_diags);
2200 all_diags.extend(dup_diags);
2201
2202 Some(WorkspaceDocumentDiagnosticReport::Full(
2203 WorkspaceFullDocumentDiagnosticReport {
2204 uri,
2205 version,
2206 full_document_diagnostic_report: FullDocumentDiagnosticReport {
2207 result_id: None,
2208 items: all_diags,
2209 },
2210 },
2211 ))
2212 })
2213 .collect::<Vec<_>>()
2214 })
2215 .await
2216 .unwrap_or_default();
2217
2218 Ok(WorkspaceDiagnosticReportResult::Report(
2219 WorkspaceDiagnosticReport { items },
2220 ))
2221 }
2222
2223 async fn code_action(&self, params: CodeActionParams) -> Result<Option<CodeActionResponse>> {
2224 let uri = ¶ms.text_document.uri;
2225 let source = self.get_open_text(uri).unwrap_or_default();
2226 let doc = match self.get_doc(uri) {
2227 Some(d) => d,
2228 None => return Ok(None),
2229 };
2230 let other_docs = self.docs.other_docs(uri, &self.open_urls());
2231
2232 let diag_cfg = self.config.read().unwrap().diagnostics.clone();
2239 let docs_sem = Arc::clone(&self.docs);
2240 let uri_sem = uri.clone();
2241 let diag_cfg_sem = diag_cfg.clone();
2242 let sem_diags = tokio::task::spawn_blocking(move || {
2243 docs_sem
2244 .get_semantic_issues_salsa(&uri_sem)
2245 .map(|issues| {
2246 crate::semantic_diagnostics::issues_to_diagnostics(
2247 &issues,
2248 &uri_sem,
2249 &diag_cfg_sem,
2250 )
2251 })
2252 .unwrap_or_default()
2253 })
2254 .await
2255 .unwrap_or_default();
2256
2257 let mut actions: Vec<CodeActionOrCommand> = Vec::new();
2259 for diag in &sem_diags {
2260 if diag.code != Some(NumberOrString::String("UndefinedClass".to_string())) {
2261 continue;
2262 }
2263 if diag.range.start.line < params.range.start.line
2265 || diag.range.start.line > params.range.end.line
2266 {
2267 continue;
2268 }
2269 let class_name = diag
2271 .message
2272 .strip_prefix("Class ")
2273 .and_then(|s| s.strip_suffix(" does not exist"))
2274 .unwrap_or("")
2275 .trim();
2276 if class_name.is_empty() {
2277 continue;
2278 }
2279
2280 for (_other_uri, other_doc) in &other_docs {
2282 if let Some(fqn) = find_fqn_for_class(other_doc, class_name) {
2283 let edit = build_use_import_edit(&source, uri, &fqn);
2284 let action = CodeAction {
2285 title: format!("Add use {fqn}"),
2286 kind: Some(CodeActionKind::QUICKFIX),
2287 edit: Some(edit),
2288 diagnostics: Some(vec![diag.clone()]),
2289 ..Default::default()
2290 };
2291 actions.push(CodeActionOrCommand::CodeAction(action));
2292 break; }
2294 }
2295 }
2296
2297 for tag in DEFERRED_ACTION_TAGS {
2300 actions.extend(defer_actions(
2301 self.generate_deferred_actions(tag, &source, &doc, params.range, uri),
2302 tag,
2303 uri,
2304 params.range,
2305 ));
2306 }
2307
2308 actions.extend(extract_variable_actions(&source, params.range, uri));
2310 actions.extend(extract_method_actions(&source, &doc, params.range, uri));
2311 actions.extend(extract_constant_actions(&source, params.range, uri));
2312 actions.extend(inline_variable_actions(&source, params.range, uri));
2314 if let Some(action) = organize_imports_action(&source, uri) {
2316 actions.push(action);
2317 }
2318
2319 Ok(if actions.is_empty() {
2320 None
2321 } else {
2322 Some(actions)
2323 })
2324 }
2325
2326 async fn code_action_resolve(&self, item: CodeAction) -> Result<CodeAction> {
2327 let data = match &item.data {
2328 Some(d) => d.clone(),
2329 None => return Ok(item),
2330 };
2331 let kind_tag = match data.get("php_lsp_resolve").and_then(|v| v.as_str()) {
2332 Some(k) => k.to_string(),
2333 None => return Ok(item),
2334 };
2335 let uri: Url = match data
2336 .get("uri")
2337 .and_then(|v| v.as_str())
2338 .and_then(|s| Url::parse(s).ok())
2339 {
2340 Some(u) => u,
2341 None => return Ok(item),
2342 };
2343 let range: Range = match data
2344 .get("range")
2345 .and_then(|v| serde_json::from_value(v.clone()).ok())
2346 {
2347 Some(r) => r,
2348 None => return Ok(item),
2349 };
2350
2351 let source = self.get_open_text(&uri).unwrap_or_default();
2352 let doc = match self.get_doc(&uri) {
2353 Some(d) => d,
2354 None => return Ok(item),
2355 };
2356
2357 let candidates = self.generate_deferred_actions(&kind_tag, &source, &doc, range, &uri);
2358
2359 for candidate in candidates {
2361 if let CodeActionOrCommand::CodeAction(ca) = candidate
2362 && ca.title == item.title
2363 {
2364 return Ok(ca);
2365 }
2366 }
2367
2368 Ok(item)
2369 }
2370}
2371
2372fn php_file_op() -> FileOperationRegistrationOptions {
2374 FileOperationRegistrationOptions {
2375 filters: vec![FileOperationFilter {
2376 scheme: Some("file".to_string()),
2377 pattern: FileOperationPattern {
2378 glob: "**/*.php".to_string(),
2379 matches: Some(FileOperationPatternKind::File),
2380 options: None,
2381 },
2382 }],
2383 }
2384}
2385
2386fn defer_actions(
2389 actions: Vec<CodeActionOrCommand>,
2390 kind_tag: &str,
2391 uri: &Url,
2392 range: Range,
2393) -> Vec<CodeActionOrCommand> {
2394 actions
2395 .into_iter()
2396 .map(|a| match a {
2397 CodeActionOrCommand::CodeAction(mut ca) => {
2398 ca.edit = None;
2399 ca.data = Some(serde_json::json!({
2400 "php_lsp_resolve": kind_tag,
2401 "uri": uri.to_string(),
2402 "range": range,
2403 }));
2404 CodeActionOrCommand::CodeAction(ca)
2405 }
2406 other => other,
2407 })
2408 .collect()
2409}
2410
2411fn is_after_arrow(source: &str, position: Position) -> bool {
2414 let line = match source.lines().nth(position.line as usize) {
2415 Some(l) => l,
2416 None => return false,
2417 };
2418 let chars: Vec<char> = line.chars().collect();
2419 let col = position.character as usize;
2420 let mut utf16_col = 0usize;
2422 let mut char_idx = 0usize;
2423 for ch in &chars {
2424 if utf16_col >= col {
2425 break;
2426 }
2427 utf16_col += ch.len_utf16();
2428 char_idx += 1;
2429 }
2430 let is_word = |c: char| c.is_alphanumeric() || c == '_';
2432 while char_idx > 0 && is_word(chars[char_idx - 1]) {
2433 char_idx -= 1;
2434 }
2435 char_idx >= 2 && chars[char_idx - 1] == '>' && chars[char_idx - 2] == '-'
2436}
2437
2438fn symbol_kind_at(source: &str, position: Position, word: &str) -> Option<SymbolKind> {
2449 if word.starts_with('$') {
2450 return None; }
2452 let line = source.lines().nth(position.line as usize)?;
2453 let chars: Vec<char> = line.chars().collect();
2454
2455 let col = position.character as usize;
2457 let mut utf16_col = 0usize;
2458 let mut char_idx = 0usize;
2459 for ch in &chars {
2460 if utf16_col >= col {
2461 break;
2462 }
2463 utf16_col += ch.len_utf16();
2464 char_idx += 1;
2465 }
2466
2467 let is_word_char = |c: char| c.is_alphanumeric() || c == '_';
2469 while char_idx > 0 && is_word_char(chars[char_idx - 1]) {
2470 char_idx -= 1;
2471 }
2472
2473 if char_idx >= 2 && chars[char_idx - 1] == '>' && chars[char_idx - 2] == '-' {
2475 return Some(SymbolKind::Method);
2476 }
2477 if char_idx >= 3
2478 && chars[char_idx - 1] == '>'
2479 && chars[char_idx - 2] == '-'
2480 && chars[char_idx - 3] == '?'
2481 {
2482 return Some(SymbolKind::Method);
2483 }
2484
2485 if char_idx >= 2 && chars[char_idx - 1] == ':' && chars[char_idx - 2] == ':' {
2487 return Some(SymbolKind::Method);
2488 }
2489
2490 if word
2492 .chars()
2493 .next()
2494 .map(|c| c.is_uppercase())
2495 .unwrap_or(false)
2496 {
2497 return Some(SymbolKind::Class);
2498 }
2499
2500 Some(SymbolKind::Function)
2502}
2503
2504fn position_to_offset(source: &str, position: Position) -> Option<u32> {
2507 let mut byte_offset = 0usize;
2508 for (idx, line) in source.split('\n').enumerate() {
2509 if idx as u32 == position.line {
2510 let line_content = line.trim_end_matches('\r');
2512 let mut col = 0u32;
2513 for (byte_idx, ch) in line_content.char_indices() {
2514 if col >= position.character {
2515 return Some((byte_offset + byte_idx) as u32);
2516 }
2517 col += ch.len_utf16() as u32;
2518 }
2519 return Some((byte_offset + line_content.len()) as u32);
2520 }
2521 byte_offset += line.len() + 1; }
2523 None
2524}
2525
2526fn cursor_is_on_method_decl(source: &str, stmts: &[Stmt<'_, '_>], position: Position) -> bool {
2533 let Some(cursor) = position_to_offset(source, position) else {
2534 return false;
2535 };
2536
2537 fn check(source: &str, stmts: &[Stmt<'_, '_>], cursor: u32) -> bool {
2538 for stmt in stmts {
2539 match &stmt.kind {
2540 StmtKind::Class(c) => {
2541 for member in c.members.iter() {
2542 if let ClassMemberKind::Method(m) = &member.kind {
2543 let start = str_offset(source, m.name);
2544 let end = start + m.name.len() as u32;
2545 if cursor >= start && cursor < end {
2546 return true;
2547 }
2548 }
2549 }
2550 }
2551 StmtKind::Interface(i) => {
2552 for member in i.members.iter() {
2553 if let ClassMemberKind::Method(m) = &member.kind {
2554 let start = str_offset(source, m.name);
2555 let end = start + m.name.len() as u32;
2556 if cursor >= start && cursor < end {
2557 return true;
2558 }
2559 }
2560 }
2561 }
2562 StmtKind::Trait(t) => {
2563 for member in t.members.iter() {
2564 if let ClassMemberKind::Method(m) = &member.kind {
2565 let start = str_offset(source, m.name);
2566 let end = start + m.name.len() as u32;
2567 if cursor >= start && cursor < end {
2568 return true;
2569 }
2570 }
2571 }
2572 }
2573 StmtKind::Enum(e) => {
2574 for member in e.members.iter() {
2575 if let EnumMemberKind::Method(m) = &member.kind {
2576 let start = str_offset(source, m.name);
2577 let end = start + m.name.len() as u32;
2578 if cursor >= start && cursor < end {
2579 return true;
2580 }
2581 }
2582 }
2583 }
2584 StmtKind::Namespace(ns) => {
2585 if let NamespaceBody::Braced(inner) = &ns.body
2586 && check(source, inner, cursor)
2587 {
2588 return true;
2589 }
2590 }
2591 _ => {}
2592 }
2593 }
2594 false
2595 }
2596
2597 check(source, stmts, cursor)
2598}
2599
2600fn class_name_at_construct_decl(
2607 source: &str,
2608 stmts: &[Stmt<'_, '_>],
2609 position: Position,
2610) -> Option<String> {
2611 let cursor = position_to_offset(source, position)?;
2612
2613 fn check(source: &str, stmts: &[Stmt<'_, '_>], cursor: u32, ns_prefix: &str) -> Option<String> {
2614 let mut current_ns = ns_prefix.to_owned();
2615 for stmt in stmts {
2616 match &stmt.kind {
2617 StmtKind::Class(c) => {
2618 for member in c.members.iter() {
2619 if let ClassMemberKind::Method(m) = &member.kind
2620 && m.name == "__construct"
2621 {
2622 let start = str_offset(source, m.name);
2623 let end = start + m.name.len() as u32;
2624 if cursor >= start && cursor < end {
2625 let short = c.name?;
2626 return Some(if current_ns.is_empty() {
2627 short.to_owned()
2628 } else {
2629 format!("{}\\{}", current_ns, short)
2630 });
2631 }
2632 }
2633 }
2634 }
2635 StmtKind::Namespace(ns) => {
2636 let ns_name = ns
2637 .name
2638 .as_ref()
2639 .map(|n| n.to_string_repr().to_string())
2640 .unwrap_or_default();
2641 match &ns.body {
2642 NamespaceBody::Braced(inner) => {
2643 if let Some(name) = check(source, inner, cursor, &ns_name) {
2644 return Some(name);
2645 }
2646 }
2647 NamespaceBody::Simple => {
2648 current_ns = ns_name;
2649 }
2650 }
2651 }
2652 _ => {}
2653 }
2654 }
2655 None
2656 }
2657
2658 check(source, stmts, cursor, "")
2659}
2660
2661fn promoted_property_at_cursor(
2669 source: &str,
2670 stmts: &[Stmt<'_, '_>],
2671 position: Position,
2672) -> Option<String> {
2673 let cursor = position_to_offset(source, position)?;
2674
2675 fn check(source: &str, stmts: &[Stmt<'_, '_>], cursor: u32) -> Option<String> {
2676 for stmt in stmts {
2677 match &stmt.kind {
2678 StmtKind::Class(c) => {
2679 for member in c.members.iter() {
2680 if let ClassMemberKind::Method(m) = &member.kind
2681 && m.name == "__construct"
2682 {
2683 for param in m.params.iter() {
2684 if param.visibility.is_none() {
2685 continue;
2686 }
2687 let name_start = str_offset(source, param.name);
2688 let name_end = name_start + param.name.len() as u32;
2689 if cursor >= name_start && cursor < name_end {
2690 return Some(param.name.trim_start_matches('$').to_owned());
2691 }
2692 }
2693 }
2694 }
2695 }
2696 StmtKind::Namespace(ns) => {
2697 if let NamespaceBody::Braced(inner) = &ns.body
2698 && let Some(name) = check(source, inner, cursor)
2699 {
2700 return Some(name);
2701 }
2702 }
2703 _ => {}
2704 }
2705 }
2706 None
2707 }
2708
2709 check(source, stmts, cursor)
2710}
2711
2712const DEFERRED_ACTION_TAGS: &[&str] = &[
2715 "phpdoc",
2716 "implement",
2717 "constructor",
2718 "getters_setters",
2719 "return_type",
2720 "promote",
2721];
2722
2723impl Backend {
2724 fn generate_deferred_actions(
2726 &self,
2727 tag: &str,
2728 source: &str,
2729 doc: &Arc<ParsedDoc>,
2730 range: Range,
2731 uri: &Url,
2732 ) -> Vec<CodeActionOrCommand> {
2733 match tag {
2734 "phpdoc" => phpdoc_actions(uri, doc, source, range),
2735 "implement" => {
2736 let imports = self.file_imports(uri);
2737 implement_missing_actions(
2738 source,
2739 doc,
2740 &self
2741 .docs
2742 .doc_with_others(uri, Arc::clone(doc), &self.open_urls()),
2743 range,
2744 uri,
2745 &imports,
2746 )
2747 }
2748 "constructor" => generate_constructor_actions(source, doc, range, uri),
2749 "getters_setters" => generate_getters_setters_actions(source, doc, range, uri),
2750 "return_type" => add_return_type_actions(source, doc, range, uri),
2751 "promote" => promote_constructor_actions(source, doc, range, uri),
2752 _ => Vec::new(),
2753 }
2754 }
2755
2756 async fn psr4_goto(&self, fqn: &str) -> Option<Location> {
2759 let path = {
2760 let psr4 = self.psr4.read().unwrap();
2761 psr4.resolve(fqn)?
2762 };
2763
2764 let file_uri = Url::from_file_path(&path).ok()?;
2765
2766 if self.docs.get_doc_salsa(&file_uri).is_none() {
2771 let text = tokio::fs::read_to_string(&path).await.ok()?;
2772 self.index_if_not_open(file_uri.clone(), &text);
2773 }
2774
2775 let doc = self.docs.get_doc_salsa(&file_uri)?;
2776
2777 let short_name = fqn.split('\\').next_back()?;
2780 let range = find_declaration_range(doc.source(), &doc, short_name)?;
2781
2782 Some(Location {
2783 uri: file_uri,
2784 range,
2785 })
2786 }
2787}
2788
2789async fn run_phpunit(
2795 client: &Client,
2796 filter: &str,
2797 root: Option<&std::path::Path>,
2798 file_uri: Option<&Url>,
2799) {
2800 let output = tokio::process::Command::new("vendor/bin/phpunit")
2801 .arg("--filter")
2802 .arg(filter)
2803 .current_dir(root.unwrap_or(std::path::Path::new(".")))
2804 .output()
2805 .await;
2806
2807 let (success, message) = match output {
2808 Ok(out) => {
2809 let text = String::from_utf8_lossy(&out.stdout).into_owned()
2810 + &String::from_utf8_lossy(&out.stderr);
2811 let last_line = text
2812 .lines()
2813 .rev()
2814 .find(|l| !l.trim().is_empty())
2815 .unwrap_or("(no output)")
2816 .to_string();
2817 let ok = out.status.success();
2818 let msg = if ok {
2819 format!("✓ {filter}: {last_line}")
2820 } else {
2821 format!("✗ {filter}: {last_line}")
2822 };
2823 (ok, msg)
2824 }
2825 Err(e) => (
2826 false,
2827 format!("php-lsp.runTest: failed to spawn phpunit — {e}"),
2828 ),
2829 };
2830
2831 let msg_type = if success {
2832 MessageType::INFO
2833 } else {
2834 MessageType::ERROR
2835 };
2836 let mut actions = vec![MessageActionItem {
2837 title: "Run Again".to_string(),
2838 properties: Default::default(),
2839 }];
2840 if !success && file_uri.is_some() {
2841 actions.push(MessageActionItem {
2842 title: "Open File".to_string(),
2843 properties: Default::default(),
2844 });
2845 }
2846
2847 let chosen = client
2848 .show_message_request(msg_type, message, Some(actions))
2849 .await;
2850
2851 match chosen {
2852 Ok(Some(ref action)) if action.title == "Run Again" => {
2853 let output2 = tokio::process::Command::new("vendor/bin/phpunit")
2855 .arg("--filter")
2856 .arg(filter)
2857 .current_dir(root.unwrap_or(std::path::Path::new(".")))
2858 .output()
2859 .await;
2860 let msg2 = match output2 {
2861 Ok(out) => {
2862 let text = String::from_utf8_lossy(&out.stdout).into_owned()
2863 + &String::from_utf8_lossy(&out.stderr);
2864 let last_line = text
2865 .lines()
2866 .rev()
2867 .find(|l| !l.trim().is_empty())
2868 .unwrap_or("(no output)")
2869 .to_string();
2870 if out.status.success() {
2871 format!("✓ {filter}: {last_line}")
2872 } else {
2873 format!("✗ {filter}: {last_line}")
2874 }
2875 }
2876 Err(e) => format!("php-lsp.runTest: failed to spawn phpunit — {e}"),
2877 };
2878 client.show_message(MessageType::INFO, msg2).await;
2879 }
2880 Ok(Some(ref action)) if action.title == "Open File" => {
2881 if let Some(uri) = file_uri {
2882 client
2883 .show_document(ShowDocumentParams {
2884 uri: uri.clone(),
2885 external: Some(false),
2886 take_focus: Some(true),
2887 selection: None,
2888 })
2889 .await
2890 .ok();
2891 }
2892 }
2893 _ => {}
2894 }
2895}
2896
2897async fn send_refresh_requests(client: &Client) {
2901 client.send_request::<SemanticTokensRefresh>(()).await.ok();
2902 client.send_request::<CodeLensRefresh>(()).await.ok();
2903 client
2904 .send_request::<InlayHintRefreshRequest>(())
2905 .await
2906 .ok();
2907 client
2908 .send_request::<WorkspaceDiagnosticRefresh>(())
2909 .await
2910 .ok();
2911 client
2912 .send_request::<InlineValueRefreshRequest>(())
2913 .await
2914 .ok();
2915}
2916
2917const MAX_INDEXED_FILES: usize = 50_000;
2920
2921#[tracing::instrument(
2933 skip(docs, open_files, cache, exclude_paths),
2934 fields(root = %root.display())
2935)]
2936async fn scan_workspace(
2937 root: PathBuf,
2938 docs: Arc<DocumentStore>,
2939 open_files: OpenFiles,
2940 cache: Option<crate::cache::WorkspaceCache>,
2941 exclude_paths: &[String],
2942 max_files: usize,
2943) -> usize {
2944 let mut php_files: Vec<PathBuf> = Vec::new();
2946 let mut stack = vec![root];
2947
2948 'walk: while let Some(dir) = stack.pop() {
2949 let mut entries = match tokio::fs::read_dir(&dir).await {
2950 Ok(e) => e,
2951 Err(_) => continue,
2952 };
2953 while let Ok(Some(entry)) = entries.next_entry().await {
2954 let path = entry.path();
2955 let path_str = path.to_string_lossy().replace('\\', "/");
2958 if exclude_paths.iter().any(|pat| {
2960 let p = pat.trim_end_matches('*').trim_end_matches('/');
2961 path_str.contains(p)
2962 }) {
2963 continue;
2964 }
2965 let file_type = match entry.file_type().await {
2966 Ok(ft) => ft,
2967 Err(_) => continue,
2968 };
2969 if file_type.is_dir() {
2970 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
2971 if !name.starts_with('.') {
2973 stack.push(path);
2974 }
2975 } else if file_type.is_file() && path.extension().is_some_and(|e| e == "php") {
2976 php_files.push(path);
2977 if php_files.len() >= max_files {
2978 break 'walk;
2979 }
2980 }
2981 }
2982 }
2983
2984 let parallelism = std::thread::available_parallelism()
2986 .map(|n| n.get())
2987 .unwrap_or(4);
2988 let sem = Arc::new(tokio::sync::Semaphore::new(parallelism));
2989 let count = Arc::new(std::sync::atomic::AtomicUsize::new(0));
2990 let mut set: tokio::task::JoinSet<()> = tokio::task::JoinSet::new();
2991
2992 for path in php_files {
2993 let permit = Arc::clone(&sem).acquire_owned().await.unwrap();
2994 let docs = Arc::clone(&docs);
2995 let open_files = open_files.clone();
2996 let cache = cache.clone();
2997 let count = Arc::clone(&count);
2998 set.spawn(async move {
2999 let _permit = permit;
3000 let Ok(text) = tokio::fs::read_to_string(&path).await else {
3001 return;
3002 };
3003 let Ok(uri) = Url::from_file_path(&path) else {
3004 return;
3005 };
3006 tokio::task::spawn_blocking(move || {
3007 if open_files.contains(&uri) {
3011 return;
3012 }
3013
3014 let cache_key = cache
3021 .as_ref()
3022 .map(|_| crate::cache::WorkspaceCache::key_for(uri.as_str(), &text));
3023 if let (Some(cache), Some(key)) = (cache.as_ref(), cache_key.as_ref())
3024 && let Some(slice) = cache.read::<mir_codebase::storage::StubSlice>(key)
3025 {
3026 docs.mirror_text(&uri, &text);
3027 docs.seed_cached_slice(&uri, Arc::new(slice));
3028 count.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3029 return;
3030 }
3031
3032 let (doc, diags) = parse_document(&text);
3034 docs.index_from_doc(uri.clone(), &doc, diags);
3035 count.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3036
3037 if let (Some(cache), Some(key)) = (cache.as_ref(), cache_key.as_ref())
3045 && let Some(slice) = docs.slice_for(&uri)
3046 {
3047 let _ = cache.write(key, &*slice);
3048 }
3049 })
3050 .await
3051 .ok();
3052 });
3053 }
3054
3055 while set.join_next().await.is_some() {}
3056
3057 count.load(std::sync::atomic::Ordering::Relaxed)
3058}
3059
3060#[cfg(test)]
3061mod tests {
3062 use super::*;
3063 use crate::use_import::find_use_insert_line;
3064 use tower_lsp::lsp_types::{Position, Range, Url};
3065
3066 #[test]
3068 fn diagnostics_config_default_is_disabled() {
3069 let cfg = DiagnosticsConfig::default();
3070 assert!(!cfg.enabled);
3071 assert!(cfg.undefined_variables);
3074 assert!(cfg.undefined_functions);
3075 assert!(cfg.undefined_classes);
3076 assert!(cfg.arity_errors);
3077 assert!(cfg.type_errors);
3078 assert!(cfg.deprecated_calls);
3079 assert!(cfg.duplicate_declarations);
3080 }
3081
3082 #[test]
3083 fn diagnostics_config_from_empty_object_is_disabled() {
3084 let cfg = DiagnosticsConfig::from_value(&serde_json::json!({}));
3085 assert!(!cfg.enabled);
3086 assert!(cfg.undefined_variables);
3087 }
3088
3089 #[test]
3090 fn diagnostics_config_from_non_object_uses_defaults() {
3091 let cfg = DiagnosticsConfig::from_value(&serde_json::json!(null));
3092 assert!(!cfg.enabled);
3093 }
3094
3095 #[test]
3096 fn diagnostics_config_can_disable_individual_flags() {
3097 let cfg = DiagnosticsConfig::from_value(&serde_json::json!({
3098 "enabled": true,
3099 "undefinedVariables": false,
3100 "undefinedFunctions": false,
3101 "undefinedClasses": true,
3102 "arityErrors": false,
3103 "typeErrors": true,
3104 "deprecatedCalls": false,
3105 "duplicateDeclarations": true,
3106 }));
3107 assert!(cfg.enabled);
3108 assert!(!cfg.undefined_variables);
3109 assert!(!cfg.undefined_functions);
3110 assert!(cfg.undefined_classes);
3111 assert!(!cfg.arity_errors);
3112 assert!(cfg.type_errors);
3113 assert!(!cfg.deprecated_calls);
3114 assert!(cfg.duplicate_declarations);
3115 }
3116
3117 #[test]
3118 fn diagnostics_config_master_switch_disables_all() {
3119 let cfg = DiagnosticsConfig::from_value(&serde_json::json!({"enabled": false}));
3120 assert!(!cfg.enabled);
3121 assert!(cfg.undefined_variables);
3123 }
3124
3125 #[test]
3126 fn diagnostics_config_master_switch_enables_all() {
3127 let cfg = DiagnosticsConfig::from_value(&serde_json::json!({"enabled": true}));
3128 assert!(cfg.enabled);
3129 assert!(cfg.undefined_variables);
3130 }
3131
3132 #[test]
3134 fn lsp_config_default_is_empty() {
3135 let cfg = LspConfig::default();
3136 assert!(cfg.php_version.is_none());
3137 assert!(cfg.exclude_paths.is_empty());
3138 assert!(!cfg.diagnostics.enabled);
3139 }
3140
3141 #[test]
3142 fn lsp_config_parses_php_version() {
3143 let cfg =
3144 LspConfig::from_value(&serde_json::json!({"phpVersion": crate::autoload::PHP_8_2}));
3145 assert_eq!(cfg.php_version.as_deref(), Some(crate::autoload::PHP_8_2));
3146 }
3147
3148 #[test]
3149 fn lsp_config_parses_exclude_paths() {
3150 let cfg = LspConfig::from_value(&serde_json::json!({
3151 "excludePaths": ["cache/*", "generated/*"]
3152 }));
3153 assert_eq!(cfg.exclude_paths, vec!["cache/*", "generated/*"]);
3154 }
3155
3156 #[test]
3157 fn lsp_config_parses_diagnostics_section() {
3158 let cfg = LspConfig::from_value(&serde_json::json!({
3159 "diagnostics": {"enabled": false}
3160 }));
3161 assert!(!cfg.diagnostics.enabled);
3162 }
3163
3164 #[test]
3165 fn lsp_config_ignores_missing_fields() {
3166 let cfg = LspConfig::from_value(&serde_json::json!({}));
3167 assert!(cfg.php_version.is_none());
3168 assert!(cfg.exclude_paths.is_empty());
3169 }
3170
3171 #[test]
3172 fn lsp_config_parses_max_indexed_files() {
3173 let cfg = LspConfig::from_value(&serde_json::json!({"maxIndexedFiles": 5000}));
3174 assert_eq!(cfg.max_indexed_files, 5000);
3175 }
3176
3177 #[test]
3178 fn lsp_config_default_max_indexed_files() {
3179 let cfg = LspConfig::default();
3180 assert_eq!(cfg.max_indexed_files, MAX_INDEXED_FILES);
3181 }
3182
3183 #[test]
3185 fn find_use_insert_line_after_php_open_tag() {
3186 let src = "<?php\nfunction foo() {}";
3187 assert_eq!(find_use_insert_line(src), 1);
3188 }
3189
3190 #[test]
3191 fn find_use_insert_line_after_existing_use() {
3192 let src = "<?php\nuse Foo\\Bar;\nuse Baz\\Qux;\nclass Impl {}";
3193 assert_eq!(find_use_insert_line(src), 3);
3194 }
3195
3196 #[test]
3197 fn find_use_insert_line_after_namespace() {
3198 let src = "<?php\nnamespace App\\Services;\nclass Service {}";
3199 assert_eq!(find_use_insert_line(src), 2);
3200 }
3201
3202 #[test]
3203 fn find_use_insert_line_after_namespace_and_use() {
3204 let src = "<?php\nnamespace App;\nuse Foo\\Bar;\nclass Impl {}";
3205 assert_eq!(find_use_insert_line(src), 3);
3206 }
3207
3208 #[test]
3209 fn find_use_insert_line_empty_file() {
3210 assert_eq!(find_use_insert_line(""), 0);
3211 }
3212
3213 #[test]
3215 fn is_after_arrow_with_method_call() {
3216 let src = "<?php\n$obj->method();\n";
3217 let pos = Position {
3219 line: 1,
3220 character: 6,
3221 };
3222 assert!(is_after_arrow(src, pos));
3223 }
3224
3225 #[test]
3226 fn is_after_arrow_without_arrow() {
3227 let src = "<?php\n$obj->method();\n";
3228 let pos = Position {
3230 line: 1,
3231 character: 1,
3232 };
3233 assert!(!is_after_arrow(src, pos));
3234 }
3235
3236 #[test]
3237 fn is_after_arrow_on_standalone_identifier() {
3238 let src = "<?php\nfunction greet() {}\n";
3239 let pos = Position {
3240 line: 1,
3241 character: 10,
3242 };
3243 assert!(!is_after_arrow(src, pos));
3244 }
3245
3246 #[test]
3247 fn is_after_arrow_out_of_bounds_line() {
3248 let src = "<?php\n$x = 1;\n";
3249 let pos = Position {
3250 line: 99,
3251 character: 0,
3252 };
3253 assert!(!is_after_arrow(src, pos));
3254 }
3255
3256 #[test]
3257 fn is_after_arrow_at_start_of_property() {
3258 let src = "<?php\n$this->name;\n";
3259 let pos = Position {
3261 line: 1,
3262 character: 7,
3263 };
3264 assert!(is_after_arrow(src, pos));
3265 }
3266
3267 #[test]
3269 fn php_file_op_matches_php_files() {
3270 let op = php_file_op();
3271 assert_eq!(op.filters.len(), 1);
3272 let filter = &op.filters[0];
3273 assert_eq!(filter.scheme.as_deref(), Some("file"));
3274 assert_eq!(filter.pattern.glob, "**/*.php");
3275 }
3276
3277 #[test]
3279 fn defer_actions_strips_edit_and_adds_data() {
3280 let uri = Url::parse("file:///test.php").unwrap();
3281 let range = Range {
3282 start: Position {
3283 line: 0,
3284 character: 0,
3285 },
3286 end: Position {
3287 line: 0,
3288 character: 5,
3289 },
3290 };
3291 let actions = vec![CodeActionOrCommand::CodeAction(CodeAction {
3292 title: "My Action".to_string(),
3293 kind: Some(CodeActionKind::REFACTOR),
3294 edit: Some(WorkspaceEdit::default()),
3295 data: None,
3296 ..Default::default()
3297 })];
3298 let deferred = defer_actions(actions, "test_kind", &uri, range);
3299 assert_eq!(deferred.len(), 1);
3300 if let CodeActionOrCommand::CodeAction(ca) = &deferred[0] {
3301 assert!(ca.edit.is_none(), "edit should be stripped");
3302 assert!(ca.data.is_some(), "data payload should be set");
3303 let data = ca.data.as_ref().unwrap();
3304 assert_eq!(data["php_lsp_resolve"], "test_kind");
3305 assert_eq!(data["uri"], uri.to_string());
3306 } else {
3307 panic!("expected CodeAction");
3308 }
3309 }
3310
3311 #[test]
3313 fn build_use_import_edit_inserts_after_php_tag() {
3314 let src = "<?php\nclass Foo {}";
3315 let uri = Url::parse("file:///test.php").unwrap();
3316 let edit = build_use_import_edit(src, &uri, "App\\Services\\Bar");
3317 let changes = edit.changes.unwrap();
3318 let edits = changes.get(&uri).unwrap();
3319 assert_eq!(edits.len(), 1);
3320 assert_eq!(edits[0].new_text, "use App\\Services\\Bar;\n");
3321 assert_eq!(edits[0].range.start.line, 1);
3322 }
3323
3324 #[test]
3325 fn build_use_import_edit_inserts_after_existing_use() {
3326 let src = "<?php\nuse Foo\\Bar;\nclass Impl {}";
3327 let uri = Url::parse("file:///test.php").unwrap();
3328 let edit = build_use_import_edit(src, &uri, "Baz\\Qux");
3329 let changes = edit.changes.unwrap();
3330 let edits = changes.get(&uri).unwrap();
3331 assert_eq!(edits[0].range.start.line, 2);
3332 assert_eq!(edits[0].new_text, "use Baz\\Qux;\n");
3333 }
3334
3335 #[test]
3337 fn undefined_class_name_extracted_from_message() {
3338 let msg = "Class MyService does not exist";
3339 let name = msg
3340 .strip_prefix("Class ")
3341 .and_then(|s| s.strip_suffix(" does not exist"))
3342 .unwrap_or("")
3343 .trim();
3344 assert_eq!(name, "MyService");
3345 }
3346
3347 #[test]
3348 fn undefined_function_message_not_matched_by_extraction() {
3349 let msg = "Function myHelper() is not defined";
3352 let name = msg
3353 .strip_prefix("Class ")
3354 .and_then(|s| s.strip_suffix(" does not exist"))
3355 .unwrap_or("")
3356 .trim();
3357 assert!(
3358 name.is_empty(),
3359 "function diagnostic should not extract a class name"
3360 );
3361 }
3362
3363 #[test]
3366 fn position_to_offset_first_line() {
3367 let src = "<?php\nfoo();";
3368 assert_eq!(
3370 position_to_offset(
3371 src,
3372 Position {
3373 line: 0,
3374 character: 0
3375 }
3376 ),
3377 Some(0)
3378 );
3379 assert_eq!(
3381 position_to_offset(
3382 src,
3383 Position {
3384 line: 0,
3385 character: 4
3386 }
3387 ),
3388 Some(4)
3389 );
3390 assert_eq!(
3392 position_to_offset(
3393 src,
3394 Position {
3395 line: 0,
3396 character: 5
3397 }
3398 ),
3399 Some(5)
3400 );
3401 }
3402
3403 #[test]
3404 fn position_to_offset_second_line() {
3405 let src = "<?php\nfoo();";
3406 assert_eq!(
3408 position_to_offset(
3409 src,
3410 Position {
3411 line: 1,
3412 character: 0
3413 }
3414 ),
3415 Some(6)
3416 );
3417 assert_eq!(
3419 position_to_offset(
3420 src,
3421 Position {
3422 line: 1,
3423 character: 3
3424 }
3425 ),
3426 Some(9)
3427 );
3428 }
3429
3430 #[test]
3431 fn position_to_offset_line_boundary_returns_none() {
3432 let src = "<?php";
3434 assert_eq!(
3435 position_to_offset(
3436 src,
3437 Position {
3438 line: 1,
3439 character: 0
3440 }
3441 ),
3442 None
3443 );
3444 assert_eq!(
3445 position_to_offset(
3446 src,
3447 Position {
3448 line: 5,
3449 character: 0
3450 }
3451 ),
3452 None
3453 );
3454 }
3455
3456 #[test]
3459 fn cursor_on_method_decl_name_returns_true() {
3460 let doc = ParsedDoc::parse("<?php\nclass C {\n public function add() {}\n}".to_string());
3463 let source = doc.source();
3464 let stmts = &doc.program().stmts;
3465 for col in 20u32..=22 {
3467 assert!(
3468 cursor_is_on_method_decl(
3469 source,
3470 stmts,
3471 Position {
3472 line: 2,
3473 character: col
3474 }
3475 ),
3476 "expected true at col {col}"
3477 );
3478 }
3479 assert!(!cursor_is_on_method_decl(
3481 source,
3482 stmts,
3483 Position {
3484 line: 2,
3485 character: 19
3486 }
3487 ));
3488 assert!(!cursor_is_on_method_decl(
3489 source,
3490 stmts,
3491 Position {
3492 line: 2,
3493 character: 23
3494 }
3495 ));
3496 }
3497
3498 #[test]
3499 fn cursor_on_free_function_decl_returns_false() {
3500 let doc = ParsedDoc::parse("<?php\nfunction add() {}".to_string());
3502 let source = doc.source();
3503 let stmts = &doc.program().stmts;
3504 assert!(!cursor_is_on_method_decl(
3505 source,
3506 stmts,
3507 Position {
3508 line: 1,
3509 character: 9
3510 }
3511 ));
3512 }
3513
3514 #[test]
3515 fn cursor_on_method_call_site_returns_false() {
3516 let doc = ParsedDoc::parse(
3518 "<?php\nclass C { public function add() {} }\n$c = new C();\n$c->add();".to_string(),
3519 );
3520 let source = doc.source();
3521 let stmts = &doc.program().stmts;
3522 assert!(!cursor_is_on_method_decl(
3523 source,
3524 stmts,
3525 Position {
3526 line: 3,
3527 character: 4
3528 }
3529 ));
3530 }
3531
3532 #[test]
3533 fn cursor_on_interface_method_decl_returns_true() {
3534 let doc = ParsedDoc::parse(
3536 "<?php\ninterface I {\n public function add(): void;\n}".to_string(),
3537 );
3538 let source = doc.source();
3539 let stmts = &doc.program().stmts;
3540 assert!(cursor_is_on_method_decl(
3541 source,
3542 stmts,
3543 Position {
3544 line: 2,
3545 character: 20
3546 }
3547 ));
3548 }
3549
3550 #[test]
3551 fn cursor_on_trait_method_decl_returns_true() {
3552 let doc = ParsedDoc::parse("<?php\ntrait T {\n public function add() {}\n}".to_string());
3554 let source = doc.source();
3555 let stmts = &doc.program().stmts;
3556 assert!(cursor_is_on_method_decl(
3557 source,
3558 stmts,
3559 Position {
3560 line: 2,
3561 character: 20
3562 }
3563 ));
3564 }
3565
3566 #[test]
3567 fn cursor_on_enum_method_decl_returns_true() {
3568 let doc = ParsedDoc::parse(
3570 "<?php\nenum Status {\n public function label(): string { return 'x'; }\n}"
3571 .to_string(),
3572 );
3573 let source = doc.source();
3574 let stmts = &doc.program().stmts;
3575 assert!(cursor_is_on_method_decl(
3576 source,
3577 stmts,
3578 Position {
3579 line: 2,
3580 character: 20
3581 }
3582 ));
3583 }
3584
3585 #[test]
3586 fn cursor_on_method_decl_in_unbraced_namespace_returns_true() {
3587 let doc = ParsedDoc::parse(
3596 "<?php\nnamespace App;\nclass C {\n public function add() {}\n}".to_string(),
3597 );
3598 let source = doc.source();
3599 let stmts = &doc.program().stmts;
3600 assert!(
3601 cursor_is_on_method_decl(
3602 source,
3603 stmts,
3604 Position {
3605 line: 3,
3606 character: 20
3607 }
3608 ),
3609 "method in unbraced namespace must be detected"
3610 );
3611 }
3612
3613 #[test]
3614 fn cursor_on_method_decl_in_braced_namespace_returns_true() {
3615 let doc = ParsedDoc::parse(
3624 "<?php\nnamespace App {\n class C {\n public function add() {}\n }\n}"
3625 .to_string(),
3626 );
3627 let source = doc.source();
3628 let stmts = &doc.program().stmts;
3629 assert!(
3630 cursor_is_on_method_decl(
3631 source,
3632 stmts,
3633 Position {
3634 line: 3,
3635 character: 24
3636 }
3637 ),
3638 "method in braced namespace must be detected"
3639 );
3640 }
3641
3642 #[test]
3645 fn merge_file_only_uses_file_values() {
3646 let file = serde_json::json!({
3647 "phpVersion": "8.1",
3648 "excludePaths": ["vendor/*"],
3649 "maxIndexedFiles": 500,
3650 });
3651 let merged = LspConfig::merge_project_configs(Some(&file), None);
3652 let cfg = LspConfig::from_value(&merged);
3653 assert_eq!(cfg.php_version, Some("8.1".to_string()));
3654 assert_eq!(cfg.exclude_paths, vec!["vendor/*"]);
3655 assert_eq!(cfg.max_indexed_files, 500);
3656 }
3657
3658 #[test]
3659 fn merge_editor_wins_per_key_over_file() {
3660 let file = serde_json::json!({"phpVersion": "8.1", "maxIndexedFiles": 100});
3661 let editor = serde_json::json!({"phpVersion": "8.3", "maxIndexedFiles": 200});
3662 let merged = LspConfig::merge_project_configs(Some(&file), Some(&editor));
3663 let cfg = LspConfig::from_value(&merged);
3664 assert_eq!(cfg.php_version, Some("8.3".to_string()));
3665 assert_eq!(cfg.max_indexed_files, 200);
3666 }
3667
3668 #[test]
3669 fn merge_exclude_paths_concat_not_replace() {
3670 let file = serde_json::json!({"excludePaths": ["cache/*"]});
3671 let editor = serde_json::json!({"excludePaths": ["logs/*"]});
3672 let merged = LspConfig::merge_project_configs(Some(&file), Some(&editor));
3673 let cfg = LspConfig::from_value(&merged);
3674 assert_eq!(cfg.exclude_paths, vec!["cache/*", "logs/*"]);
3676 }
3677
3678 #[test]
3679 fn merge_no_file_uses_editor_only() {
3680 let editor = serde_json::json!({"phpVersion": "8.2", "excludePaths": ["tmp/*"]});
3681 let merged = LspConfig::merge_project_configs(None, Some(&editor));
3682 let cfg = LspConfig::from_value(&merged);
3683 assert_eq!(cfg.php_version, Some("8.2".to_string()));
3684 assert_eq!(cfg.exclude_paths, vec!["tmp/*"]);
3685 }
3686
3687 #[test]
3688 fn merge_both_none_returns_defaults() {
3689 let merged = LspConfig::merge_project_configs(None, None);
3690 let cfg = LspConfig::from_value(&merged);
3691 assert!(cfg.php_version.is_none());
3692 assert!(cfg.exclude_paths.is_empty());
3693 assert_eq!(cfg.max_indexed_files, MAX_INDEXED_FILES);
3694 }
3695
3696 #[test]
3697 fn merge_file_editor_both_have_exclude_paths_all_present() {
3698 let file = serde_json::json!({"excludePaths": ["a/*", "b/*"]});
3699 let editor = serde_json::json!({"excludePaths": ["c/*"]});
3700 let merged = LspConfig::merge_project_configs(Some(&file), Some(&editor));
3701 let cfg = LspConfig::from_value(&merged);
3702 assert_eq!(cfg.exclude_paths, vec!["a/*", "b/*", "c/*"]);
3703 }
3704}