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: true,
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.get("enabled").and_then(|x| x.as_bool()).unwrap_or(true);
134 cfg.undefined_variables = flag("undefinedVariables");
135 cfg.undefined_functions = flag("undefinedFunctions");
136 cfg.undefined_classes = flag("undefinedClasses");
137 cfg.arity_errors = flag("arityErrors");
138 cfg.type_errors = flag("typeErrors");
139 cfg.deprecated_calls = flag("deprecatedCalls");
140 cfg.duplicate_declarations = flag("duplicateDeclarations");
141 cfg
142 }
143}
144
145#[derive(Debug, Clone)]
148pub struct FeaturesConfig {
149 pub completion: bool,
150 pub hover: bool,
151 pub definition: bool,
152 pub declaration: bool,
153 pub references: bool,
154 pub document_symbols: bool,
155 pub workspace_symbols: bool,
156 pub rename: bool,
157 pub signature_help: bool,
158 pub inlay_hints: bool,
159 pub semantic_tokens: bool,
160 pub selection_range: bool,
161 pub call_hierarchy: bool,
162 pub document_highlight: bool,
163 pub implementation: bool,
164 pub code_action: bool,
165 pub type_definition: bool,
166 pub code_lens: bool,
167 pub formatting: bool,
168 pub range_formatting: bool,
169 pub on_type_formatting: bool,
170 pub document_link: bool,
171 pub linked_editing_range: bool,
172 pub inline_values: bool,
173}
174
175impl Default for FeaturesConfig {
176 fn default() -> Self {
177 FeaturesConfig {
178 completion: true,
179 hover: true,
180 definition: true,
181 declaration: true,
182 references: true,
183 document_symbols: true,
184 workspace_symbols: true,
185 rename: true,
186 signature_help: true,
187 inlay_hints: true,
188 semantic_tokens: true,
189 selection_range: true,
190 call_hierarchy: true,
191 document_highlight: true,
192 implementation: true,
193 code_action: true,
194 type_definition: true,
195 code_lens: true,
196 formatting: true,
197 range_formatting: true,
198 on_type_formatting: true,
199 document_link: true,
200 linked_editing_range: true,
201 inline_values: true,
202 }
203 }
204}
205
206impl FeaturesConfig {
207 fn from_value(v: &serde_json::Value) -> Self {
208 let mut cfg = FeaturesConfig::default();
209 let Some(obj) = v.as_object() else { return cfg };
210 let flag = |key: &str| obj.get(key).and_then(|x| x.as_bool()).unwrap_or(true);
211 cfg.completion = flag("completion");
212 cfg.hover = flag("hover");
213 cfg.definition = flag("definition");
214 cfg.declaration = flag("declaration");
215 cfg.references = flag("references");
216 cfg.document_symbols = flag("documentSymbols");
217 cfg.workspace_symbols = flag("workspaceSymbols");
218 cfg.rename = flag("rename");
219 cfg.signature_help = flag("signatureHelp");
220 cfg.inlay_hints = flag("inlayHints");
221 cfg.semantic_tokens = flag("semanticTokens");
222 cfg.selection_range = flag("selectionRange");
223 cfg.call_hierarchy = flag("callHierarchy");
224 cfg.document_highlight = flag("documentHighlight");
225 cfg.implementation = flag("implementation");
226 cfg.code_action = flag("codeAction");
227 cfg.type_definition = flag("typeDefinition");
228 cfg.code_lens = flag("codeLens");
229 cfg.formatting = flag("formatting");
230 cfg.range_formatting = flag("rangeFormatting");
231 cfg.on_type_formatting = flag("onTypeFormatting");
232 cfg.document_link = flag("documentLink");
233 cfg.linked_editing_range = flag("linkedEditingRange");
234 cfg.inline_values = flag("inlineValues");
235 cfg
236 }
237}
238
239#[derive(Debug, Clone)]
241pub struct LspConfig {
242 pub php_version: Option<String>,
245 pub exclude_paths: Vec<String>,
247 pub diagnostics: DiagnosticsConfig,
249 pub features: FeaturesConfig,
251 pub max_indexed_files: usize,
255}
256
257impl Default for LspConfig {
258 fn default() -> Self {
259 LspConfig {
260 php_version: None,
261 exclude_paths: Vec::new(),
262 diagnostics: DiagnosticsConfig::default(),
263 features: FeaturesConfig::default(),
264 max_indexed_files: MAX_INDEXED_FILES,
265 }
266 }
267}
268
269impl LspConfig {
270 pub fn merge_project_configs(
278 file: Option<&serde_json::Value>,
279 editor: Option<&serde_json::Value>,
280 ) -> serde_json::Value {
281 let mut merged = file
282 .cloned()
283 .unwrap_or(serde_json::Value::Object(Default::default()));
284 let Some(editor_obj) = editor.and_then(|e| e.as_object()) else {
285 return merged;
286 };
287 let merged_obj = merged
288 .as_object_mut()
289 .expect("merged base is always an object");
290 for (key, val) in editor_obj {
291 if key == "excludePaths" {
292 let file_arr = merged_obj
293 .get("excludePaths")
294 .and_then(|v| v.as_array())
295 .cloned()
296 .unwrap_or_default();
297 let editor_arr = val.as_array().cloned().unwrap_or_default();
298 merged_obj.insert(
299 key.clone(),
300 serde_json::Value::Array([file_arr, editor_arr].concat()),
301 );
302 } else {
303 merged_obj.insert(key.clone(), val.clone());
304 }
305 }
306 merged
307 }
308
309 fn from_value(v: &serde_json::Value) -> Self {
310 let mut cfg = LspConfig::default();
311 if let Some(ver) = v.get("phpVersion").and_then(|x| x.as_str())
312 && crate::autoload::is_valid_php_version(ver)
313 {
314 cfg.php_version = Some(ver.to_string());
315 }
316 if let Some(arr) = v.get("excludePaths").and_then(|x| x.as_array()) {
317 cfg.exclude_paths = arr
318 .iter()
319 .filter_map(|x| x.as_str().map(str::to_string))
320 .collect();
321 }
322 if let Some(diag_val) = v.get("diagnostics") {
323 cfg.diagnostics = DiagnosticsConfig::from_value(diag_val);
324 }
325 if let Some(feat_val) = v.get("features") {
326 cfg.features = FeaturesConfig::from_value(feat_val);
327 }
328 if let Some(n) = v.get("maxIndexedFiles").and_then(|x| x.as_u64()) {
329 cfg.max_indexed_files = n as usize;
330 }
331 cfg
332 }
333}
334
335#[derive(Default, Clone)]
342struct OpenFile {
343 text: String,
345 version: u64,
348 parse_diagnostics: Vec<Diagnostic>,
350}
351
352#[derive(Clone, Default)]
355pub struct OpenFiles(Arc<DashMap<Url, OpenFile>>);
356
357impl OpenFiles {
358 fn new() -> Self {
359 Self::default()
360 }
361
362 fn set_open_text(&self, docs: &DocumentStore, uri: Url, text: String) -> u64 {
363 docs.mirror_text(&uri, &text);
364 let mut entry = self.0.entry(uri).or_default();
365 entry.version += 1;
366 entry.text = text;
367 entry.version
368 }
369
370 fn close(&self, docs: &DocumentStore, uri: &Url) {
371 self.0.remove(uri);
372 docs.evict_token_cache(uri);
373 }
374
375 fn current_version(&self, uri: &Url) -> Option<u64> {
376 self.0.get(uri).map(|e| e.version)
377 }
378
379 fn text(&self, uri: &Url) -> Option<String> {
380 self.0.get(uri).map(|e| e.text.clone())
381 }
382
383 fn set_parse_diagnostics(&self, uri: &Url, diagnostics: Vec<Diagnostic>) {
384 if let Some(mut entry) = self.0.get_mut(uri) {
385 entry.parse_diagnostics = diagnostics;
386 }
387 }
388
389 fn parse_diagnostics(&self, uri: &Url) -> Option<Vec<Diagnostic>> {
390 self.0.get(uri).map(|e| e.parse_diagnostics.clone())
391 }
392
393 fn all_with_diagnostics(&self) -> Vec<(Url, Vec<Diagnostic>, Option<i64>)> {
394 self.0
395 .iter()
396 .map(|e| {
397 (
398 e.key().clone(),
399 e.value().parse_diagnostics.clone(),
400 Some(e.value().version as i64),
401 )
402 })
403 .collect()
404 }
405
406 fn urls(&self) -> Vec<Url> {
407 self.0.iter().map(|e| e.key().clone()).collect()
408 }
409
410 fn contains(&self, uri: &Url) -> bool {
411 self.0.contains_key(uri)
412 }
413
414 fn get_doc(&self, docs: &DocumentStore, uri: &Url) -> Option<Arc<ParsedDoc>> {
416 if !self.contains(uri) {
417 return None;
418 }
419 docs.get_doc_salsa(uri)
420 }
421}
422
423fn compute_open_file_diagnostics(
439 docs: &DocumentStore,
440 open_files: &OpenFiles,
441 uri: &Url,
442 diag_cfg: &DiagnosticsConfig,
443) -> Vec<Diagnostic> {
444 let mut out = open_files.parse_diagnostics(uri).unwrap_or_default();
445 let source = open_files.text(uri).unwrap_or_default();
446 if let Some(d) = open_files.get_doc(docs, uri) {
447 out.extend(duplicate_declaration_diagnostics(&source, &d, diag_cfg));
448 }
449 if let Some(issues) = docs.get_semantic_issues_salsa(uri) {
450 out.extend(crate::semantic_diagnostics::issues_to_diagnostics(
451 &issues, uri, diag_cfg,
452 ));
453 }
454 out
455}
456
457pub struct Backend {
458 client: Client,
459 docs: Arc<DocumentStore>,
460 open_files: OpenFiles,
464 root_paths: Arc<RwLock<Vec<PathBuf>>>,
465 psr4: Arc<RwLock<Psr4Map>>,
466 meta: Arc<RwLock<PhpStormMeta>>,
467 config: Arc<RwLock<LspConfig>>,
468}
469
470impl Backend {
471 pub fn new(client: Client) -> Self {
472 let docs = Arc::new(DocumentStore::new());
477 let psr4 = docs.psr4_arc();
478 Backend {
479 client,
480 docs,
481 open_files: OpenFiles::new(),
482 root_paths: Arc::new(RwLock::new(Vec::new())),
483 psr4,
484 meta: Arc::new(RwLock::new(PhpStormMeta::default())),
485 config: Arc::new(RwLock::new(LspConfig::default())),
486 }
487 }
488
489 fn set_open_text(&self, uri: Url, text: String) -> u64 {
492 self.open_files.set_open_text(&self.docs, uri, text)
493 }
494
495 fn close_open_file(&self, uri: &Url) {
496 self.open_files.close(&self.docs, uri);
497 }
498
499 fn index_if_not_open(&self, uri: Url, text: &str) {
503 if !self.open_files.contains(&uri) {
504 self.docs.index(uri, text);
505 }
506 }
507
508 fn index_from_doc_if_not_open(&self, uri: Url, doc: &ParsedDoc, diags: Vec<Diagnostic>) {
510 if !self.open_files.contains(&uri) {
511 self.docs.index_from_doc(uri, doc, diags);
512 }
513 }
514
515 fn get_open_text(&self, uri: &Url) -> Option<String> {
516 self.open_files.text(uri)
517 }
518
519 fn set_parse_diagnostics(&self, uri: &Url, diagnostics: Vec<Diagnostic>) {
520 self.open_files.set_parse_diagnostics(uri, diagnostics);
521 }
522
523 fn get_parse_diagnostics(&self, uri: &Url) -> Option<Vec<Diagnostic>> {
524 self.open_files.parse_diagnostics(uri)
525 }
526
527 fn all_open_files_with_diagnostics(&self) -> Vec<(Url, Vec<Diagnostic>, Option<i64>)> {
528 self.open_files.all_with_diagnostics()
529 }
530
531 fn open_urls(&self) -> Vec<Url> {
532 self.open_files.urls()
533 }
534
535 fn get_doc(&self, uri: &Url) -> Option<Arc<ParsedDoc>> {
536 self.open_files.get_doc(&self.docs, uri)
537 }
538
539 fn codebase(&self) -> Arc<mir_codebase::Codebase> {
544 self.docs.get_codebase_salsa()
545 }
546
547 fn file_imports(&self, uri: &Url) -> std::collections::HashMap<String, String> {
549 self.codebase()
550 .file_imports
551 .get(uri.as_str())
552 .map(|r| r.clone())
553 .unwrap_or_default()
554 }
555
556 fn resolve_php_version(&self, explicit: Option<&str>) -> (String, &'static str) {
559 let roots = self.root_paths.read().unwrap().clone();
560 crate::autoload::resolve_php_version_from_roots(&roots, explicit)
561 }
562}
563
564#[async_trait]
565impl LanguageServer for Backend {
566 async fn initialize(&self, params: InitializeParams) -> Result<InitializeResult> {
567 {
570 let mut roots: Vec<PathBuf> = params
571 .workspace_folders
572 .as_deref()
573 .unwrap_or(&[])
574 .iter()
575 .filter_map(|f| f.uri.to_file_path().ok())
576 .collect();
577 if roots.is_empty()
578 && let Some(path) = params.root_uri.as_ref().and_then(|u| u.to_file_path().ok())
579 {
580 roots.push(path);
581 }
582 *self.root_paths.write().unwrap() = roots;
583 }
584
585 {
591 let roots = self.root_paths.read().unwrap().clone();
592 if !roots.is_empty() {
593 let mut merged = Psr4Map::empty();
594 for root in &roots {
595 merged.extend(Psr4Map::load(root));
596 }
597 *self.psr4.write().unwrap() = merged;
598 }
599 }
600
601 {
603 let opts = params.initialization_options.as_ref();
604 let roots = self.root_paths.read().unwrap().clone();
605
606 let file_cfg = crate::autoload::load_project_config_json(&roots);
608
609 if matches!(file_cfg, Some(serde_json::Value::Null)) {
611 self.client
612 .log_message(
613 tower_lsp::lsp_types::MessageType::WARNING,
614 "php-lsp: .php-lsp.json contains invalid JSON — ignoring",
615 )
616 .await;
617 }
618
619 if let Some(serde_json::Value::Object(ref obj)) = file_cfg
621 && let Some(ver) = obj.get("phpVersion").and_then(|v| v.as_str())
622 && !crate::autoload::is_valid_php_version(ver)
623 {
624 self.client
625 .log_message(
626 tower_lsp::lsp_types::MessageType::WARNING,
627 format!(
628 "php-lsp: .php-lsp.json unsupported phpVersion {ver:?} — valid values: {}",
629 crate::autoload::SUPPORTED_PHP_VERSIONS.join(", ")
630 ),
631 )
632 .await;
633 }
634
635 if let Some(ver) = opts
637 .and_then(|o| o.get("phpVersion"))
638 .and_then(|v| v.as_str())
639 && !crate::autoload::is_valid_php_version(ver)
640 {
641 self.client
642 .log_message(
643 tower_lsp::lsp_types::MessageType::WARNING,
644 format!(
645 "php-lsp: unsupported phpVersion {ver:?} — valid values: {}",
646 crate::autoload::SUPPORTED_PHP_VERSIONS.join(", ")
647 ),
648 )
649 .await;
650 }
651
652 let file_obj = file_cfg.as_ref().filter(|v| v.is_object());
655 let merged = LspConfig::merge_project_configs(file_obj, opts);
656 let mut cfg = LspConfig::from_value(&merged);
657
658 let (ver, source) = self.resolve_php_version(cfg.php_version.as_deref());
663 self.client
664 .log_message(
665 tower_lsp::lsp_types::MessageType::INFO,
666 format!("php-lsp: using PHP {ver} ({source})"),
667 )
668 .await;
669 if source != "set by editor" && !crate::autoload::is_valid_php_version(&ver) {
674 self.client
675 .show_message(
676 tower_lsp::lsp_types::MessageType::WARNING,
677 format!(
678 "php-lsp: detected PHP {ver} is outside the supported range ({}); \
679 analysis may be inaccurate",
680 crate::autoload::SUPPORTED_PHP_VERSIONS.join(", ")
681 ),
682 )
683 .await;
684 }
685 cfg.php_version = Some(ver.clone());
686 if let Ok(pv) = ver.parse::<mir_analyzer::PhpVersion>() {
687 self.docs.set_php_version(pv);
688 }
689 *self.config.write().unwrap() = cfg;
690 }
691
692 let feat = self.config.read().unwrap().features.clone();
693 Ok(InitializeResult {
694 capabilities: ServerCapabilities {
695 text_document_sync: Some(TextDocumentSyncCapability::Options(
696 TextDocumentSyncOptions {
697 open_close: Some(true),
698 change: Some(TextDocumentSyncKind::FULL),
699 will_save: Some(true),
700 will_save_wait_until: Some(true),
701 save: Some(TextDocumentSyncSaveOptions::SaveOptions(SaveOptions {
702 include_text: Some(false),
703 })),
704 },
705 )),
706 completion_provider: feat.completion.then(|| CompletionOptions {
707 trigger_characters: Some(vec![
708 "$".to_string(),
709 ">".to_string(),
710 ":".to_string(),
711 "(".to_string(),
712 "[".to_string(),
713 ]),
714 resolve_provider: Some(true),
715 ..Default::default()
716 }),
717 hover_provider: feat.hover.then_some(HoverProviderCapability::Simple(true)),
718 definition_provider: feat.definition.then_some(OneOf::Left(true)),
719 references_provider: feat.references.then_some(OneOf::Left(true)),
720 document_symbol_provider: feat.document_symbols.then_some(OneOf::Left(true)),
721 workspace_symbol_provider: feat.workspace_symbols.then(|| {
722 OneOf::Right(WorkspaceSymbolOptions {
723 resolve_provider: Some(true),
724 work_done_progress_options: Default::default(),
725 })
726 }),
727 rename_provider: feat.rename.then(|| {
728 OneOf::Right(RenameOptions {
729 prepare_provider: Some(true),
730 work_done_progress_options: Default::default(),
731 })
732 }),
733 signature_help_provider: feat.signature_help.then(|| SignatureHelpOptions {
734 trigger_characters: Some(vec!["(".to_string(), ",".to_string()]),
735 retrigger_characters: None,
736 work_done_progress_options: Default::default(),
737 }),
738 inlay_hint_provider: feat.inlay_hints.then(|| {
739 OneOf::Right(InlayHintServerCapabilities::Options(InlayHintOptions {
740 resolve_provider: Some(true),
741 work_done_progress_options: Default::default(),
742 }))
743 }),
744 folding_range_provider: Some(FoldingRangeProviderCapability::Simple(true)),
745 semantic_tokens_provider: feat.semantic_tokens.then(|| {
746 SemanticTokensServerCapabilities::SemanticTokensOptions(SemanticTokensOptions {
747 legend: legend(),
748 full: Some(SemanticTokensFullOptions::Delta { delta: Some(true) }),
749 range: Some(true),
750 ..Default::default()
751 })
752 }),
753 selection_range_provider: feat
754 .selection_range
755 .then_some(SelectionRangeProviderCapability::Simple(true)),
756 call_hierarchy_provider: feat
757 .call_hierarchy
758 .then_some(CallHierarchyServerCapability::Simple(true)),
759 document_highlight_provider: feat.document_highlight.then_some(OneOf::Left(true)),
760 implementation_provider: feat
761 .implementation
762 .then_some(ImplementationProviderCapability::Simple(true)),
763 code_action_provider: feat.code_action.then(|| {
764 CodeActionProviderCapability::Options(CodeActionOptions {
765 resolve_provider: Some(true),
766 ..Default::default()
767 })
768 }),
769 declaration_provider: feat
770 .declaration
771 .then_some(DeclarationCapability::Simple(true)),
772 type_definition_provider: feat
773 .type_definition
774 .then_some(TypeDefinitionProviderCapability::Simple(true)),
775 code_lens_provider: feat.code_lens.then_some(CodeLensOptions {
776 resolve_provider: Some(true),
777 }),
778 document_formatting_provider: feat.formatting.then_some(OneOf::Left(true)),
779 document_range_formatting_provider: feat
780 .range_formatting
781 .then_some(OneOf::Left(true)),
782 document_on_type_formatting_provider: feat.on_type_formatting.then(|| {
783 DocumentOnTypeFormattingOptions {
784 first_trigger_character: "}".to_string(),
785 more_trigger_character: Some(vec!["\n".to_string()]),
786 }
787 }),
788 document_link_provider: feat.document_link.then(|| DocumentLinkOptions {
789 resolve_provider: Some(true),
790 work_done_progress_options: Default::default(),
791 }),
792 execute_command_provider: Some(ExecuteCommandOptions {
793 commands: vec!["php-lsp.runTest".to_string()],
794 work_done_progress_options: Default::default(),
795 }),
796 diagnostic_provider: Some(DiagnosticServerCapabilities::Options(
797 DiagnosticOptions {
798 identifier: None,
799 inter_file_dependencies: true,
800 workspace_diagnostics: true,
801 work_done_progress_options: Default::default(),
802 },
803 )),
804 workspace: Some(WorkspaceServerCapabilities {
805 workspace_folders: Some(WorkspaceFoldersServerCapabilities {
806 supported: Some(true),
807 change_notifications: Some(OneOf::Left(true)),
808 }),
809 file_operations: Some(WorkspaceFileOperationsServerCapabilities {
810 will_rename: Some(php_file_op()),
811 did_rename: Some(php_file_op()),
812 did_create: Some(php_file_op()),
813 will_delete: Some(php_file_op()),
814 did_delete: Some(php_file_op()),
815 ..Default::default()
816 }),
817 }),
818 linked_editing_range_provider: feat
819 .linked_editing_range
820 .then_some(LinkedEditingRangeServerCapabilities::Simple(true)),
821 moniker_provider: Some(OneOf::Left(true)),
822 inline_value_provider: feat.inline_values.then(|| {
823 OneOf::Right(InlineValueServerCapabilities::Options(InlineValueOptions {
824 work_done_progress_options: Default::default(),
825 }))
826 }),
827 ..Default::default()
828 },
829 ..Default::default()
830 })
831 }
832
833 async fn initialized(&self, _params: InitializedParams) {
834 let php_selector = serde_json::json!([{"language": "php"}]);
836 let registrations = vec![
837 Registration {
838 id: "php-lsp-file-watcher".to_string(),
839 method: "workspace/didChangeWatchedFiles".to_string(),
840 register_options: Some(serde_json::json!({
841 "watchers": [{"globPattern": "**/*.php"}]
842 })),
843 },
844 Registration {
847 id: "php-lsp-type-hierarchy".to_string(),
848 method: "textDocument/prepareTypeHierarchy".to_string(),
849 register_options: Some(serde_json::json!({"documentSelector": php_selector})),
850 },
851 Registration {
853 id: "php-lsp-config-change".to_string(),
854 method: "workspace/didChangeConfiguration".to_string(),
855 register_options: Some(serde_json::json!({"section": "php-lsp"})),
856 },
857 ];
858 self.client.register_capability(registrations).await.ok();
859
860 let roots = self.root_paths.read().unwrap().clone();
863 if !roots.is_empty() {
864 {
866 let mut merged = Psr4Map::empty();
867 for root in &roots {
868 merged.extend(Psr4Map::load(root));
869 }
870 *self.psr4.write().unwrap() = merged;
871 }
872 *self.meta.write().unwrap() = PhpStormMeta::load(&roots[0]);
874
875 let token = NumberOrString::String("php-lsp/indexing".to_string());
877 self.client
878 .send_request::<WorkDoneProgressCreate>(WorkDoneProgressCreateParams {
879 token: token.clone(),
880 })
881 .await
882 .ok();
883
884 let docs = Arc::clone(&self.docs);
885 let open_files = self.open_files.clone();
886 let client = self.client.clone();
887 let (exclude_paths, max_indexed_files) = {
888 let cfg = self.config.read().unwrap();
889 (cfg.exclude_paths.clone(), cfg.max_indexed_files)
890 };
891 tokio::spawn(async move {
892 client
893 .send_notification::<ProgressNotification>(ProgressParams {
894 token: token.clone(),
895 value: ProgressParamsValue::WorkDone(WorkDoneProgress::Begin(
896 WorkDoneProgressBegin {
897 title: "php-lsp: indexing workspace".to_string(),
898 cancellable: Some(false),
899 message: None,
900 percentage: None,
901 },
902 )),
903 })
904 .await;
905
906 let mut total = 0usize;
907 for root in roots {
908 let cache = crate::cache::WorkspaceCache::new(&root);
914 total += scan_workspace(
915 root,
916 Arc::clone(&docs),
917 open_files.clone(),
918 cache,
919 &exclude_paths,
920 max_indexed_files,
921 )
922 .await;
923 }
924
925 client
926 .send_notification::<ProgressNotification>(ProgressParams {
927 token,
928 value: ProgressParamsValue::WorkDone(WorkDoneProgress::End(
929 WorkDoneProgressEnd {
930 message: Some(format!("Indexed {total} files")),
931 },
932 )),
933 })
934 .await;
935
936 client
937 .log_message(
938 MessageType::INFO,
939 format!("php-lsp: indexed {total} workspace files"),
940 )
941 .await;
942
943 send_refresh_requests(&client).await;
947
948 let warm_docs = Arc::clone(&docs);
962 tokio::task::spawn_blocking(move || {
963 warm_docs.warm_reference_index();
964 });
965 drop(docs);
966 client.send_notification::<IndexReadyNotification>(()).await;
967 });
968 }
969
970 self.client
971 .log_message(MessageType::INFO, "php-lsp ready")
972 .await;
973 }
974
975 async fn did_change_configuration(&self, _params: DidChangeConfigurationParams) {
976 let items = vec![ConfigurationItem {
979 scope_uri: None,
980 section: Some("php-lsp".to_string()),
981 }];
982 if let Ok(values) = self.client.configuration(items).await
983 && let Some(value) = values.into_iter().next()
984 {
985 let roots = self.root_paths.read().unwrap().clone();
986
987 let file_cfg = crate::autoload::load_project_config_json(&roots);
990
991 if let Some(ver) = value.get("phpVersion").and_then(|v| v.as_str())
992 && !crate::autoload::is_valid_php_version(ver)
993 {
994 self.client
995 .log_message(
996 tower_lsp::lsp_types::MessageType::WARNING,
997 format!(
998 "php-lsp: unsupported phpVersion {ver:?} — valid values: {}",
999 crate::autoload::SUPPORTED_PHP_VERSIONS.join(", ")
1000 ),
1001 )
1002 .await;
1003 }
1004
1005 let file_obj = file_cfg.as_ref().filter(|v| v.is_object());
1006 let merged = LspConfig::merge_project_configs(file_obj, Some(&value));
1007 let mut cfg = LspConfig::from_value(&merged);
1008
1009 let (ver, source) = self.resolve_php_version(cfg.php_version.as_deref());
1011 self.client
1012 .log_message(
1013 tower_lsp::lsp_types::MessageType::INFO,
1014 format!("php-lsp: using PHP {ver} ({source})"),
1015 )
1016 .await;
1017 if source != "set by editor" && !crate::autoload::is_valid_php_version(&ver) {
1020 self.client
1021 .show_message(
1022 tower_lsp::lsp_types::MessageType::WARNING,
1023 format!(
1024 "php-lsp: detected PHP {ver} is outside the supported range ({}); \
1025 analysis may be inaccurate",
1026 crate::autoload::SUPPORTED_PHP_VERSIONS.join(", ")
1027 ),
1028 )
1029 .await;
1030 }
1031 cfg.php_version = Some(ver.clone());
1032 if let Ok(pv) = ver.parse::<mir_analyzer::PhpVersion>() {
1033 self.docs.set_php_version(pv);
1034 }
1035 *self.config.write().unwrap() = cfg;
1036 send_refresh_requests(&self.client).await;
1037 }
1038 }
1039
1040 async fn did_change_workspace_folders(&self, params: DidChangeWorkspaceFoldersParams) {
1041 {
1043 let mut roots = self.root_paths.write().unwrap();
1044 for removed in ¶ms.event.removed {
1045 if let Ok(path) = removed.uri.to_file_path() {
1046 roots.retain(|r| r != &path);
1047 }
1048 }
1049 }
1050
1051 let (exclude_paths, max_indexed_files) = {
1053 let cfg = self.config.read().unwrap();
1054 (cfg.exclude_paths.clone(), cfg.max_indexed_files)
1055 };
1056 for added in ¶ms.event.added {
1057 if let Ok(path) = added.uri.to_file_path() {
1058 let is_new = {
1059 let mut roots = self.root_paths.write().unwrap();
1060 if !roots.contains(&path) {
1061 roots.push(path.clone());
1062 true
1063 } else {
1064 false
1065 }
1066 };
1067 if is_new {
1068 let docs = Arc::clone(&self.docs);
1069 let open_files = self.open_files.clone();
1070 let ex = exclude_paths.clone();
1071 let path_clone = path.clone();
1072 let client = self.client.clone();
1073 tokio::spawn(async move {
1074 let cache = crate::cache::WorkspaceCache::new(&path_clone);
1075 scan_workspace(path_clone, docs, open_files, cache, &ex, max_indexed_files)
1076 .await;
1077 send_refresh_requests(&client).await;
1078 });
1079 }
1080 }
1081 }
1082 }
1083
1084 async fn shutdown(&self) -> Result<()> {
1085 Ok(())
1086 }
1087
1088 async fn did_open(&self, params: DidOpenTextDocumentParams) {
1089 let uri = params.text_document.uri;
1090 let text = params.text_document.text;
1091
1092 self.set_open_text(uri.clone(), text.clone());
1096
1097 let docs_for_spawn = Arc::clone(&self.docs);
1098 let diag_cfg = self.config.read().unwrap().diagnostics.clone();
1099
1100 let uri_sem = uri.clone();
1105 let (parse_diags, sem_issues) = tokio::task::spawn_blocking(move || {
1106 let (_doc, parse_diags) = parse_document(&text);
1107 let sem_issues = docs_for_spawn.get_semantic_issues_salsa(&uri_sem);
1108 (parse_diags, sem_issues)
1109 })
1110 .await
1111 .unwrap_or_else(|_| (vec![], None));
1112
1113 self.set_parse_diagnostics(&uri, parse_diags.clone());
1114 let stored_source = self.get_open_text(&uri).unwrap_or_default();
1115 let doc2 = self.get_doc(&uri);
1116 let mut all_diags = parse_diags;
1117 if let Some(ref d) = doc2 {
1118 let dup_diags = duplicate_declaration_diagnostics(&stored_source, d, &diag_cfg);
1119 all_diags.extend(dup_diags);
1120 }
1121 if let Some(issues) = sem_issues {
1122 all_diags.extend(crate::semantic_diagnostics::issues_to_diagnostics(
1123 &issues, &uri, &diag_cfg,
1124 ));
1125 }
1126 self.client
1128 .publish_diagnostics(uri.clone(), all_diags, None)
1129 .await;
1130
1131 let docs_dep = Arc::clone(&self.docs);
1135 let open_files_dep = self.open_files.clone();
1136 let diag_cfg_dep = diag_cfg.clone();
1137 let opened_uri = uri.clone();
1138 let dependents = tokio::task::spawn_blocking(move || {
1139 let mut out: Vec<(Url, Vec<Diagnostic>)> = Vec::new();
1140 for other in open_files_dep.urls() {
1141 if other == opened_uri {
1142 continue;
1143 }
1144 let diags = compute_open_file_diagnostics(
1145 &docs_dep,
1146 &open_files_dep,
1147 &other,
1148 &diag_cfg_dep,
1149 );
1150 out.push((other, diags));
1151 }
1152 out
1153 })
1154 .await
1155 .unwrap_or_default();
1156 for (dep_uri, dep_diags) in dependents {
1157 self.client
1158 .publish_diagnostics(dep_uri, dep_diags, None)
1159 .await;
1160 }
1161 }
1162
1163 async fn did_change(&self, params: DidChangeTextDocumentParams) {
1164 let uri = params.text_document.uri;
1165 let text = match params.content_changes.into_iter().last() {
1166 Some(c) => c.text,
1167 None => return,
1168 };
1169
1170 let version = self.set_open_text(uri.clone(), text.clone());
1174
1175 let docs = Arc::clone(&self.docs);
1176 let open_files = self.open_files.clone();
1177 let client = self.client.clone();
1178 let diag_cfg = self.config.read().unwrap().diagnostics.clone();
1179 tokio::spawn(async move {
1180 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1183
1184 let (_doc, diagnostics) = tokio::task::spawn_blocking(move || parse_document(&text))
1185 .await
1186 .unwrap_or_else(|_| (ParsedDoc::default(), vec![]));
1187
1188 if open_files.current_version(&uri) == Some(version) {
1191 open_files.set_parse_diagnostics(&uri, diagnostics.clone());
1192
1193 let docs_sem = Arc::clone(&docs);
1199 let open_files_sem = open_files.clone();
1200 let uri_sem = uri.clone();
1201 let diag_cfg_sem = diag_cfg.clone();
1202 let extra = tokio::task::spawn_blocking(move || {
1203 let Some(d) = open_files_sem.get_doc(&docs_sem, &uri_sem) else {
1204 return Vec::<Diagnostic>::new();
1205 };
1206 let source = open_files_sem.text(&uri_sem).unwrap_or_default();
1207 let mut out = Vec::new();
1208 if let Some(issues) = docs_sem.get_semantic_issues_salsa(&uri_sem) {
1209 out.extend(crate::semantic_diagnostics::issues_to_diagnostics(
1210 &issues,
1211 &uri_sem,
1212 &diag_cfg_sem,
1213 ));
1214 }
1215 out.extend(duplicate_declaration_diagnostics(
1216 &source,
1217 &d,
1218 &diag_cfg_sem,
1219 ));
1220 out
1221 })
1222 .await
1223 .unwrap_or_default();
1224
1225 let mut all_diags = diagnostics;
1226 all_diags.extend(extra);
1227 client
1232 .publish_diagnostics(uri.clone(), all_diags, None)
1233 .await;
1234
1235 let docs_dep = Arc::clone(&docs);
1244 let open_files_dep = open_files.clone();
1245 let diag_cfg_dep = diag_cfg.clone();
1246 let changed_uri = uri.clone();
1247 let dependents = tokio::task::spawn_blocking(move || {
1248 let mut out: Vec<(Url, Vec<Diagnostic>)> = Vec::new();
1249 for other in open_files_dep.urls() {
1250 if other == changed_uri {
1251 continue;
1252 }
1253 let diags = compute_open_file_diagnostics(
1254 &docs_dep,
1255 &open_files_dep,
1256 &other,
1257 &diag_cfg_dep,
1258 );
1259 out.push((other, diags));
1260 }
1261 out
1262 })
1263 .await
1264 .unwrap_or_default();
1265 for (dep_uri, dep_diags) in dependents {
1266 client.publish_diagnostics(dep_uri, dep_diags, None).await;
1267 }
1268 }
1269 });
1270 }
1271
1272 async fn did_close(&self, params: DidCloseTextDocumentParams) {
1273 let uri = params.text_document.uri;
1274 self.close_open_file(&uri);
1275 self.client.publish_diagnostics(uri, vec![], None).await;
1277 }
1278
1279 async fn will_save(&self, _params: WillSaveTextDocumentParams) {}
1280
1281 async fn will_save_wait_until(
1282 &self,
1283 params: WillSaveTextDocumentParams,
1284 ) -> Result<Option<Vec<TextEdit>>> {
1285 let source = self
1286 .get_open_text(¶ms.text_document.uri)
1287 .unwrap_or_default();
1288 Ok(format_document(&source))
1289 }
1290
1291 async fn did_save(&self, params: DidSaveTextDocumentParams) {
1292 let uri = params.text_document.uri;
1293 let diag_cfg = self.config.read().unwrap().diagnostics.clone();
1299 let all = compute_open_file_diagnostics(&self.docs, &self.open_files, &uri, &diag_cfg);
1300 self.client.publish_diagnostics(uri, all, None).await;
1301 }
1302
1303 async fn did_change_watched_files(&self, params: DidChangeWatchedFilesParams) {
1304 for change in params.changes {
1305 match change.typ {
1306 FileChangeType::CREATED | FileChangeType::CHANGED => {
1307 if let Ok(path) = change.uri.to_file_path()
1308 && let Ok(text) = tokio::fs::read_to_string(&path).await
1309 {
1310 let (doc, diags) = parse_document(&text);
1315 self.index_from_doc_if_not_open(change.uri.clone(), &doc, diags);
1316 }
1317 }
1318 FileChangeType::DELETED => {
1319 self.docs.remove(&change.uri);
1320 }
1321 _ => {}
1322 }
1323 }
1324 send_refresh_requests(&self.client).await;
1326 }
1327
1328 async fn completion(&self, params: CompletionParams) -> Result<Option<CompletionResponse>> {
1329 let uri = ¶ms.text_document_position.text_document.uri;
1330 let position = params.text_document_position.position;
1331 let source = self.get_open_text(uri).unwrap_or_default();
1332 let doc = match self.get_doc(uri) {
1334 Some(d) => d,
1335 None => return Ok(Some(CompletionResponse::Array(vec![]))),
1336 };
1337 let other_with_returns = self.docs.other_docs_with_returns(uri, &self.open_urls());
1338 let other_docs: Vec<Arc<ParsedDoc>> = other_with_returns
1339 .iter()
1340 .map(|(_, d, _)| d.clone())
1341 .collect();
1342 let other_returns: Vec<Arc<crate::ast::MethodReturnsMap>> = other_with_returns
1343 .iter()
1344 .map(|(_, _, r)| r.clone())
1345 .collect();
1346 let doc_returns = self.docs.get_method_returns_salsa(uri);
1347 let trigger = params
1348 .context
1349 .as_ref()
1350 .and_then(|c| c.trigger_character.as_deref());
1351 let meta_guard = self.meta.read().unwrap();
1352 let meta_opt = if meta_guard.is_empty() {
1353 None
1354 } else {
1355 Some(&*meta_guard)
1356 };
1357 let imports = self.file_imports(uri);
1358 let ctx = CompletionCtx {
1359 source: Some(&source),
1360 position: Some(position),
1361 meta: meta_opt,
1362 doc_uri: Some(uri),
1363 file_imports: Some(&imports),
1364 doc_returns: doc_returns.as_deref(),
1365 other_returns: Some(&other_returns),
1366 };
1367 Ok(Some(CompletionResponse::Array(filtered_completions_at(
1368 &doc,
1369 &other_docs,
1370 trigger,
1371 &ctx,
1372 ))))
1373 }
1374
1375 async fn completion_resolve(&self, mut item: CompletionItem) -> Result<CompletionItem> {
1376 if item.documentation.is_some() && item.detail.is_some() {
1377 return Ok(item);
1378 }
1379 let name = item.label.trim_end_matches(':');
1381 let all_indexes = self.docs.all_indexes();
1382 if item.detail.is_none()
1383 && let Some(sig) = signature_for_symbol_from_index(name, &all_indexes)
1384 {
1385 item.detail = Some(sig);
1386 }
1387 if item.documentation.is_none()
1388 && let Some(md) = docs_for_symbol_from_index(name, &all_indexes)
1389 {
1390 item.documentation = Some(Documentation::MarkupContent(MarkupContent {
1391 kind: MarkupKind::Markdown,
1392 value: md,
1393 }));
1394 }
1395 Ok(item)
1396 }
1397
1398 async fn goto_definition(
1399 &self,
1400 params: GotoDefinitionParams,
1401 ) -> Result<Option<GotoDefinitionResponse>> {
1402 let uri = ¶ms.text_document_position_params.text_document.uri;
1403 let position = params.text_document_position_params.position;
1404 let source = self.get_open_text(uri).unwrap_or_default();
1405 let doc = match self.get_doc(uri) {
1406 Some(d) => d,
1407 None => return Ok(None),
1408 };
1409 let empty_other_docs: Vec<(Url, Arc<ParsedDoc>)> = vec![];
1411 if let Some(loc) = goto_definition(uri, &source, &doc, &empty_other_docs, position) {
1412 return Ok(Some(GotoDefinitionResponse::Scalar(loc)));
1413 }
1414 let other_indexes = self.docs.other_indexes(uri);
1416 if let Some(word) = crate::util::word_at(&source, position)
1417 && let Some(loc) = find_in_indexes(&word, &other_indexes)
1418 {
1419 return Ok(Some(GotoDefinitionResponse::Scalar(loc)));
1420 }
1421
1422 if let Some(word) = word_at(&source, position)
1424 && word.contains('\\')
1425 && let Some(loc) = self.psr4_goto(&word).await
1426 {
1427 return Ok(Some(GotoDefinitionResponse::Scalar(loc)));
1428 }
1429
1430 Ok(None)
1431 }
1432
1433 async fn references(&self, params: ReferenceParams) -> Result<Option<Vec<Location>>> {
1434 let uri = ¶ms.text_document_position.text_document.uri;
1435 let position = params.text_document_position.position;
1436 let source = self.get_open_text(uri).unwrap_or_default();
1437 let word = match word_at(&source, position) {
1438 Some(w) => w,
1439 None => return Ok(None),
1440 };
1441 if word == "__construct"
1448 && let Some(doc) = self.get_doc(uri)
1449 && let Some(class_name) =
1450 class_name_at_construct_decl(doc.source(), &doc.program().stmts, position)
1451 {
1452 let all_docs = self.docs.all_docs_for_scan();
1453 let include_declaration = params.context.include_declaration;
1454 let short_name = class_name
1460 .rsplit('\\')
1461 .next()
1462 .unwrap_or(class_name.as_str())
1463 .to_owned();
1464 let class_fqn = if class_name.contains('\\') {
1465 Some(class_name.as_str())
1466 } else {
1467 None
1468 };
1469 let mut locations = find_constructor_references(&short_name, &all_docs, class_fqn);
1473 if include_declaration {
1474 let end = Position {
1480 line: position.line,
1481 character: position.character + "__construct".len() as u32,
1482 };
1483 locations.push(Location {
1484 uri: uri.clone(),
1485 range: Range {
1486 start: position,
1487 end,
1488 },
1489 });
1490 }
1491 return Ok(if locations.is_empty() {
1492 None
1493 } else {
1494 Some(locations)
1495 });
1496 }
1497
1498 let doc_opt = self.get_doc(uri);
1499 let (word, kind) = if let Some(doc) = &doc_opt
1503 && let Some(prop_name) =
1504 promoted_property_at_cursor(doc.source(), &doc.program().stmts, position)
1505 {
1506 (prop_name, Some(SymbolKind::Property))
1507 } else if let Some(doc) = &doc_opt {
1508 let stmts = &doc.program().stmts;
1509 if cursor_is_on_method_decl(doc.source(), stmts, position) {
1510 (word, Some(SymbolKind::Method))
1511 } else if let Some(prop_name) =
1512 cursor_is_on_property_decl(doc.source(), stmts, position)
1513 {
1514 (prop_name, Some(SymbolKind::Property))
1515 } else {
1516 let k = symbol_kind_at(&source, position, &word);
1517 (word, k)
1518 }
1519 } else {
1520 let k = symbol_kind_at(&source, position, &word);
1521 (word, k)
1522 };
1523 let all_docs = self.docs.all_docs_for_scan();
1524 let include_declaration = params.context.include_declaration;
1525
1526 let target_fqn: Option<String> = doc_opt.as_ref().and_then(|doc| {
1531 let imports = self.file_imports(uri);
1532 match kind {
1533 Some(SymbolKind::Function) | Some(SymbolKind::Class) => {
1534 let resolved = crate::moniker::resolve_fqn(doc, &word, &imports);
1535 if resolved.contains('\\') {
1536 Some(resolved)
1537 } else {
1538 None
1539 }
1540 }
1541 Some(SymbolKind::Method) => {
1542 let short_owner =
1544 crate::type_map::enclosing_class_at(doc.source(), doc, position)?;
1545 Some(crate::moniker::resolve_fqn(doc, &short_owner, &imports))
1547 }
1548 _ => None,
1549 }
1550 });
1551
1552 let locations = {
1557 let cb = self.codebase();
1558 let docs = Arc::clone(&self.docs);
1559 let lookup = move |key: &str| docs.get_symbol_refs_salsa(key);
1560 find_references_codebase_with_target(
1561 &word,
1562 &all_docs,
1563 include_declaration,
1564 kind,
1565 target_fqn.as_deref(),
1566 &cb,
1567 &lookup,
1568 )
1569 .unwrap_or_else(|| match target_fqn.as_deref() {
1570 Some(t) => {
1571 find_references_with_target(&word, &all_docs, include_declaration, kind, t)
1572 }
1573 None => find_references(&word, &all_docs, include_declaration, kind),
1574 })
1575 };
1576
1577 Ok(if locations.is_empty() {
1578 None
1579 } else {
1580 Some(locations)
1581 })
1582 }
1583
1584 async fn prepare_rename(
1585 &self,
1586 params: TextDocumentPositionParams,
1587 ) -> Result<Option<PrepareRenameResponse>> {
1588 let uri = ¶ms.text_document.uri;
1589 let source = self.get_open_text(uri).unwrap_or_default();
1590 Ok(prepare_rename(&source, params.position).map(PrepareRenameResponse::Range))
1591 }
1592
1593 async fn rename(&self, params: RenameParams) -> Result<Option<WorkspaceEdit>> {
1594 let uri = ¶ms.text_document_position.text_document.uri;
1595 let position = params.text_document_position.position;
1596 let source = self.get_open_text(uri).unwrap_or_default();
1597 let word = match word_at(&source, position) {
1598 Some(w) => w,
1599 None => return Ok(None),
1600 };
1601 if word.starts_with('$') {
1602 let doc = match self.get_doc(uri) {
1603 Some(d) => d,
1604 None => return Ok(None),
1605 };
1606 Ok(Some(rename_variable(
1607 &word,
1608 ¶ms.new_name,
1609 uri,
1610 &doc,
1611 position,
1612 )))
1613 } else if is_after_arrow(&source, position) {
1614 let all_docs = self.docs.all_docs_for_scan();
1615 Ok(Some(rename_property(&word, ¶ms.new_name, &all_docs)))
1616 } else {
1617 let all_docs = self.docs.all_docs_for_scan();
1618 Ok(Some(rename(&word, ¶ms.new_name, &all_docs)))
1619 }
1620 }
1621
1622 async fn signature_help(&self, params: SignatureHelpParams) -> Result<Option<SignatureHelp>> {
1623 let uri = ¶ms.text_document_position_params.text_document.uri;
1624 let position = params.text_document_position_params.position;
1625 let source = self.get_open_text(uri).unwrap_or_default();
1626 let doc = match self.get_doc(uri) {
1627 Some(d) => d,
1628 None => return Ok(None),
1629 };
1630 Ok(signature_help(&source, &doc, position))
1631 }
1632
1633 async fn hover(&self, params: HoverParams) -> Result<Option<Hover>> {
1634 let uri = ¶ms.text_document_position_params.text_document.uri;
1635 let position = params.text_document_position_params.position;
1636 let source = self.get_open_text(uri).unwrap_or_default();
1637 let doc = match self.get_doc(uri) {
1638 Some(d) => d,
1639 None => return Ok(None),
1640 };
1641 let doc_returns = self
1642 .docs
1643 .get_method_returns_salsa(uri)
1644 .unwrap_or_else(|| std::sync::Arc::new(Default::default()));
1645 let other_docs = self.docs.other_docs_with_returns(uri, &self.open_urls());
1646 let result = hover_info(&source, &doc, &doc_returns, position, &other_docs);
1647 if result.is_some() {
1648 return Ok(result);
1649 }
1650 let all_indexes = self.docs.all_indexes();
1655 if let Some(word) = crate::util::word_at(&source, position) {
1656 if let Some(h) = class_hover_from_index(&word, &all_indexes) {
1658 return Ok(Some(h));
1659 }
1660 if let Some(resolved) = crate::hover::resolve_use_alias(&doc.program().stmts, &word)
1662 && let Some(h) = class_hover_from_index(&resolved, &all_indexes)
1663 {
1664 return Ok(Some(h));
1665 }
1666 }
1667 Ok(None)
1668 }
1669
1670 async fn document_symbol(
1671 &self,
1672 params: DocumentSymbolParams,
1673 ) -> Result<Option<DocumentSymbolResponse>> {
1674 let uri = ¶ms.text_document.uri;
1675 let doc = match self.get_doc(uri) {
1676 Some(d) => d,
1677 None => return Ok(None),
1678 };
1679 Ok(Some(DocumentSymbolResponse::Nested(document_symbols(
1680 doc.source(),
1681 &doc,
1682 ))))
1683 }
1684
1685 async fn folding_range(&self, params: FoldingRangeParams) -> Result<Option<Vec<FoldingRange>>> {
1686 let uri = ¶ms.text_document.uri;
1687 let doc = match self.get_doc(uri) {
1688 Some(d) => d,
1689 None => return Ok(None),
1690 };
1691 let ranges = folding_ranges(doc.source(), &doc);
1692 Ok(if ranges.is_empty() {
1693 None
1694 } else {
1695 Some(ranges)
1696 })
1697 }
1698
1699 async fn inlay_hint(&self, params: InlayHintParams) -> Result<Option<Vec<InlayHint>>> {
1700 let uri = ¶ms.text_document.uri;
1701 let doc = match self.get_doc(uri) {
1702 Some(d) => d,
1703 None => return Ok(None),
1704 };
1705 let doc_returns = self.docs.get_method_returns_salsa(uri);
1706 let wi = self.docs.get_workspace_index_salsa();
1707 Ok(Some(inlay_hints(
1708 doc.source(),
1709 &doc,
1710 doc_returns.as_deref(),
1711 params.range,
1712 &wi.files,
1713 )))
1714 }
1715
1716 async fn inlay_hint_resolve(&self, mut item: InlayHint) -> Result<InlayHint> {
1717 if item.tooltip.is_some() {
1718 return Ok(item);
1719 }
1720 let func_name = item
1721 .data
1722 .as_ref()
1723 .and_then(|d| d.get("php_lsp_fn"))
1724 .and_then(|v| v.as_str())
1725 .map(str::to_string);
1726 if let Some(name) = func_name {
1727 let all_indexes = self.docs.all_indexes();
1728 if let Some(md) = docs_for_symbol_from_index(&name, &all_indexes) {
1729 item.tooltip = Some(InlayHintTooltip::MarkupContent(MarkupContent {
1730 kind: MarkupKind::Markdown,
1731 value: md,
1732 }));
1733 }
1734 }
1735 Ok(item)
1736 }
1737
1738 async fn symbol(
1739 &self,
1740 params: WorkspaceSymbolParams,
1741 ) -> Result<Option<Vec<SymbolInformation>>> {
1742 let wi = self.docs.get_workspace_index_salsa();
1746 let results = workspace_symbols_from_workspace(¶ms.query, &wi);
1747 Ok(if results.is_empty() {
1748 None
1749 } else {
1750 Some(results)
1751 })
1752 }
1753
1754 async fn symbol_resolve(&self, params: WorkspaceSymbol) -> Result<WorkspaceSymbol> {
1755 let docs = self.docs.docs_for(&self.open_urls());
1757 Ok(resolve_workspace_symbol(params, &docs))
1758 }
1759
1760 async fn semantic_tokens_full(
1761 &self,
1762 params: SemanticTokensParams,
1763 ) -> Result<Option<SemanticTokensResult>> {
1764 let uri = ¶ms.text_document.uri;
1765 let doc = match self.get_doc(uri) {
1766 Some(d) => d,
1767 None => {
1768 return Ok(Some(SemanticTokensResult::Tokens(SemanticTokens {
1769 result_id: None,
1770 data: vec![],
1771 })));
1772 }
1773 };
1774 let tokens = semantic_tokens(doc.source(), &doc);
1775 let result_id = token_hash(&tokens);
1776 self.docs
1777 .store_token_cache(uri, result_id.clone(), tokens.clone());
1778 Ok(Some(SemanticTokensResult::Tokens(SemanticTokens {
1779 result_id: Some(result_id),
1780 data: tokens,
1781 })))
1782 }
1783
1784 async fn semantic_tokens_range(
1785 &self,
1786 params: SemanticTokensRangeParams,
1787 ) -> Result<Option<SemanticTokensRangeResult>> {
1788 let uri = ¶ms.text_document.uri;
1789 let doc = match self.get_doc(uri) {
1790 Some(d) => d,
1791 None => {
1792 return Ok(Some(SemanticTokensRangeResult::Tokens(SemanticTokens {
1793 result_id: None,
1794 data: vec![],
1795 })));
1796 }
1797 };
1798 let tokens = semantic_tokens_range(doc.source(), &doc, params.range);
1799 Ok(Some(SemanticTokensRangeResult::Tokens(SemanticTokens {
1800 result_id: None,
1801 data: tokens,
1802 })))
1803 }
1804
1805 async fn semantic_tokens_full_delta(
1806 &self,
1807 params: SemanticTokensDeltaParams,
1808 ) -> Result<Option<SemanticTokensFullDeltaResult>> {
1809 let uri = ¶ms.text_document.uri;
1810 let doc = match self.get_doc(uri) {
1811 Some(d) => d,
1812 None => return Ok(None),
1813 };
1814
1815 let new_tokens = semantic_tokens(doc.source(), &doc);
1816 let new_result_id = token_hash(&new_tokens);
1817 let prev_id = ¶ms.previous_result_id;
1818
1819 let result = match self.docs.get_token_cache(uri, prev_id) {
1820 Some(old_tokens) => {
1821 let edits = compute_token_delta(&old_tokens, &new_tokens);
1822 SemanticTokensFullDeltaResult::TokensDelta(SemanticTokensDelta {
1823 result_id: Some(new_result_id.clone()),
1824 edits,
1825 })
1826 }
1827 None => SemanticTokensFullDeltaResult::Tokens(SemanticTokens {
1829 result_id: Some(new_result_id.clone()),
1830 data: new_tokens.clone(),
1831 }),
1832 };
1833
1834 self.docs.store_token_cache(uri, new_result_id, new_tokens);
1835 Ok(Some(result))
1836 }
1837
1838 async fn selection_range(
1839 &self,
1840 params: SelectionRangeParams,
1841 ) -> Result<Option<Vec<SelectionRange>>> {
1842 let uri = ¶ms.text_document.uri;
1843 let doc = match self.get_doc(uri) {
1844 Some(d) => d,
1845 None => return Ok(None),
1846 };
1847 let ranges = selection_ranges(doc.source(), &doc, ¶ms.positions);
1848 Ok(if ranges.is_empty() {
1849 None
1850 } else {
1851 Some(ranges)
1852 })
1853 }
1854
1855 async fn prepare_call_hierarchy(
1856 &self,
1857 params: CallHierarchyPrepareParams,
1858 ) -> Result<Option<Vec<CallHierarchyItem>>> {
1859 let uri = ¶ms.text_document_position_params.text_document.uri;
1860 let position = params.text_document_position_params.position;
1861 let source = self.get_open_text(uri).unwrap_or_default();
1862 let word = match word_at(&source, position) {
1863 Some(w) => w,
1864 None => return Ok(None),
1865 };
1866 let all_docs = self.docs.all_docs_for_scan();
1867 Ok(prepare_call_hierarchy(&word, &all_docs).map(|item| vec![item]))
1868 }
1869
1870 async fn incoming_calls(
1871 &self,
1872 params: CallHierarchyIncomingCallsParams,
1873 ) -> Result<Option<Vec<CallHierarchyIncomingCall>>> {
1874 let all_docs = self.docs.all_docs_for_scan();
1875 let calls = incoming_calls(¶ms.item, &all_docs);
1876 Ok(if calls.is_empty() { None } else { Some(calls) })
1877 }
1878
1879 async fn outgoing_calls(
1880 &self,
1881 params: CallHierarchyOutgoingCallsParams,
1882 ) -> Result<Option<Vec<CallHierarchyOutgoingCall>>> {
1883 let all_docs = self.docs.all_docs_for_scan();
1884 let calls = outgoing_calls(¶ms.item, &all_docs);
1885 Ok(if calls.is_empty() { None } else { Some(calls) })
1886 }
1887
1888 async fn document_highlight(
1889 &self,
1890 params: DocumentHighlightParams,
1891 ) -> Result<Option<Vec<DocumentHighlight>>> {
1892 let uri = ¶ms.text_document_position_params.text_document.uri;
1893 let position = params.text_document_position_params.position;
1894 let source = self.get_open_text(uri).unwrap_or_default();
1895 let doc = match self.get_doc(uri) {
1896 Some(d) => d,
1897 None => return Ok(None),
1898 };
1899 let highlights = document_highlights(&source, &doc, position);
1900 Ok(if highlights.is_empty() {
1901 None
1902 } else {
1903 Some(highlights)
1904 })
1905 }
1906
1907 async fn linked_editing_range(
1908 &self,
1909 params: LinkedEditingRangeParams,
1910 ) -> Result<Option<LinkedEditingRanges>> {
1911 let uri = ¶ms.text_document_position_params.text_document.uri;
1912 let position = params.text_document_position_params.position;
1913 let source = self.get_open_text(uri).unwrap_or_default();
1914 let doc = match self.get_doc(uri) {
1915 Some(d) => d,
1916 None => return Ok(None),
1917 };
1918 let highlights = document_highlights(&source, &doc, position);
1920 if highlights.is_empty() {
1921 return Ok(None);
1922 }
1923 let ranges: Vec<Range> = highlights.into_iter().map(|h| h.range).collect();
1924 Ok(Some(LinkedEditingRanges {
1925 ranges,
1926 word_pattern: Some(r"[$a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*".to_string()),
1928 }))
1929 }
1930
1931 async fn goto_implementation(
1932 &self,
1933 params: tower_lsp::lsp_types::request::GotoImplementationParams,
1934 ) -> Result<Option<tower_lsp::lsp_types::request::GotoImplementationResponse>> {
1935 let uri = ¶ms.text_document_position_params.text_document.uri;
1936 let position = params.text_document_position_params.position;
1937 let source = self.get_open_text(uri).unwrap_or_default();
1938 let imports = self.file_imports(uri);
1939 let word = crate::util::word_at(&source, position).unwrap_or_default();
1940 let fqn = imports.get(&word).map(|s| s.as_str());
1941 let open_docs = self.docs.docs_for(&self.open_urls());
1943 let mut locs = find_implementations(&word, fqn, &open_docs);
1944 if locs.is_empty() {
1945 let wi = self.docs.get_workspace_index_salsa();
1948 locs = find_implementations_from_workspace(&word, fqn, &wi);
1949 }
1950 if locs.is_empty() {
1951 Ok(None)
1952 } else {
1953 Ok(Some(GotoDefinitionResponse::Array(locs)))
1954 }
1955 }
1956
1957 async fn goto_declaration(
1958 &self,
1959 params: tower_lsp::lsp_types::request::GotoDeclarationParams,
1960 ) -> Result<Option<tower_lsp::lsp_types::request::GotoDeclarationResponse>> {
1961 let uri = ¶ms.text_document_position_params.text_document.uri;
1962 let position = params.text_document_position_params.position;
1963 let source = self.get_open_text(uri).unwrap_or_default();
1964 let open_docs = self.docs.docs_for(&self.open_urls());
1966 if let Some(loc) = goto_declaration(&source, &open_docs, position) {
1967 return Ok(Some(GotoDefinitionResponse::Scalar(loc)));
1968 }
1969 let all_indexes = self.docs.all_indexes();
1971 Ok(goto_declaration_from_index(&source, &all_indexes, position)
1972 .map(GotoDefinitionResponse::Scalar))
1973 }
1974
1975 async fn goto_type_definition(
1976 &self,
1977 params: tower_lsp::lsp_types::request::GotoTypeDefinitionParams,
1978 ) -> Result<Option<tower_lsp::lsp_types::request::GotoTypeDefinitionResponse>> {
1979 let uri = ¶ms.text_document_position_params.text_document.uri;
1980 let position = params.text_document_position_params.position;
1981 let source = self.get_open_text(uri).unwrap_or_default();
1982 let doc = match self.get_doc(uri) {
1983 Some(d) => d,
1984 None => return Ok(None),
1985 };
1986 let doc_returns = self.docs.get_method_returns_salsa(uri);
1987 let open_docs = self.docs.docs_for(&self.open_urls());
1989 if let Some(loc) =
1990 goto_type_definition(&source, &doc, doc_returns.as_deref(), &open_docs, position)
1991 {
1992 return Ok(Some(GotoDefinitionResponse::Scalar(loc)));
1993 }
1994 let all_indexes = self.docs.all_indexes();
1996 Ok(goto_type_definition_from_index(
1997 &source,
1998 &doc,
1999 doc_returns.as_deref(),
2000 &all_indexes,
2001 position,
2002 )
2003 .map(GotoDefinitionResponse::Scalar))
2004 }
2005
2006 async fn prepare_type_hierarchy(
2007 &self,
2008 params: TypeHierarchyPrepareParams,
2009 ) -> Result<Option<Vec<TypeHierarchyItem>>> {
2010 let uri = ¶ms.text_document_position_params.text_document.uri;
2011 let position = params.text_document_position_params.position;
2012 let source = self.get_open_text(uri).unwrap_or_default();
2013 let wi = self.docs.get_workspace_index_salsa();
2015 Ok(prepare_type_hierarchy_from_workspace(&source, &wi, position).map(|item| vec![item]))
2016 }
2017
2018 async fn supertypes(
2019 &self,
2020 params: TypeHierarchySupertypesParams,
2021 ) -> Result<Option<Vec<TypeHierarchyItem>>> {
2022 let wi = self.docs.get_workspace_index_salsa();
2024 let result = supertypes_of_from_workspace(¶ms.item, &wi);
2025 Ok(if result.is_empty() {
2026 None
2027 } else {
2028 Some(result)
2029 })
2030 }
2031
2032 async fn subtypes(
2033 &self,
2034 params: TypeHierarchySubtypesParams,
2035 ) -> Result<Option<Vec<TypeHierarchyItem>>> {
2036 let wi = self.docs.get_workspace_index_salsa();
2038 let result = subtypes_of_from_workspace(¶ms.item, &wi);
2039 Ok(if result.is_empty() {
2040 None
2041 } else {
2042 Some(result)
2043 })
2044 }
2045
2046 async fn code_lens(&self, params: CodeLensParams) -> Result<Option<Vec<CodeLens>>> {
2047 let uri = ¶ms.text_document.uri;
2048 let doc = match self.get_doc(uri) {
2049 Some(d) => d,
2050 None => return Ok(None),
2051 };
2052 let all_docs = self.docs.all_docs_for_scan();
2053 let lenses = code_lenses(uri, &doc, &all_docs);
2054 Ok(if lenses.is_empty() {
2055 None
2056 } else {
2057 Some(lenses)
2058 })
2059 }
2060
2061 async fn code_lens_resolve(&self, params: CodeLens) -> Result<CodeLens> {
2062 Ok(params)
2064 }
2065
2066 async fn document_link(&self, params: DocumentLinkParams) -> Result<Option<Vec<DocumentLink>>> {
2067 let uri = ¶ms.text_document.uri;
2068 let doc = match self.get_doc(uri) {
2069 Some(d) => d,
2070 None => return Ok(None),
2071 };
2072 let links = document_links(uri, &doc, doc.source());
2073 Ok(if links.is_empty() { None } else { Some(links) })
2074 }
2075
2076 async fn document_link_resolve(&self, params: DocumentLink) -> Result<DocumentLink> {
2077 Ok(params)
2079 }
2080
2081 async fn formatting(&self, params: DocumentFormattingParams) -> Result<Option<Vec<TextEdit>>> {
2082 let uri = ¶ms.text_document.uri;
2083 let source = self.get_open_text(uri).unwrap_or_default();
2084 Ok(format_document(&source))
2085 }
2086
2087 async fn range_formatting(
2088 &self,
2089 params: DocumentRangeFormattingParams,
2090 ) -> Result<Option<Vec<TextEdit>>> {
2091 let uri = ¶ms.text_document.uri;
2092 let source = self.get_open_text(uri).unwrap_or_default();
2093 Ok(format_range(&source, params.range))
2094 }
2095
2096 async fn on_type_formatting(
2097 &self,
2098 params: DocumentOnTypeFormattingParams,
2099 ) -> Result<Option<Vec<TextEdit>>> {
2100 let uri = ¶ms.text_document_position.text_document.uri;
2101 let source = self.get_open_text(uri).unwrap_or_default();
2102 let edits = on_type_format(
2103 &source,
2104 params.text_document_position.position,
2105 ¶ms.ch,
2106 ¶ms.options,
2107 );
2108 Ok(if edits.is_empty() { None } else { Some(edits) })
2109 }
2110
2111 async fn execute_command(
2112 &self,
2113 params: ExecuteCommandParams,
2114 ) -> Result<Option<serde_json::Value>> {
2115 match params.command.as_str() {
2116 "php-lsp.runTest" => {
2117 let file_uri = params
2119 .arguments
2120 .first()
2121 .and_then(|v| v.as_str())
2122 .and_then(|s| Url::parse(s).ok());
2123 let filter = params
2124 .arguments
2125 .get(1)
2126 .and_then(|v| v.as_str())
2127 .unwrap_or("")
2128 .to_string();
2129
2130 let root = self.root_paths.read().unwrap().first().cloned();
2131 let client = self.client.clone();
2132
2133 tokio::spawn(async move {
2134 run_phpunit(&client, &filter, root.as_deref(), file_uri.as_ref()).await;
2135 });
2136
2137 Ok(None)
2138 }
2139 _ => Ok(None),
2140 }
2141 }
2142
2143 async fn will_rename_files(&self, params: RenameFilesParams) -> Result<Option<WorkspaceEdit>> {
2144 let psr4 = self.psr4.read().unwrap();
2145 let all_docs = self.docs.all_docs_for_scan();
2146 let mut merged_changes: std::collections::HashMap<
2147 tower_lsp::lsp_types::Url,
2148 Vec<tower_lsp::lsp_types::TextEdit>,
2149 > = std::collections::HashMap::new();
2150
2151 for file_rename in ¶ms.files {
2152 let old_path = tower_lsp::lsp_types::Url::parse(&file_rename.old_uri)
2153 .ok()
2154 .and_then(|u| u.to_file_path().ok());
2155 let new_path = tower_lsp::lsp_types::Url::parse(&file_rename.new_uri)
2156 .ok()
2157 .and_then(|u| u.to_file_path().ok());
2158
2159 let (Some(old_path), Some(new_path)) = (old_path, new_path) else {
2160 continue;
2161 };
2162
2163 let old_fqn = psr4.file_to_fqn(&old_path);
2164 let new_fqn = psr4.file_to_fqn(&new_path);
2165
2166 let (Some(old_fqn), Some(new_fqn)) = (old_fqn, new_fqn) else {
2167 continue;
2168 };
2169
2170 let edit = use_edits_for_rename(&old_fqn, &new_fqn, &all_docs);
2171 if let Some(changes) = edit.changes {
2172 for (uri, edits) in changes {
2173 merged_changes.entry(uri).or_default().extend(edits);
2174 }
2175 }
2176 }
2177
2178 Ok(if merged_changes.is_empty() {
2179 None
2180 } else {
2181 Some(WorkspaceEdit {
2182 changes: Some(merged_changes),
2183 ..Default::default()
2184 })
2185 })
2186 }
2187
2188 async fn did_rename_files(&self, params: RenameFilesParams) {
2189 for file_rename in ¶ms.files {
2190 if let Ok(old_uri) = tower_lsp::lsp_types::Url::parse(&file_rename.old_uri) {
2192 self.docs.remove(&old_uri);
2193 }
2194 if let Ok(new_uri) = tower_lsp::lsp_types::Url::parse(&file_rename.new_uri)
2196 && let Ok(path) = new_uri.to_file_path()
2197 && let Ok(text) = tokio::fs::read_to_string(&path).await
2198 {
2199 self.index_if_not_open(new_uri, &text);
2200 }
2201 }
2202 }
2203
2204 async fn will_create_files(&self, params: CreateFilesParams) -> Result<Option<WorkspaceEdit>> {
2207 let psr4 = self.psr4.read().unwrap();
2208 let mut changes: std::collections::HashMap<Url, Vec<TextEdit>> =
2209 std::collections::HashMap::new();
2210
2211 for file in ¶ms.files {
2212 let Ok(uri) = Url::parse(&file.uri) else {
2213 continue;
2214 };
2215 if !uri.path().ends_with(".php") {
2218 continue;
2219 }
2220
2221 let stub = if let Ok(path) = uri.to_file_path()
2222 && let Some(fqn) = psr4.file_to_fqn(&path)
2223 {
2224 let (ns, class_name) = match fqn.rfind('\\') {
2225 Some(pos) => (&fqn[..pos], &fqn[pos + 1..]),
2226 None => ("", fqn.as_str()),
2227 };
2228 if ns.is_empty() {
2229 format!("<?php\n\ndeclare(strict_types=1);\n\nclass {class_name}\n{{\n}}\n")
2230 } else {
2231 format!(
2232 "<?php\n\ndeclare(strict_types=1);\n\nnamespace {ns};\n\nclass {class_name}\n{{\n}}\n"
2233 )
2234 }
2235 } else {
2236 "<?php\n\n".to_string()
2237 };
2238
2239 changes.insert(
2240 uri,
2241 vec![TextEdit {
2242 range: Range {
2243 start: Position {
2244 line: 0,
2245 character: 0,
2246 },
2247 end: Position {
2248 line: 0,
2249 character: 0,
2250 },
2251 },
2252 new_text: stub,
2253 }],
2254 );
2255 }
2256
2257 Ok(if changes.is_empty() {
2258 None
2259 } else {
2260 Some(WorkspaceEdit {
2261 changes: Some(changes),
2262 ..Default::default()
2263 })
2264 })
2265 }
2266
2267 async fn did_create_files(&self, params: CreateFilesParams) {
2268 for file in ¶ms.files {
2269 if let Ok(uri) = Url::parse(&file.uri)
2270 && let Ok(path) = uri.to_file_path()
2271 && let Ok(text) = tokio::fs::read_to_string(&path).await
2272 {
2273 self.index_if_not_open(uri, &text);
2274 }
2275 }
2276 send_refresh_requests(&self.client).await;
2277 }
2278
2279 async fn will_delete_files(&self, params: DeleteFilesParams) -> Result<Option<WorkspaceEdit>> {
2284 let psr4 = self.psr4.read().unwrap();
2285 let all_docs = self.docs.all_docs_for_scan();
2286 let mut merged_changes: std::collections::HashMap<Url, Vec<TextEdit>> =
2287 std::collections::HashMap::new();
2288
2289 for file in ¶ms.files {
2290 let path = Url::parse(&file.uri)
2291 .ok()
2292 .and_then(|u| u.to_file_path().ok());
2293 let Some(path) = path else { continue };
2294 let Some(fqn) = psr4.file_to_fqn(&path) else {
2295 continue;
2296 };
2297
2298 let edit = use_edits_for_delete(&fqn, &all_docs);
2299 if let Some(changes) = edit.changes {
2300 for (uri, edits) in changes {
2301 merged_changes.entry(uri).or_default().extend(edits);
2302 }
2303 }
2304 }
2305
2306 Ok(if merged_changes.is_empty() {
2307 None
2308 } else {
2309 Some(WorkspaceEdit {
2310 changes: Some(merged_changes),
2311 ..Default::default()
2312 })
2313 })
2314 }
2315
2316 async fn did_delete_files(&self, params: DeleteFilesParams) {
2317 for file in ¶ms.files {
2318 if let Ok(uri) = Url::parse(&file.uri) {
2319 self.docs.remove(&uri);
2320 self.client.publish_diagnostics(uri, vec![], None).await;
2322 }
2323 }
2324 send_refresh_requests(&self.client).await;
2325 }
2326
2327 async fn moniker(&self, params: MonikerParams) -> Result<Option<Vec<Moniker>>> {
2330 let uri = ¶ms.text_document_position_params.text_document.uri;
2331 let position = params.text_document_position_params.position;
2332 let source = self.get_open_text(uri).unwrap_or_default();
2333 let doc = match self.get_doc(uri) {
2334 Some(d) => d,
2335 None => return Ok(None),
2336 };
2337 let imports = self.file_imports(uri);
2338 Ok(moniker_at(&source, &doc, position, &imports).map(|m| vec![m]))
2339 }
2340
2341 async fn inline_value(&self, params: InlineValueParams) -> Result<Option<Vec<InlineValue>>> {
2344 let uri = ¶ms.text_document.uri;
2345 let source = self.get_open_text(uri).unwrap_or_default();
2346 let values = inline_values_in_range(&source, params.range);
2347 Ok(if values.is_empty() {
2348 None
2349 } else {
2350 Some(values)
2351 })
2352 }
2353
2354 async fn diagnostic(
2355 &self,
2356 params: DocumentDiagnosticParams,
2357 ) -> Result<DocumentDiagnosticReportResult> {
2358 let uri = ¶ms.text_document.uri;
2359 let source = self.get_open_text(uri).unwrap_or_default();
2360
2361 let parse_diags = self.get_parse_diagnostics(uri).unwrap_or_default();
2362 let doc = match self.get_doc(uri) {
2363 Some(d) => d,
2364 None => {
2365 return Ok(DocumentDiagnosticReportResult::Report(
2366 DocumentDiagnosticReport::Full(RelatedFullDocumentDiagnosticReport {
2367 related_documents: None,
2368 full_document_diagnostic_report: FullDocumentDiagnosticReport {
2369 result_id: None,
2370 items: parse_diags,
2371 },
2372 }),
2373 ));
2374 }
2375 };
2376 let (diag_cfg, php_version) = {
2377 let cfg = self.config.read().unwrap();
2378 (cfg.diagnostics.clone(), cfg.php_version.clone())
2379 };
2380 let _ = php_version.as_deref();
2381 let docs = Arc::clone(&self.docs);
2383 let uri_owned = uri.clone();
2384 let diag_cfg_sem = diag_cfg.clone();
2385 let sem_diags = tokio::task::spawn_blocking(move || {
2386 docs.get_semantic_issues_salsa(&uri_owned)
2387 .map(|issues| {
2388 crate::semantic_diagnostics::issues_to_diagnostics(
2389 &issues,
2390 &uri_owned,
2391 &diag_cfg_sem,
2392 )
2393 })
2394 .unwrap_or_default()
2395 })
2396 .await
2397 .unwrap_or_default();
2398 let dup_diags = duplicate_declaration_diagnostics(&source, &doc, &diag_cfg);
2399
2400 let mut items = parse_diags;
2401 items.extend(sem_diags);
2402 items.extend(dup_diags);
2403
2404 Ok(DocumentDiagnosticReportResult::Report(
2405 DocumentDiagnosticReport::Full(RelatedFullDocumentDiagnosticReport {
2406 related_documents: None,
2407 full_document_diagnostic_report: FullDocumentDiagnosticReport {
2408 result_id: None,
2409 items,
2410 },
2411 }),
2412 ))
2413 }
2414
2415 async fn workspace_diagnostic(
2416 &self,
2417 _params: WorkspaceDiagnosticParams,
2418 ) -> Result<WorkspaceDiagnosticReportResult> {
2419 let all_parse_diags = self.all_open_files_with_diagnostics();
2420 let (diag_cfg, php_version) = {
2421 let cfg = self.config.read().unwrap();
2422 (cfg.diagnostics.clone(), cfg.php_version.clone())
2423 };
2424
2425 let _ = php_version.as_deref();
2433 let docs = Arc::clone(&self.docs);
2434 let diag_cfg_sweep = diag_cfg.clone();
2435 let items = tokio::task::spawn_blocking(move || {
2436 all_parse_diags
2437 .into_iter()
2438 .filter_map(|(uri, parse_diags, version)| {
2439 let doc = docs.get_doc_salsa(&uri)?;
2440
2441 let source = doc.source().to_string();
2442 let sem_diags = docs
2443 .get_semantic_issues_salsa(&uri)
2444 .map(|issues| {
2445 crate::semantic_diagnostics::issues_to_diagnostics(
2446 &issues,
2447 &uri,
2448 &diag_cfg_sweep,
2449 )
2450 })
2451 .unwrap_or_default();
2452 let dup_diags =
2453 duplicate_declaration_diagnostics(&source, &doc, &diag_cfg_sweep);
2454
2455 let mut all_diags = parse_diags;
2456 all_diags.extend(sem_diags);
2457 all_diags.extend(dup_diags);
2458
2459 Some(WorkspaceDocumentDiagnosticReport::Full(
2460 WorkspaceFullDocumentDiagnosticReport {
2461 uri,
2462 version,
2463 full_document_diagnostic_report: FullDocumentDiagnosticReport {
2464 result_id: None,
2465 items: all_diags,
2466 },
2467 },
2468 ))
2469 })
2470 .collect::<Vec<_>>()
2471 })
2472 .await
2473 .unwrap_or_default();
2474
2475 Ok(WorkspaceDiagnosticReportResult::Report(
2476 WorkspaceDiagnosticReport { items },
2477 ))
2478 }
2479
2480 async fn code_action(&self, params: CodeActionParams) -> Result<Option<CodeActionResponse>> {
2481 let uri = ¶ms.text_document.uri;
2482 let source = self.get_open_text(uri).unwrap_or_default();
2483 let doc = match self.get_doc(uri) {
2484 Some(d) => d,
2485 None => return Ok(None),
2486 };
2487 let other_docs = self.docs.other_docs(uri, &self.open_urls());
2488
2489 let diag_cfg = self.config.read().unwrap().diagnostics.clone();
2496 let docs_sem = Arc::clone(&self.docs);
2497 let uri_sem = uri.clone();
2498 let diag_cfg_sem = diag_cfg.clone();
2499 let sem_diags = tokio::task::spawn_blocking(move || {
2500 docs_sem
2501 .get_semantic_issues_salsa(&uri_sem)
2502 .map(|issues| {
2503 crate::semantic_diagnostics::issues_to_diagnostics(
2504 &issues,
2505 &uri_sem,
2506 &diag_cfg_sem,
2507 )
2508 })
2509 .unwrap_or_default()
2510 })
2511 .await
2512 .unwrap_or_default();
2513
2514 let mut actions: Vec<CodeActionOrCommand> = Vec::new();
2516 for diag in &sem_diags {
2517 if diag.code != Some(NumberOrString::String("UndefinedClass".to_string())) {
2518 continue;
2519 }
2520 if diag.range.start.line < params.range.start.line
2522 || diag.range.start.line > params.range.end.line
2523 {
2524 continue;
2525 }
2526 let class_name = diag
2528 .message
2529 .strip_prefix("Class ")
2530 .and_then(|s| s.strip_suffix(" does not exist"))
2531 .unwrap_or("")
2532 .trim();
2533 if class_name.is_empty() {
2534 continue;
2535 }
2536
2537 for (_other_uri, other_doc) in &other_docs {
2539 if let Some(fqn) = find_fqn_for_class(other_doc, class_name) {
2540 let edit = build_use_import_edit(&source, uri, &fqn);
2541 let action = CodeAction {
2542 title: format!("Add use {fqn}"),
2543 kind: Some(CodeActionKind::QUICKFIX),
2544 edit: Some(edit),
2545 diagnostics: Some(vec![diag.clone()]),
2546 ..Default::default()
2547 };
2548 actions.push(CodeActionOrCommand::CodeAction(action));
2549 break; }
2551 }
2552 }
2553
2554 for tag in DEFERRED_ACTION_TAGS {
2557 actions.extend(defer_actions(
2558 self.generate_deferred_actions(tag, &source, &doc, params.range, uri),
2559 tag,
2560 uri,
2561 params.range,
2562 ));
2563 }
2564
2565 actions.extend(extract_variable_actions(&source, params.range, uri));
2567 actions.extend(extract_method_actions(&source, &doc, params.range, uri));
2568 actions.extend(extract_constant_actions(&source, params.range, uri));
2569 actions.extend(inline_variable_actions(&source, params.range, uri));
2571 if let Some(action) = organize_imports_action(&source, uri) {
2573 actions.push(action);
2574 }
2575
2576 Ok(if actions.is_empty() {
2577 None
2578 } else {
2579 Some(actions)
2580 })
2581 }
2582
2583 async fn code_action_resolve(&self, item: CodeAction) -> Result<CodeAction> {
2584 let data = match &item.data {
2585 Some(d) => d.clone(),
2586 None => return Ok(item),
2587 };
2588 let kind_tag = match data.get("php_lsp_resolve").and_then(|v| v.as_str()) {
2589 Some(k) => k.to_string(),
2590 None => return Ok(item),
2591 };
2592 let uri: Url = match data
2593 .get("uri")
2594 .and_then(|v| v.as_str())
2595 .and_then(|s| Url::parse(s).ok())
2596 {
2597 Some(u) => u,
2598 None => return Ok(item),
2599 };
2600 let range: Range = match data
2601 .get("range")
2602 .and_then(|v| serde_json::from_value(v.clone()).ok())
2603 {
2604 Some(r) => r,
2605 None => return Ok(item),
2606 };
2607
2608 let source = self.get_open_text(&uri).unwrap_or_default();
2609 let doc = match self.get_doc(&uri) {
2610 Some(d) => d,
2611 None => return Ok(item),
2612 };
2613
2614 let candidates = self.generate_deferred_actions(&kind_tag, &source, &doc, range, &uri);
2615
2616 for candidate in candidates {
2618 if let CodeActionOrCommand::CodeAction(ca) = candidate
2619 && ca.title == item.title
2620 {
2621 return Ok(ca);
2622 }
2623 }
2624
2625 Ok(item)
2626 }
2627}
2628
2629fn php_file_op() -> FileOperationRegistrationOptions {
2631 FileOperationRegistrationOptions {
2632 filters: vec![FileOperationFilter {
2633 scheme: Some("file".to_string()),
2634 pattern: FileOperationPattern {
2635 glob: "**/*.php".to_string(),
2636 matches: Some(FileOperationPatternKind::File),
2637 options: None,
2638 },
2639 }],
2640 }
2641}
2642
2643fn defer_actions(
2646 actions: Vec<CodeActionOrCommand>,
2647 kind_tag: &str,
2648 uri: &Url,
2649 range: Range,
2650) -> Vec<CodeActionOrCommand> {
2651 actions
2652 .into_iter()
2653 .map(|a| match a {
2654 CodeActionOrCommand::CodeAction(mut ca) => {
2655 ca.edit = None;
2656 ca.data = Some(serde_json::json!({
2657 "php_lsp_resolve": kind_tag,
2658 "uri": uri.to_string(),
2659 "range": range,
2660 }));
2661 CodeActionOrCommand::CodeAction(ca)
2662 }
2663 other => other,
2664 })
2665 .collect()
2666}
2667
2668fn is_after_arrow(source: &str, position: Position) -> bool {
2671 let line = match source.lines().nth(position.line as usize) {
2672 Some(l) => l,
2673 None => return false,
2674 };
2675 let chars: Vec<char> = line.chars().collect();
2676 let col = position.character as usize;
2677 let mut utf16_col = 0usize;
2679 let mut char_idx = 0usize;
2680 for ch in &chars {
2681 if utf16_col >= col {
2682 break;
2683 }
2684 utf16_col += ch.len_utf16();
2685 char_idx += 1;
2686 }
2687 let is_word = |c: char| c.is_alphanumeric() || c == '_';
2689 while char_idx > 0 && is_word(chars[char_idx - 1]) {
2690 char_idx -= 1;
2691 }
2692 char_idx >= 2 && chars[char_idx - 1] == '>' && chars[char_idx - 2] == '-'
2693}
2694
2695fn symbol_kind_at(source: &str, position: Position, word: &str) -> Option<SymbolKind> {
2706 if word.starts_with('$') {
2707 return None; }
2709 let line = source.lines().nth(position.line as usize)?;
2710 let chars: Vec<char> = line.chars().collect();
2711
2712 let col = position.character as usize;
2714 let mut utf16_col = 0usize;
2715 let mut char_idx = 0usize;
2716 for ch in &chars {
2717 if utf16_col >= col {
2718 break;
2719 }
2720 utf16_col += ch.len_utf16();
2721 char_idx += 1;
2722 }
2723
2724 let is_word_char = |c: char| c.is_alphanumeric() || c == '_';
2726 while char_idx > 0 && is_word_char(chars[char_idx - 1]) {
2727 char_idx -= 1;
2728 }
2729
2730 let word_end = {
2732 let mut i = char_idx;
2733 while i < chars.len() && is_word_char(chars[i]) {
2734 i += 1;
2735 }
2736 while i < chars.len() && chars[i] == ' ' {
2738 i += 1;
2739 }
2740 i
2741 };
2742 let next_is_call = word_end < chars.len() && chars[word_end] == '(';
2743
2744 if char_idx >= 2 && chars[char_idx - 1] == '>' && chars[char_idx - 2] == '-' {
2746 return if next_is_call {
2747 Some(SymbolKind::Method)
2748 } else {
2749 Some(SymbolKind::Property)
2750 };
2751 }
2752 if char_idx >= 3
2753 && chars[char_idx - 1] == '>'
2754 && chars[char_idx - 2] == '-'
2755 && chars[char_idx - 3] == '?'
2756 {
2757 return if next_is_call {
2758 Some(SymbolKind::Method)
2759 } else {
2760 Some(SymbolKind::Property)
2761 };
2762 }
2763
2764 if char_idx >= 2 && chars[char_idx - 1] == ':' && chars[char_idx - 2] == ':' {
2766 return Some(SymbolKind::Method);
2767 }
2768
2769 if word
2771 .chars()
2772 .next()
2773 .map(|c| c.is_uppercase())
2774 .unwrap_or(false)
2775 {
2776 return Some(SymbolKind::Class);
2777 }
2778
2779 Some(SymbolKind::Function)
2781}
2782
2783fn position_to_offset(source: &str, position: Position) -> Option<u32> {
2786 let mut byte_offset = 0usize;
2787 for (idx, line) in source.split('\n').enumerate() {
2788 if idx as u32 == position.line {
2789 let line_content = line.trim_end_matches('\r');
2791 let mut col = 0u32;
2792 for (byte_idx, ch) in line_content.char_indices() {
2793 if col >= position.character {
2794 return Some((byte_offset + byte_idx) as u32);
2795 }
2796 col += ch.len_utf16() as u32;
2797 }
2798 return Some((byte_offset + line_content.len()) as u32);
2799 }
2800 byte_offset += line.len() + 1; }
2802 None
2803}
2804
2805fn cursor_is_on_method_decl(source: &str, stmts: &[Stmt<'_, '_>], position: Position) -> bool {
2812 let Some(cursor) = position_to_offset(source, position) else {
2813 return false;
2814 };
2815
2816 fn check(source: &str, stmts: &[Stmt<'_, '_>], cursor: u32) -> bool {
2817 for stmt in stmts {
2818 match &stmt.kind {
2819 StmtKind::Class(c) => {
2820 for member in c.members.iter() {
2821 if let ClassMemberKind::Method(m) = &member.kind {
2822 let start = str_offset(source, m.name);
2823 let end = start + m.name.len() as u32;
2824 if cursor >= start && cursor < end {
2825 return true;
2826 }
2827 }
2828 }
2829 }
2830 StmtKind::Interface(i) => {
2831 for member in i.members.iter() {
2832 if let ClassMemberKind::Method(m) = &member.kind {
2833 let start = str_offset(source, m.name);
2834 let end = start + m.name.len() as u32;
2835 if cursor >= start && cursor < end {
2836 return true;
2837 }
2838 }
2839 }
2840 }
2841 StmtKind::Trait(t) => {
2842 for member in t.members.iter() {
2843 if let ClassMemberKind::Method(m) = &member.kind {
2844 let start = str_offset(source, m.name);
2845 let end = start + m.name.len() as u32;
2846 if cursor >= start && cursor < end {
2847 return true;
2848 }
2849 }
2850 }
2851 }
2852 StmtKind::Enum(e) => {
2853 for member in e.members.iter() {
2854 if let EnumMemberKind::Method(m) = &member.kind {
2855 let start = str_offset(source, m.name);
2856 let end = start + m.name.len() as u32;
2857 if cursor >= start && cursor < end {
2858 return true;
2859 }
2860 }
2861 }
2862 }
2863 StmtKind::Namespace(ns) => {
2864 if let NamespaceBody::Braced(inner) = &ns.body
2865 && check(source, inner, cursor)
2866 {
2867 return true;
2868 }
2869 }
2870 _ => {}
2871 }
2872 }
2873 false
2874 }
2875
2876 check(source, stmts, cursor)
2877}
2878
2879fn cursor_is_on_property_decl(
2884 source: &str,
2885 stmts: &[Stmt<'_, '_>],
2886 position: Position,
2887) -> Option<String> {
2888 let cursor = position_to_offset(source, position)?;
2889
2890 fn check(source: &str, stmts: &[Stmt<'_, '_>], cursor: u32) -> Option<String> {
2891 for stmt in stmts {
2892 match &stmt.kind {
2893 StmtKind::Class(c) => {
2894 for member in c.members.iter() {
2895 if let ClassMemberKind::Property(p) = &member.kind {
2896 let start = str_offset(source, p.name);
2897 let end = start + p.name.len() as u32;
2898 if cursor >= start && cursor < end {
2899 return Some(p.name.to_owned());
2900 }
2901 }
2902 }
2903 }
2904 StmtKind::Trait(t) => {
2905 for member in t.members.iter() {
2906 if let ClassMemberKind::Property(p) = &member.kind {
2907 let start = str_offset(source, p.name);
2908 let end = start + p.name.len() as u32;
2909 if cursor >= start && cursor < end {
2910 return Some(p.name.to_owned());
2911 }
2912 }
2913 }
2914 }
2915 StmtKind::Namespace(ns) => {
2916 if let NamespaceBody::Braced(inner) = &ns.body
2917 && let Some(name) = check(source, inner, cursor)
2918 {
2919 return Some(name);
2920 }
2921 }
2922 _ => {}
2923 }
2924 }
2925 None
2926 }
2927
2928 check(source, stmts, cursor)
2929}
2930
2931fn class_name_at_construct_decl(
2938 source: &str,
2939 stmts: &[Stmt<'_, '_>],
2940 position: Position,
2941) -> Option<String> {
2942 let cursor = position_to_offset(source, position)?;
2943
2944 fn check(source: &str, stmts: &[Stmt<'_, '_>], cursor: u32, ns_prefix: &str) -> Option<String> {
2945 let mut current_ns = ns_prefix.to_owned();
2946 for stmt in stmts {
2947 match &stmt.kind {
2948 StmtKind::Class(c) => {
2949 for member in c.members.iter() {
2950 if let ClassMemberKind::Method(m) = &member.kind
2951 && m.name == "__construct"
2952 {
2953 let start = str_offset(source, m.name);
2954 let end = start + m.name.len() as u32;
2955 if cursor >= start && cursor < end {
2956 let short = c.name?;
2957 return Some(if current_ns.is_empty() {
2958 short.to_owned()
2959 } else {
2960 format!("{}\\{}", current_ns, short)
2961 });
2962 }
2963 }
2964 }
2965 }
2966 StmtKind::Namespace(ns) => {
2967 let ns_name = ns
2968 .name
2969 .as_ref()
2970 .map(|n| n.to_string_repr().to_string())
2971 .unwrap_or_default();
2972 match &ns.body {
2973 NamespaceBody::Braced(inner) => {
2974 if let Some(name) = check(source, inner, cursor, &ns_name) {
2975 return Some(name);
2976 }
2977 }
2978 NamespaceBody::Simple => {
2979 current_ns = ns_name;
2980 }
2981 }
2982 }
2983 _ => {}
2984 }
2985 }
2986 None
2987 }
2988
2989 check(source, stmts, cursor, "")
2990}
2991
2992fn promoted_property_at_cursor(
3000 source: &str,
3001 stmts: &[Stmt<'_, '_>],
3002 position: Position,
3003) -> Option<String> {
3004 let cursor = position_to_offset(source, position)?;
3005
3006 fn check(source: &str, stmts: &[Stmt<'_, '_>], cursor: u32) -> Option<String> {
3007 for stmt in stmts {
3008 match &stmt.kind {
3009 StmtKind::Class(c) => {
3010 for member in c.members.iter() {
3011 if let ClassMemberKind::Method(m) = &member.kind
3012 && m.name == "__construct"
3013 {
3014 for param in m.params.iter() {
3015 if param.visibility.is_none() {
3016 continue;
3017 }
3018 let name_start = str_offset(source, param.name);
3019 let name_end = name_start + param.name.len() as u32;
3020 if cursor >= name_start && cursor < name_end {
3021 return Some(param.name.trim_start_matches('$').to_owned());
3022 }
3023 }
3024 }
3025 }
3026 }
3027 StmtKind::Namespace(ns) => {
3028 if let NamespaceBody::Braced(inner) = &ns.body
3029 && let Some(name) = check(source, inner, cursor)
3030 {
3031 return Some(name);
3032 }
3033 }
3034 _ => {}
3035 }
3036 }
3037 None
3038 }
3039
3040 check(source, stmts, cursor)
3041}
3042
3043const DEFERRED_ACTION_TAGS: &[&str] = &[
3046 "phpdoc",
3047 "implement",
3048 "constructor",
3049 "getters_setters",
3050 "return_type",
3051 "promote",
3052];
3053
3054impl Backend {
3055 fn generate_deferred_actions(
3057 &self,
3058 tag: &str,
3059 source: &str,
3060 doc: &Arc<ParsedDoc>,
3061 range: Range,
3062 uri: &Url,
3063 ) -> Vec<CodeActionOrCommand> {
3064 match tag {
3065 "phpdoc" => phpdoc_actions(uri, doc, source, range),
3066 "implement" => {
3067 let imports = self.file_imports(uri);
3068 implement_missing_actions(
3069 source,
3070 doc,
3071 &self
3072 .docs
3073 .doc_with_others(uri, Arc::clone(doc), &self.open_urls()),
3074 range,
3075 uri,
3076 &imports,
3077 )
3078 }
3079 "constructor" => generate_constructor_actions(source, doc, range, uri),
3080 "getters_setters" => generate_getters_setters_actions(source, doc, range, uri),
3081 "return_type" => add_return_type_actions(source, doc, range, uri),
3082 "promote" => promote_constructor_actions(source, doc, range, uri),
3083 _ => Vec::new(),
3084 }
3085 }
3086
3087 async fn psr4_goto(&self, fqn: &str) -> Option<Location> {
3090 let path = {
3091 let psr4 = self.psr4.read().unwrap();
3092 psr4.resolve(fqn)?
3093 };
3094
3095 let file_uri = Url::from_file_path(&path).ok()?;
3096
3097 if self.docs.get_doc_salsa(&file_uri).is_none() {
3102 let text = tokio::fs::read_to_string(&path).await.ok()?;
3103 self.index_if_not_open(file_uri.clone(), &text);
3104 }
3105
3106 let doc = self.docs.get_doc_salsa(&file_uri)?;
3107
3108 let short_name = fqn.split('\\').next_back()?;
3111 let range = find_declaration_range(doc.source(), &doc, short_name)?;
3112
3113 Some(Location {
3114 uri: file_uri,
3115 range,
3116 })
3117 }
3118}
3119
3120async fn run_phpunit(
3126 client: &Client,
3127 filter: &str,
3128 root: Option<&std::path::Path>,
3129 file_uri: Option<&Url>,
3130) {
3131 let output = tokio::process::Command::new("vendor/bin/phpunit")
3132 .arg("--filter")
3133 .arg(filter)
3134 .current_dir(root.unwrap_or(std::path::Path::new(".")))
3135 .output()
3136 .await;
3137
3138 let (success, message) = match output {
3139 Ok(out) => {
3140 let text = String::from_utf8_lossy(&out.stdout).into_owned()
3141 + &String::from_utf8_lossy(&out.stderr);
3142 let last_line = text
3143 .lines()
3144 .rev()
3145 .find(|l| !l.trim().is_empty())
3146 .unwrap_or("(no output)")
3147 .to_string();
3148 let ok = out.status.success();
3149 let msg = if ok {
3150 format!("✓ {filter}: {last_line}")
3151 } else {
3152 format!("✗ {filter}: {last_line}")
3153 };
3154 (ok, msg)
3155 }
3156 Err(e) => (
3157 false,
3158 format!("php-lsp.runTest: failed to spawn phpunit — {e}"),
3159 ),
3160 };
3161
3162 let msg_type = if success {
3163 MessageType::INFO
3164 } else {
3165 MessageType::ERROR
3166 };
3167 let mut actions = vec![MessageActionItem {
3168 title: "Run Again".to_string(),
3169 properties: Default::default(),
3170 }];
3171 if !success && file_uri.is_some() {
3172 actions.push(MessageActionItem {
3173 title: "Open File".to_string(),
3174 properties: Default::default(),
3175 });
3176 }
3177
3178 let chosen = client
3179 .show_message_request(msg_type, message, Some(actions))
3180 .await;
3181
3182 match chosen {
3183 Ok(Some(ref action)) if action.title == "Run Again" => {
3184 let output2 = tokio::process::Command::new("vendor/bin/phpunit")
3186 .arg("--filter")
3187 .arg(filter)
3188 .current_dir(root.unwrap_or(std::path::Path::new(".")))
3189 .output()
3190 .await;
3191 let msg2 = match output2 {
3192 Ok(out) => {
3193 let text = String::from_utf8_lossy(&out.stdout).into_owned()
3194 + &String::from_utf8_lossy(&out.stderr);
3195 let last_line = text
3196 .lines()
3197 .rev()
3198 .find(|l| !l.trim().is_empty())
3199 .unwrap_or("(no output)")
3200 .to_string();
3201 if out.status.success() {
3202 format!("✓ {filter}: {last_line}")
3203 } else {
3204 format!("✗ {filter}: {last_line}")
3205 }
3206 }
3207 Err(e) => format!("php-lsp.runTest: failed to spawn phpunit — {e}"),
3208 };
3209 client.show_message(MessageType::INFO, msg2).await;
3210 }
3211 Ok(Some(ref action)) if action.title == "Open File" => {
3212 if let Some(uri) = file_uri {
3213 client
3214 .show_document(ShowDocumentParams {
3215 uri: uri.clone(),
3216 external: Some(false),
3217 take_focus: Some(true),
3218 selection: None,
3219 })
3220 .await
3221 .ok();
3222 }
3223 }
3224 _ => {}
3225 }
3226}
3227
3228async fn send_refresh_requests(client: &Client) {
3232 client.send_request::<SemanticTokensRefresh>(()).await.ok();
3233 client.send_request::<CodeLensRefresh>(()).await.ok();
3234 client
3235 .send_request::<InlayHintRefreshRequest>(())
3236 .await
3237 .ok();
3238 client
3239 .send_request::<WorkspaceDiagnosticRefresh>(())
3240 .await
3241 .ok();
3242 client
3243 .send_request::<InlineValueRefreshRequest>(())
3244 .await
3245 .ok();
3246}
3247
3248const MAX_INDEXED_FILES: usize = 50_000;
3251
3252#[tracing::instrument(
3264 skip(docs, open_files, cache, exclude_paths),
3265 fields(root = %root.display())
3266)]
3267async fn scan_workspace(
3268 root: PathBuf,
3269 docs: Arc<DocumentStore>,
3270 open_files: OpenFiles,
3271 cache: Option<crate::cache::WorkspaceCache>,
3272 exclude_paths: &[String],
3273 max_files: usize,
3274) -> usize {
3275 let mut php_files: Vec<PathBuf> = Vec::new();
3277 let mut stack = vec![root];
3278
3279 'walk: while let Some(dir) = stack.pop() {
3280 let mut entries = match tokio::fs::read_dir(&dir).await {
3281 Ok(e) => e,
3282 Err(_) => continue,
3283 };
3284 while let Ok(Some(entry)) = entries.next_entry().await {
3285 let path = entry.path();
3286 let path_str = path.to_string_lossy().replace('\\', "/");
3289 if exclude_paths.iter().any(|pat| {
3291 let p = pat.trim_end_matches('*').trim_end_matches('/');
3292 path_str.contains(p)
3293 }) {
3294 continue;
3295 }
3296 let file_type = match entry.file_type().await {
3297 Ok(ft) => ft,
3298 Err(_) => continue,
3299 };
3300 if file_type.is_dir() {
3301 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
3302 if !name.starts_with('.') {
3304 stack.push(path);
3305 }
3306 } else if file_type.is_file() && path.extension().is_some_and(|e| e == "php") {
3307 php_files.push(path);
3308 if php_files.len() >= max_files {
3309 break 'walk;
3310 }
3311 }
3312 }
3313 }
3314
3315 let parallelism = std::thread::available_parallelism()
3317 .map(|n| n.get())
3318 .unwrap_or(4);
3319 let sem = Arc::new(tokio::sync::Semaphore::new(parallelism));
3320 let count = Arc::new(std::sync::atomic::AtomicUsize::new(0));
3321 let mut set: tokio::task::JoinSet<()> = tokio::task::JoinSet::new();
3322
3323 for path in php_files {
3324 let permit = Arc::clone(&sem).acquire_owned().await.unwrap();
3325 let docs = Arc::clone(&docs);
3326 let open_files = open_files.clone();
3327 let cache = cache.clone();
3328 let count = Arc::clone(&count);
3329 set.spawn(async move {
3330 let _permit = permit;
3331 let Ok(text) = tokio::fs::read_to_string(&path).await else {
3332 return;
3333 };
3334 let Ok(uri) = Url::from_file_path(&path) else {
3335 return;
3336 };
3337 tokio::task::spawn_blocking(move || {
3338 if open_files.contains(&uri) {
3342 return;
3343 }
3344
3345 let cache_key = cache
3352 .as_ref()
3353 .map(|_| crate::cache::WorkspaceCache::key_for(uri.as_str(), &text));
3354 if let (Some(cache), Some(key)) = (cache.as_ref(), cache_key.as_ref())
3355 && let Some(slice) = cache.read::<mir_codebase::storage::StubSlice>(key)
3356 {
3357 docs.mirror_text(&uri, &text);
3358 docs.seed_cached_slice(&uri, Arc::new(slice));
3359 count.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3360 return;
3361 }
3362
3363 let (doc, diags) = parse_document(&text);
3365 docs.index_from_doc(uri.clone(), &doc, diags);
3366 count.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3367
3368 if let (Some(cache), Some(key)) = (cache.as_ref(), cache_key.as_ref())
3376 && let Some(slice) = docs.slice_for(&uri)
3377 {
3378 let _ = cache.write(key, &*slice);
3379 }
3380 })
3381 .await
3382 .ok();
3383 });
3384 }
3385
3386 while set.join_next().await.is_some() {}
3387
3388 count.load(std::sync::atomic::Ordering::Relaxed)
3389}
3390
3391#[cfg(test)]
3392mod tests {
3393 use super::*;
3394 use crate::use_import::find_use_insert_line;
3395 use tower_lsp::lsp_types::{Position, Range, Url};
3396
3397 #[test]
3399 fn diagnostics_config_default_is_enabled() {
3400 let cfg = DiagnosticsConfig::default();
3401 assert!(cfg.enabled);
3402 assert!(cfg.undefined_variables);
3403 assert!(cfg.undefined_functions);
3404 assert!(cfg.undefined_classes);
3405 assert!(cfg.arity_errors);
3406 assert!(cfg.type_errors);
3407 assert!(cfg.deprecated_calls);
3408 assert!(cfg.duplicate_declarations);
3409 }
3410
3411 #[test]
3412 fn diagnostics_config_from_empty_object_is_enabled() {
3413 let cfg = DiagnosticsConfig::from_value(&serde_json::json!({}));
3414 assert!(cfg.enabled);
3415 assert!(cfg.undefined_variables);
3416 }
3417
3418 #[test]
3419 fn diagnostics_config_from_non_object_uses_defaults() {
3420 let cfg = DiagnosticsConfig::from_value(&serde_json::json!(null));
3421 assert!(cfg.enabled);
3422 }
3423
3424 #[test]
3425 fn diagnostics_config_can_disable_individual_flags() {
3426 let cfg = DiagnosticsConfig::from_value(&serde_json::json!({
3427 "enabled": true,
3428 "undefinedVariables": false,
3429 "undefinedFunctions": false,
3430 "undefinedClasses": true,
3431 "arityErrors": false,
3432 "typeErrors": true,
3433 "deprecatedCalls": false,
3434 "duplicateDeclarations": true,
3435 }));
3436 assert!(cfg.enabled);
3437 assert!(!cfg.undefined_variables);
3438 assert!(!cfg.undefined_functions);
3439 assert!(cfg.undefined_classes);
3440 assert!(!cfg.arity_errors);
3441 assert!(cfg.type_errors);
3442 assert!(!cfg.deprecated_calls);
3443 assert!(cfg.duplicate_declarations);
3444 }
3445
3446 #[test]
3447 fn diagnostics_config_master_switch_disables_all() {
3448 let cfg = DiagnosticsConfig::from_value(&serde_json::json!({"enabled": false}));
3449 assert!(!cfg.enabled);
3450 assert!(cfg.undefined_variables);
3452 }
3453
3454 #[test]
3455 fn diagnostics_config_master_switch_enables_all() {
3456 let cfg = DiagnosticsConfig::from_value(&serde_json::json!({"enabled": true}));
3457 assert!(cfg.enabled);
3458 assert!(cfg.undefined_variables);
3459 }
3460
3461 #[test]
3463 fn lsp_config_default_is_empty() {
3464 let cfg = LspConfig::default();
3465 assert!(cfg.php_version.is_none());
3466 assert!(cfg.exclude_paths.is_empty());
3467 assert!(cfg.diagnostics.enabled);
3468 }
3469
3470 #[test]
3471 fn lsp_config_parses_php_version() {
3472 let cfg =
3473 LspConfig::from_value(&serde_json::json!({"phpVersion": crate::autoload::PHP_8_2}));
3474 assert_eq!(cfg.php_version.as_deref(), Some(crate::autoload::PHP_8_2));
3475 }
3476
3477 #[test]
3478 fn lsp_config_parses_exclude_paths() {
3479 let cfg = LspConfig::from_value(&serde_json::json!({
3480 "excludePaths": ["cache/*", "generated/*"]
3481 }));
3482 assert_eq!(cfg.exclude_paths, vec!["cache/*", "generated/*"]);
3483 }
3484
3485 #[test]
3486 fn lsp_config_parses_diagnostics_section() {
3487 let cfg = LspConfig::from_value(&serde_json::json!({
3488 "diagnostics": {"enabled": false}
3489 }));
3490 assert!(!cfg.diagnostics.enabled);
3491 }
3492
3493 #[test]
3494 fn lsp_config_ignores_missing_fields() {
3495 let cfg = LspConfig::from_value(&serde_json::json!({}));
3496 assert!(cfg.php_version.is_none());
3497 assert!(cfg.exclude_paths.is_empty());
3498 }
3499
3500 #[test]
3501 fn lsp_config_parses_max_indexed_files() {
3502 let cfg = LspConfig::from_value(&serde_json::json!({"maxIndexedFiles": 5000}));
3503 assert_eq!(cfg.max_indexed_files, 5000);
3504 }
3505
3506 #[test]
3507 fn lsp_config_default_max_indexed_files() {
3508 let cfg = LspConfig::default();
3509 assert_eq!(cfg.max_indexed_files, MAX_INDEXED_FILES);
3510 }
3511
3512 #[test]
3514 fn features_config_default_all_enabled() {
3515 let cfg = FeaturesConfig::default();
3516 assert!(cfg.completion);
3517 assert!(cfg.hover);
3518 assert!(cfg.definition);
3519 assert!(cfg.declaration);
3520 assert!(cfg.references);
3521 assert!(cfg.document_symbols);
3522 assert!(cfg.workspace_symbols);
3523 assert!(cfg.rename);
3524 assert!(cfg.signature_help);
3525 assert!(cfg.inlay_hints);
3526 assert!(cfg.semantic_tokens);
3527 assert!(cfg.selection_range);
3528 assert!(cfg.call_hierarchy);
3529 assert!(cfg.document_highlight);
3530 assert!(cfg.implementation);
3531 assert!(cfg.code_action);
3532 assert!(cfg.type_definition);
3533 assert!(cfg.code_lens);
3534 assert!(cfg.formatting);
3535 assert!(cfg.range_formatting);
3536 assert!(cfg.on_type_formatting);
3537 assert!(cfg.document_link);
3538 assert!(cfg.linked_editing_range);
3539 assert!(cfg.inline_values);
3540 }
3541
3542 #[test]
3543 fn features_config_from_empty_object_all_enabled() {
3544 let cfg = FeaturesConfig::from_value(&serde_json::json!({}));
3545 assert!(cfg.completion);
3546 assert!(cfg.hover);
3547 assert!(cfg.call_hierarchy);
3548 assert!(cfg.inline_values);
3549 }
3550
3551 #[test]
3552 fn features_config_can_disable_individual_flags() {
3553 let cfg = FeaturesConfig::from_value(&serde_json::json!({
3554 "callHierarchy": false,
3555 }));
3556 assert!(!cfg.call_hierarchy);
3557 assert!(cfg.completion);
3558 assert!(cfg.hover);
3559 assert!(cfg.definition);
3560 assert!(cfg.inline_values);
3561 }
3562
3563 #[test]
3564 fn lsp_config_parses_features_section() {
3565 let cfg = LspConfig::from_value(&serde_json::json!({
3566 "features": {"callHierarchy": false}
3567 }));
3568 assert!(!cfg.features.call_hierarchy);
3569 assert!(cfg.features.completion);
3570 assert!(cfg.features.hover);
3571 }
3572
3573 #[test]
3575 fn find_use_insert_line_after_php_open_tag() {
3576 let src = "<?php\nfunction foo() {}";
3577 assert_eq!(find_use_insert_line(src), 1);
3578 }
3579
3580 #[test]
3581 fn find_use_insert_line_after_existing_use() {
3582 let src = "<?php\nuse Foo\\Bar;\nuse Baz\\Qux;\nclass Impl {}";
3583 assert_eq!(find_use_insert_line(src), 3);
3584 }
3585
3586 #[test]
3587 fn find_use_insert_line_after_namespace() {
3588 let src = "<?php\nnamespace App\\Services;\nclass Service {}";
3589 assert_eq!(find_use_insert_line(src), 2);
3590 }
3591
3592 #[test]
3593 fn find_use_insert_line_after_namespace_and_use() {
3594 let src = "<?php\nnamespace App;\nuse Foo\\Bar;\nclass Impl {}";
3595 assert_eq!(find_use_insert_line(src), 3);
3596 }
3597
3598 #[test]
3599 fn find_use_insert_line_empty_file() {
3600 assert_eq!(find_use_insert_line(""), 0);
3601 }
3602
3603 #[test]
3605 fn is_after_arrow_with_method_call() {
3606 let src = "<?php\n$obj->method();\n";
3607 let pos = Position {
3609 line: 1,
3610 character: 6,
3611 };
3612 assert!(is_after_arrow(src, pos));
3613 }
3614
3615 #[test]
3616 fn is_after_arrow_without_arrow() {
3617 let src = "<?php\n$obj->method();\n";
3618 let pos = Position {
3620 line: 1,
3621 character: 1,
3622 };
3623 assert!(!is_after_arrow(src, pos));
3624 }
3625
3626 #[test]
3627 fn is_after_arrow_on_standalone_identifier() {
3628 let src = "<?php\nfunction greet() {}\n";
3629 let pos = Position {
3630 line: 1,
3631 character: 10,
3632 };
3633 assert!(!is_after_arrow(src, pos));
3634 }
3635
3636 #[test]
3637 fn is_after_arrow_out_of_bounds_line() {
3638 let src = "<?php\n$x = 1;\n";
3639 let pos = Position {
3640 line: 99,
3641 character: 0,
3642 };
3643 assert!(!is_after_arrow(src, pos));
3644 }
3645
3646 #[test]
3647 fn is_after_arrow_at_start_of_property() {
3648 let src = "<?php\n$this->name;\n";
3649 let pos = Position {
3651 line: 1,
3652 character: 7,
3653 };
3654 assert!(is_after_arrow(src, pos));
3655 }
3656
3657 #[test]
3659 fn php_file_op_matches_php_files() {
3660 let op = php_file_op();
3661 assert_eq!(op.filters.len(), 1);
3662 let filter = &op.filters[0];
3663 assert_eq!(filter.scheme.as_deref(), Some("file"));
3664 assert_eq!(filter.pattern.glob, "**/*.php");
3665 }
3666
3667 #[test]
3669 fn defer_actions_strips_edit_and_adds_data() {
3670 let uri = Url::parse("file:///test.php").unwrap();
3671 let range = Range {
3672 start: Position {
3673 line: 0,
3674 character: 0,
3675 },
3676 end: Position {
3677 line: 0,
3678 character: 5,
3679 },
3680 };
3681 let actions = vec![CodeActionOrCommand::CodeAction(CodeAction {
3682 title: "My Action".to_string(),
3683 kind: Some(CodeActionKind::REFACTOR),
3684 edit: Some(WorkspaceEdit::default()),
3685 data: None,
3686 ..Default::default()
3687 })];
3688 let deferred = defer_actions(actions, "test_kind", &uri, range);
3689 assert_eq!(deferred.len(), 1);
3690 if let CodeActionOrCommand::CodeAction(ca) = &deferred[0] {
3691 assert!(ca.edit.is_none(), "edit should be stripped");
3692 assert!(ca.data.is_some(), "data payload should be set");
3693 let data = ca.data.as_ref().unwrap();
3694 assert_eq!(data["php_lsp_resolve"], "test_kind");
3695 assert_eq!(data["uri"], uri.to_string());
3696 } else {
3697 panic!("expected CodeAction");
3698 }
3699 }
3700
3701 #[test]
3703 fn build_use_import_edit_inserts_after_php_tag() {
3704 let src = "<?php\nclass Foo {}";
3705 let uri = Url::parse("file:///test.php").unwrap();
3706 let edit = build_use_import_edit(src, &uri, "App\\Services\\Bar");
3707 let changes = edit.changes.unwrap();
3708 let edits = changes.get(&uri).unwrap();
3709 assert_eq!(edits.len(), 1);
3710 assert_eq!(edits[0].new_text, "use App\\Services\\Bar;\n");
3711 assert_eq!(edits[0].range.start.line, 1);
3712 }
3713
3714 #[test]
3715 fn build_use_import_edit_inserts_after_existing_use() {
3716 let src = "<?php\nuse Foo\\Bar;\nclass Impl {}";
3717 let uri = Url::parse("file:///test.php").unwrap();
3718 let edit = build_use_import_edit(src, &uri, "Baz\\Qux");
3719 let changes = edit.changes.unwrap();
3720 let edits = changes.get(&uri).unwrap();
3721 assert_eq!(edits[0].range.start.line, 2);
3722 assert_eq!(edits[0].new_text, "use Baz\\Qux;\n");
3723 }
3724
3725 #[test]
3727 fn undefined_class_name_extracted_from_message() {
3728 let msg = "Class MyService does not exist";
3729 let name = msg
3730 .strip_prefix("Class ")
3731 .and_then(|s| s.strip_suffix(" does not exist"))
3732 .unwrap_or("")
3733 .trim();
3734 assert_eq!(name, "MyService");
3735 }
3736
3737 #[test]
3738 fn undefined_function_message_not_matched_by_extraction() {
3739 let msg = "Function myHelper() is not defined";
3742 let name = msg
3743 .strip_prefix("Class ")
3744 .and_then(|s| s.strip_suffix(" does not exist"))
3745 .unwrap_or("")
3746 .trim();
3747 assert!(
3748 name.is_empty(),
3749 "function diagnostic should not extract a class name"
3750 );
3751 }
3752
3753 #[test]
3756 fn position_to_offset_first_line() {
3757 let src = "<?php\nfoo();";
3758 assert_eq!(
3760 position_to_offset(
3761 src,
3762 Position {
3763 line: 0,
3764 character: 0
3765 }
3766 ),
3767 Some(0)
3768 );
3769 assert_eq!(
3771 position_to_offset(
3772 src,
3773 Position {
3774 line: 0,
3775 character: 4
3776 }
3777 ),
3778 Some(4)
3779 );
3780 assert_eq!(
3782 position_to_offset(
3783 src,
3784 Position {
3785 line: 0,
3786 character: 5
3787 }
3788 ),
3789 Some(5)
3790 );
3791 }
3792
3793 #[test]
3794 fn position_to_offset_second_line() {
3795 let src = "<?php\nfoo();";
3796 assert_eq!(
3798 position_to_offset(
3799 src,
3800 Position {
3801 line: 1,
3802 character: 0
3803 }
3804 ),
3805 Some(6)
3806 );
3807 assert_eq!(
3809 position_to_offset(
3810 src,
3811 Position {
3812 line: 1,
3813 character: 3
3814 }
3815 ),
3816 Some(9)
3817 );
3818 }
3819
3820 #[test]
3821 fn position_to_offset_line_boundary_returns_none() {
3822 let src = "<?php";
3824 assert_eq!(
3825 position_to_offset(
3826 src,
3827 Position {
3828 line: 1,
3829 character: 0
3830 }
3831 ),
3832 None
3833 );
3834 assert_eq!(
3835 position_to_offset(
3836 src,
3837 Position {
3838 line: 5,
3839 character: 0
3840 }
3841 ),
3842 None
3843 );
3844 }
3845
3846 #[test]
3849 fn cursor_on_method_decl_name_returns_true() {
3850 let doc = ParsedDoc::parse("<?php\nclass C {\n public function add() {}\n}".to_string());
3853 let source = doc.source();
3854 let stmts = &doc.program().stmts;
3855 for col in 20u32..=22 {
3857 assert!(
3858 cursor_is_on_method_decl(
3859 source,
3860 stmts,
3861 Position {
3862 line: 2,
3863 character: col
3864 }
3865 ),
3866 "expected true at col {col}"
3867 );
3868 }
3869 assert!(!cursor_is_on_method_decl(
3871 source,
3872 stmts,
3873 Position {
3874 line: 2,
3875 character: 19
3876 }
3877 ));
3878 assert!(!cursor_is_on_method_decl(
3879 source,
3880 stmts,
3881 Position {
3882 line: 2,
3883 character: 23
3884 }
3885 ));
3886 }
3887
3888 #[test]
3889 fn cursor_on_free_function_decl_returns_false() {
3890 let doc = ParsedDoc::parse("<?php\nfunction add() {}".to_string());
3892 let source = doc.source();
3893 let stmts = &doc.program().stmts;
3894 assert!(!cursor_is_on_method_decl(
3895 source,
3896 stmts,
3897 Position {
3898 line: 1,
3899 character: 9
3900 }
3901 ));
3902 }
3903
3904 #[test]
3905 fn cursor_on_method_call_site_returns_false() {
3906 let doc = ParsedDoc::parse(
3908 "<?php\nclass C { public function add() {} }\n$c = new C();\n$c->add();".to_string(),
3909 );
3910 let source = doc.source();
3911 let stmts = &doc.program().stmts;
3912 assert!(!cursor_is_on_method_decl(
3913 source,
3914 stmts,
3915 Position {
3916 line: 3,
3917 character: 4
3918 }
3919 ));
3920 }
3921
3922 #[test]
3923 fn cursor_on_interface_method_decl_returns_true() {
3924 let doc = ParsedDoc::parse(
3926 "<?php\ninterface I {\n public function add(): void;\n}".to_string(),
3927 );
3928 let source = doc.source();
3929 let stmts = &doc.program().stmts;
3930 assert!(cursor_is_on_method_decl(
3931 source,
3932 stmts,
3933 Position {
3934 line: 2,
3935 character: 20
3936 }
3937 ));
3938 }
3939
3940 #[test]
3941 fn cursor_on_trait_method_decl_returns_true() {
3942 let doc = ParsedDoc::parse("<?php\ntrait T {\n public function add() {}\n}".to_string());
3944 let source = doc.source();
3945 let stmts = &doc.program().stmts;
3946 assert!(cursor_is_on_method_decl(
3947 source,
3948 stmts,
3949 Position {
3950 line: 2,
3951 character: 20
3952 }
3953 ));
3954 }
3955
3956 #[test]
3957 fn cursor_on_enum_method_decl_returns_true() {
3958 let doc = ParsedDoc::parse(
3960 "<?php\nenum Status {\n public function label(): string { return 'x'; }\n}"
3961 .to_string(),
3962 );
3963 let source = doc.source();
3964 let stmts = &doc.program().stmts;
3965 assert!(cursor_is_on_method_decl(
3966 source,
3967 stmts,
3968 Position {
3969 line: 2,
3970 character: 20
3971 }
3972 ));
3973 }
3974
3975 #[test]
3976 fn cursor_on_method_decl_in_unbraced_namespace_returns_true() {
3977 let doc = ParsedDoc::parse(
3986 "<?php\nnamespace App;\nclass C {\n public function add() {}\n}".to_string(),
3987 );
3988 let source = doc.source();
3989 let stmts = &doc.program().stmts;
3990 assert!(
3991 cursor_is_on_method_decl(
3992 source,
3993 stmts,
3994 Position {
3995 line: 3,
3996 character: 20
3997 }
3998 ),
3999 "method in unbraced namespace must be detected"
4000 );
4001 }
4002
4003 #[test]
4004 fn cursor_on_method_decl_in_braced_namespace_returns_true() {
4005 let doc = ParsedDoc::parse(
4014 "<?php\nnamespace App {\n class C {\n public function add() {}\n }\n}"
4015 .to_string(),
4016 );
4017 let source = doc.source();
4018 let stmts = &doc.program().stmts;
4019 assert!(
4020 cursor_is_on_method_decl(
4021 source,
4022 stmts,
4023 Position {
4024 line: 3,
4025 character: 24
4026 }
4027 ),
4028 "method in braced namespace must be detected"
4029 );
4030 }
4031
4032 #[test]
4035 fn merge_file_only_uses_file_values() {
4036 let file = serde_json::json!({
4037 "phpVersion": "8.1",
4038 "excludePaths": ["vendor/*"],
4039 "maxIndexedFiles": 500,
4040 });
4041 let merged = LspConfig::merge_project_configs(Some(&file), None);
4042 let cfg = LspConfig::from_value(&merged);
4043 assert_eq!(cfg.php_version, Some("8.1".to_string()));
4044 assert_eq!(cfg.exclude_paths, vec!["vendor/*"]);
4045 assert_eq!(cfg.max_indexed_files, 500);
4046 }
4047
4048 #[test]
4049 fn merge_editor_wins_per_key_over_file() {
4050 let file = serde_json::json!({"phpVersion": "8.1", "maxIndexedFiles": 100});
4051 let editor = serde_json::json!({"phpVersion": "8.3", "maxIndexedFiles": 200});
4052 let merged = LspConfig::merge_project_configs(Some(&file), Some(&editor));
4053 let cfg = LspConfig::from_value(&merged);
4054 assert_eq!(cfg.php_version, Some("8.3".to_string()));
4055 assert_eq!(cfg.max_indexed_files, 200);
4056 }
4057
4058 #[test]
4059 fn merge_exclude_paths_concat_not_replace() {
4060 let file = serde_json::json!({"excludePaths": ["cache/*"]});
4061 let editor = serde_json::json!({"excludePaths": ["logs/*"]});
4062 let merged = LspConfig::merge_project_configs(Some(&file), Some(&editor));
4063 let cfg = LspConfig::from_value(&merged);
4064 assert_eq!(cfg.exclude_paths, vec!["cache/*", "logs/*"]);
4066 }
4067
4068 #[test]
4069 fn merge_no_file_uses_editor_only() {
4070 let editor = serde_json::json!({"phpVersion": "8.2", "excludePaths": ["tmp/*"]});
4071 let merged = LspConfig::merge_project_configs(None, Some(&editor));
4072 let cfg = LspConfig::from_value(&merged);
4073 assert_eq!(cfg.php_version, Some("8.2".to_string()));
4074 assert_eq!(cfg.exclude_paths, vec!["tmp/*"]);
4075 }
4076
4077 #[test]
4078 fn merge_both_none_returns_defaults() {
4079 let merged = LspConfig::merge_project_configs(None, None);
4080 let cfg = LspConfig::from_value(&merged);
4081 assert!(cfg.php_version.is_none());
4082 assert!(cfg.exclude_paths.is_empty());
4083 assert_eq!(cfg.max_indexed_files, MAX_INDEXED_FILES);
4084 }
4085
4086 #[test]
4087 fn merge_file_editor_both_have_exclude_paths_all_present() {
4088 let file = serde_json::json!({"excludePaths": ["a/*", "b/*"]});
4089 let editor = serde_json::json!({"excludePaths": ["c/*"]});
4090 let merged = LspConfig::merge_project_configs(Some(&file), Some(&editor));
4091 let cfg = LspConfig::from_value(&merged);
4092 assert_eq!(cfg.exclude_paths, vec!["a/*", "b/*", "c/*"]);
4093 }
4094}