1use std::path::PathBuf;
2use std::sync::{Arc, RwLock};
3
4use tower_lsp::jsonrpc::Result;
5use tower_lsp::lsp_types::notification::Progress as ProgressNotification;
6
7enum IndexReadyNotification {}
10impl tower_lsp::lsp_types::notification::Notification for IndexReadyNotification {
11 type Params = ();
12 const METHOD: &'static str = "$/php-lsp/indexReady";
13}
14use tower_lsp::lsp_types::request::WorkDoneProgressCreate;
15use tower_lsp::lsp_types::*;
16use tower_lsp::{Client, LanguageServer, async_trait};
17
18use php_ast::{ClassMemberKind, EnumMemberKind, NamespaceBody, Stmt, StmtKind};
19
20use crate::ast::{ParsedDoc, str_offset};
21use crate::autoload::Psr4Map;
22use crate::call_hierarchy::{incoming_calls, outgoing_calls, prepare_call_hierarchy};
23use crate::code_lens::code_lenses;
24use crate::completion::{CompletionCtx, filtered_completions_at};
25use crate::config::LspConfig;
26use crate::declaration::{goto_declaration, goto_declaration_from_index};
27use crate::definition::{
28 find_declaration_range, find_in_indexes, find_method_in_class_hierarchy, goto_definition,
29};
30use crate::diagnostics::{parse_document, parse_document_no_diags};
31use crate::document_highlight::document_highlights;
32use crate::document_link::document_links;
33use crate::document_store::DocumentStore;
34use crate::extract_action::extract_variable_actions;
35use crate::extract_constant_action::extract_constant_actions;
36use crate::extract_method_action::extract_method_actions;
37use crate::file_rename::{use_edits_for_delete, use_edits_for_rename};
38use crate::folding::folding_ranges;
39use crate::formatting::{format_document, format_range};
40use crate::generate_action::{generate_constructor_actions, generate_getters_setters_actions};
41use crate::hover::{
42 class_hover_from_index, docs_for_symbol_from_index, hover_info, signature_for_symbol_from_index,
43};
44use crate::implement_action::implement_missing_actions;
45use crate::implementation::{find_implementations, find_implementations_from_workspace};
46use crate::inlay_hints::inlay_hints;
47use crate::inline_action::inline_variable_actions;
48use crate::inline_value::inline_values_in_range;
49use crate::moniker::moniker_at;
50use crate::on_type_format::on_type_format;
51use crate::open_files::{OpenFiles, compute_open_file_diagnostics};
52use crate::organize_imports::organize_imports_action;
53use crate::panic_guard::{guard_async, guard_async_result};
54use crate::phpdoc_action::phpdoc_actions;
55use crate::phpstorm_meta::PhpStormMeta;
56use crate::promote_action::promote_constructor_actions;
57use crate::references::{
58 SymbolKind, find_constructor_references, find_references, 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_position;
77use crate::workspace_scan::{scan_workspace, send_refresh_requests};
78
79pub struct Backend {
80 client: Client,
81 docs: Arc<DocumentStore>,
82 open_files: OpenFiles,
86 root_paths: Arc<RwLock<Vec<PathBuf>>>,
87 psr4: Arc<RwLock<Psr4Map>>,
88 meta: Arc<RwLock<PhpStormMeta>>,
89 config: Arc<RwLock<LspConfig>>,
90}
91
92impl Backend {
93 pub fn new(client: Client) -> Self {
94 let docs = Arc::new(DocumentStore::new());
99 let psr4 = docs.psr4_arc();
100 Backend {
101 client,
102 docs,
103 open_files: OpenFiles::new(),
104 root_paths: Arc::new(RwLock::new(Vec::new())),
105 psr4,
106 meta: Arc::new(RwLock::new(PhpStormMeta::default())),
107 config: Arc::new(RwLock::new(LspConfig::default())),
108 }
109 }
110
111 fn set_open_text(&self, uri: Url, text: String) -> u64 {
114 self.open_files.set_open_text(&self.docs, uri, text)
115 }
116
117 fn close_open_file(&self, uri: &Url) {
118 self.open_files.close(&self.docs, uri);
119 }
120
121 fn index_if_not_open(&self, uri: Url, text: &str) {
125 if !self.open_files.contains(&uri) {
126 self.docs.index(uri, text);
127 }
128 }
129
130 fn index_from_doc_if_not_open(&self, uri: Url, doc: &ParsedDoc) {
132 if !self.open_files.contains(&uri) {
133 self.docs.index_from_doc(uri, doc);
134 }
135 }
136
137 fn get_open_text(&self, uri: &Url) -> Option<String> {
138 self.open_files.text(uri)
139 }
140
141 fn set_parse_diagnostics(&self, uri: &Url, diagnostics: Vec<Diagnostic>) {
142 self.open_files.set_parse_diagnostics(uri, diagnostics);
143 }
144
145 fn get_parse_diagnostics(&self, uri: &Url) -> Option<Vec<Diagnostic>> {
146 self.open_files.parse_diagnostics(uri)
147 }
148
149 fn all_open_files_with_diagnostics(&self) -> Vec<(Url, Vec<Diagnostic>, Option<i64>)> {
150 self.open_files.all_with_diagnostics()
151 }
152
153 fn open_urls(&self) -> Vec<Url> {
154 self.open_files.urls()
155 }
156
157 fn get_doc(&self, uri: &Url) -> Option<Arc<ParsedDoc>> {
158 self.open_files.get_doc(&self.docs, uri)
159 }
160
161 fn codebase(&self) -> mir_analyzer::db::MirDb {
164 let php_version = self.docs.workspace_php_version();
165 let session = self.docs.analysis_session(php_version);
166 session.snapshot_db()
167 }
168
169 fn file_imports(&self, uri: &Url) -> std::collections::HashMap<String, String> {
171 self.docs
172 .get_doc_salsa(uri)
173 .map(|doc| crate::references::collect_file_imports(&doc))
174 .unwrap_or_default()
175 }
176
177 fn resolve_php_version(&self, explicit: Option<&str>) -> (String, &'static str) {
180 let roots = self.root_paths.read().unwrap().clone();
181 crate::autoload::resolve_php_version_from_roots(&roots, explicit)
182 }
183
184 async fn compute_dependent_publishes(
189 &self,
190 changed_uri: &Url,
191 diag_cfg: &crate::config::DiagnosticsConfig,
192 ) -> Vec<(Url, Vec<Diagnostic>)> {
193 compute_dependent_publishes_owned(
194 Arc::clone(&self.docs),
195 self.open_files.clone(),
196 changed_uri.clone(),
197 diag_cfg.clone(),
198 )
199 .await
200 }
201}
202
203fn build_mir_symbol(
210 word: &str,
211 kind: Option<crate::references::SymbolKind>,
212 target_fqn: Option<&str>,
213) -> Option<mir_analyzer::Symbol> {
214 use crate::references::SymbolKind;
215 use std::sync::Arc as StdArc;
216 match kind {
217 Some(SymbolKind::Function) => {
218 target_fqn.map(|fqn| mir_analyzer::Symbol::Function(StdArc::from(fqn)))
219 }
220 Some(SymbolKind::Class) => {
221 target_fqn.map(|fqn| mir_analyzer::Symbol::Class(StdArc::from(fqn)))
222 }
223 Some(SymbolKind::Method) => target_fqn.map(|owning| mir_analyzer::Symbol::Method {
224 class: StdArc::from(owning),
225 name: StdArc::from(word.to_ascii_lowercase()),
228 }),
229 Some(SymbolKind::Property) | None => None,
230 }
231}
232
233async fn compute_dependent_publishes_owned(
238 docs: Arc<DocumentStore>,
239 open_files: OpenFiles,
240 changed_uri: Url,
241 diag_cfg: crate::config::DiagnosticsConfig,
242) -> Vec<(Url, Vec<Diagnostic>)> {
243 tokio::task::spawn_blocking(move || {
244 let php_version = docs.workspace_php_version();
252 let session = docs.analysis_session(php_version);
253 let analyses = session.analyze_dependents_of(changed_uri.as_str());
254 if analyses.is_empty() {
255 return Vec::new();
256 }
257
258 let open_urls: std::collections::HashSet<Url> = open_files
261 .urls()
262 .into_iter()
263 .filter(|u| u != &changed_uri)
264 .collect();
265 let dependents: Vec<(Url, mir_analyzer::FileAnalysis)> = analyses
266 .into_iter()
267 .filter_map(|(file, analysis)| {
268 let url = Url::parse(file.as_ref()).ok()?;
269 open_urls.contains(&url).then_some((url, analysis))
270 })
271 .collect();
272 if dependents.is_empty() {
273 return Vec::new();
274 }
275
276 let dep_files: Vec<Arc<str>> = dependents
280 .iter()
281 .map(|(u, _)| Arc::from(u.as_str()))
282 .collect();
283 let class_issues = session.class_issues_for(&dep_files);
284 let mut class_issues_by_file: std::collections::HashMap<Arc<str>, Vec<mir_issues::Issue>> =
285 std::collections::HashMap::new();
286 for issue in class_issues {
287 if issue.suppressed {
288 continue;
289 }
290 let file = issue.location.file.clone();
291 class_issues_by_file.entry(file).or_default().push(issue);
292 }
293
294 let mut out: Vec<(Url, Vec<Diagnostic>)> = Vec::with_capacity(dependents.len());
295 for (url, analysis) in dependents {
296 let mut diags = open_files.parse_diagnostics(&url).unwrap_or_default();
297 if let Some(d) = open_files.get_doc(&docs, &url) {
298 let source = open_files.text(&url).unwrap_or_default();
299 diags.extend(
300 crate::semantic_diagnostics::duplicate_declaration_diagnostics(
301 &source, &d, &diag_cfg,
302 ),
303 );
304 }
305 let mut issues: Vec<mir_issues::Issue> = analysis
306 .issues
307 .into_iter()
308 .filter(|i| !i.suppressed)
309 .collect();
310 if let Some(extra) = class_issues_by_file.remove(&Arc::<str>::from(url.as_str())) {
311 issues.extend(extra);
312 }
313 diags.extend(crate::semantic_diagnostics::issues_to_diagnostics(
314 &issues, &url, &diag_cfg,
315 ));
316 out.push((url, diags));
317 }
318 out
319 })
320 .await
321 .unwrap_or_default()
322}
323
324fn compute_diagnostic_result_id(diagnostics: &[Diagnostic], uri: &str) -> String {
327 use std::collections::hash_map::DefaultHasher;
328 use std::hash::{Hash, Hasher};
329
330 let mut hasher = DefaultHasher::new();
331 uri.hash(&mut hasher);
332 diagnostics.len().hash(&mut hasher);
333
334 for diag in diagnostics {
335 diag.range.start.line.hash(&mut hasher);
336 diag.range.start.character.hash(&mut hasher);
337 diag.range.end.line.hash(&mut hasher);
338 diag.range.end.character.hash(&mut hasher);
339 diag.message.hash(&mut hasher);
340 let severity_val = match diag.severity {
341 Some(tower_lsp::lsp_types::DiagnosticSeverity::ERROR) => 1,
342 Some(tower_lsp::lsp_types::DiagnosticSeverity::WARNING) => 2,
343 Some(tower_lsp::lsp_types::DiagnosticSeverity::INFORMATION) => 3,
344 Some(tower_lsp::lsp_types::DiagnosticSeverity::HINT) => 4,
345 None => 0,
346 _ => 5, };
348 severity_val.hash(&mut hasher);
349 if let Some(code) = &diag.code {
350 format!("{:?}", code).hash(&mut hasher);
351 }
352 if let Some(source) = &diag.source {
353 source.hash(&mut hasher);
354 }
355 if let Some(tags) = &diag.tags {
356 for tag in tags {
357 let tag_val = match *tag {
358 tower_lsp::lsp_types::DiagnosticTag::UNNECESSARY => 1,
359 tower_lsp::lsp_types::DiagnosticTag::DEPRECATED => 2,
360 _ => 3,
361 };
362 tag_val.hash(&mut hasher);
363 }
364 }
365 }
366
367 format!("v1:{:x}", hasher.finish())
368}
369
370#[async_trait]
371impl LanguageServer for Backend {
372 async fn initialize(&self, params: InitializeParams) -> Result<InitializeResult> {
373 {
376 let mut roots: Vec<PathBuf> = params
377 .workspace_folders
378 .as_deref()
379 .unwrap_or(&[])
380 .iter()
381 .filter_map(|f| f.uri.to_file_path().ok())
382 .collect();
383 if roots.is_empty()
384 && let Some(path) = params.root_uri.as_ref().and_then(|u| u.to_file_path().ok())
385 {
386 roots.push(path);
387 }
388 *self.root_paths.write().unwrap() = roots;
389 }
390
391 {
397 let roots = self.root_paths.read().unwrap().clone();
398 if !roots.is_empty() {
399 let mut merged = Psr4Map::empty();
400 for root in &roots {
401 merged.extend(Psr4Map::load(root));
402 }
403 *self.psr4.write().unwrap() = merged;
404 }
405 }
406
407 {
408 let opts = params.initialization_options.as_ref();
409 let roots = self.root_paths.read().unwrap().clone();
410 let file_cfg = crate::autoload::load_project_config_json(&roots);
411
412 if matches!(file_cfg, Some(serde_json::Value::Null)) {
413 self.client
414 .log_message(
415 tower_lsp::lsp_types::MessageType::WARNING,
416 "php-lsp: .php-lsp.json contains invalid JSON — ignoring",
417 )
418 .await;
419 }
420
421 if let Some(serde_json::Value::Object(ref obj)) = file_cfg
422 && let Some(ver) = obj.get("phpVersion").and_then(|v| v.as_str())
423 && !crate::autoload::is_valid_php_version(ver)
424 {
425 self.client
426 .log_message(
427 tower_lsp::lsp_types::MessageType::WARNING,
428 format!(
429 "php-lsp: .php-lsp.json unsupported phpVersion {ver:?} — valid values: {}",
430 crate::autoload::SUPPORTED_PHP_VERSIONS.join(", ")
431 ),
432 )
433 .await;
434 }
435
436 if let Some(ver) = opts
437 .and_then(|o| o.get("phpVersion"))
438 .and_then(|v| v.as_str())
439 && !crate::autoload::is_valid_php_version(ver)
440 {
441 self.client
442 .log_message(
443 tower_lsp::lsp_types::MessageType::WARNING,
444 format!(
445 "php-lsp: unsupported phpVersion {ver:?} — valid values: {}",
446 crate::autoload::SUPPORTED_PHP_VERSIONS.join(", ")
447 ),
448 )
449 .await;
450 }
451
452 let file_obj = file_cfg.as_ref().filter(|v| v.is_object());
455 let merged = LspConfig::merge_project_configs(file_obj, opts);
456 let mut cfg = LspConfig::from_value(&merged);
457
458 let (ver, source) = self.resolve_php_version(cfg.php_version.as_deref());
463 self.client
464 .log_message(
465 tower_lsp::lsp_types::MessageType::INFO,
466 format!("php-lsp: using PHP {ver} ({source})"),
467 )
468 .await;
469 if source != "set by editor" && !crate::autoload::is_valid_php_version(&ver) {
474 self.client
475 .show_message(
476 tower_lsp::lsp_types::MessageType::WARNING,
477 format!(
478 "php-lsp: detected PHP {ver} is outside the supported range ({}); \
479 analysis may be inaccurate",
480 crate::autoload::SUPPORTED_PHP_VERSIONS.join(", ")
481 ),
482 )
483 .await;
484 }
485 cfg.php_version = Some(ver.clone());
486 if let Ok(pv) = ver.parse::<mir_analyzer::PhpVersion>() {
487 self.docs.set_php_version(pv);
488 }
489 *self.config.write().unwrap() = cfg;
490 }
491
492 let feat = self.config.read().unwrap().features.clone();
493 Ok(InitializeResult {
494 capabilities: ServerCapabilities {
495 text_document_sync: Some(TextDocumentSyncCapability::Options(
496 TextDocumentSyncOptions {
497 open_close: Some(true),
498 change: Some(TextDocumentSyncKind::FULL),
499 will_save: Some(true),
500 will_save_wait_until: Some(true),
501 save: Some(TextDocumentSyncSaveOptions::SaveOptions(SaveOptions {
502 include_text: Some(false),
503 })),
504 },
505 )),
506 completion_provider: feat.completion.then(|| CompletionOptions {
507 trigger_characters: Some(vec![
508 "$".to_string(),
509 ">".to_string(),
510 ":".to_string(),
511 "(".to_string(),
512 "[".to_string(),
513 ]),
514 resolve_provider: Some(true),
515 ..Default::default()
516 }),
517 hover_provider: feat.hover.then_some(HoverProviderCapability::Simple(true)),
518 definition_provider: feat.definition.then_some(OneOf::Left(true)),
519 references_provider: feat.references.then_some(OneOf::Left(true)),
520 document_symbol_provider: feat.document_symbols.then_some(OneOf::Left(true)),
521 workspace_symbol_provider: feat.workspace_symbols.then(|| {
522 OneOf::Right(WorkspaceSymbolOptions {
523 resolve_provider: Some(true),
524 work_done_progress_options: Default::default(),
525 })
526 }),
527 rename_provider: feat.rename.then(|| {
528 OneOf::Right(RenameOptions {
529 prepare_provider: Some(true),
530 work_done_progress_options: Default::default(),
531 })
532 }),
533 signature_help_provider: feat.signature_help.then(|| SignatureHelpOptions {
534 trigger_characters: Some(vec!["(".to_string(), ",".to_string()]),
535 retrigger_characters: None,
536 work_done_progress_options: Default::default(),
537 }),
538 inlay_hint_provider: feat.inlay_hints.then(|| {
539 OneOf::Right(InlayHintServerCapabilities::Options(InlayHintOptions {
540 resolve_provider: Some(true),
541 work_done_progress_options: Default::default(),
542 }))
543 }),
544 folding_range_provider: Some(FoldingRangeProviderCapability::Simple(true)),
545 semantic_tokens_provider: feat.semantic_tokens.then(|| {
546 SemanticTokensServerCapabilities::SemanticTokensOptions(SemanticTokensOptions {
547 legend: legend(),
548 full: Some(SemanticTokensFullOptions::Delta { delta: Some(true) }),
549 range: Some(true),
550 ..Default::default()
551 })
552 }),
553 selection_range_provider: feat
554 .selection_range
555 .then_some(SelectionRangeProviderCapability::Simple(true)),
556 call_hierarchy_provider: feat
557 .call_hierarchy
558 .then_some(CallHierarchyServerCapability::Simple(true)),
559 document_highlight_provider: feat.document_highlight.then_some(OneOf::Left(true)),
560 implementation_provider: feat
561 .implementation
562 .then_some(ImplementationProviderCapability::Simple(true)),
563 code_action_provider: feat.code_action.then(|| {
564 CodeActionProviderCapability::Options(CodeActionOptions {
565 resolve_provider: Some(true),
566 ..Default::default()
567 })
568 }),
569 declaration_provider: feat
570 .declaration
571 .then_some(DeclarationCapability::Simple(true)),
572 type_definition_provider: feat
573 .type_definition
574 .then_some(TypeDefinitionProviderCapability::Simple(true)),
575 code_lens_provider: feat.code_lens.then_some(CodeLensOptions {
576 resolve_provider: Some(true),
577 }),
578 document_formatting_provider: feat.formatting.then_some(OneOf::Left(true)),
579 document_range_formatting_provider: feat
580 .range_formatting
581 .then_some(OneOf::Left(true)),
582 document_on_type_formatting_provider: feat.on_type_formatting.then(|| {
583 DocumentOnTypeFormattingOptions {
584 first_trigger_character: "}".to_string(),
585 more_trigger_character: Some(vec!["\n".to_string()]),
586 }
587 }),
588 document_link_provider: feat.document_link.then(|| DocumentLinkOptions {
589 resolve_provider: Some(true),
590 work_done_progress_options: Default::default(),
591 }),
592 execute_command_provider: Some(ExecuteCommandOptions {
593 commands: vec!["php-lsp.runTest".to_string()],
594 work_done_progress_options: Default::default(),
595 }),
596 diagnostic_provider: Some(DiagnosticServerCapabilities::Options(
597 DiagnosticOptions {
598 identifier: None,
599 inter_file_dependencies: true,
600 workspace_diagnostics: true,
601 work_done_progress_options: Default::default(),
602 },
603 )),
604 workspace: Some(WorkspaceServerCapabilities {
605 workspace_folders: Some(WorkspaceFoldersServerCapabilities {
606 supported: Some(true),
607 change_notifications: Some(OneOf::Left(true)),
608 }),
609 file_operations: Some(WorkspaceFileOperationsServerCapabilities {
610 will_rename: Some(php_file_op()),
611 did_rename: Some(php_file_op()),
612 did_create: Some(php_file_op()),
613 will_delete: Some(php_file_op()),
614 did_delete: Some(php_file_op()),
615 ..Default::default()
616 }),
617 }),
618 linked_editing_range_provider: feat
619 .linked_editing_range
620 .then_some(LinkedEditingRangeServerCapabilities::Simple(true)),
621 moniker_provider: Some(OneOf::Left(true)),
622 inline_value_provider: feat.inline_values.then(|| {
623 OneOf::Right(InlineValueServerCapabilities::Options(InlineValueOptions {
624 work_done_progress_options: Default::default(),
625 }))
626 }),
627 ..Default::default()
628 },
629 ..Default::default()
630 })
631 }
632
633 async fn initialized(&self, _params: InitializedParams) {
634 let php_selector = serde_json::json!([{"language": "php"}]);
636 let registrations = vec![
637 Registration {
638 id: "php-lsp-file-watcher".to_string(),
639 method: "workspace/didChangeWatchedFiles".to_string(),
640 register_options: Some(serde_json::json!({
641 "watchers": [{"globPattern": "**/*.php"}]
642 })),
643 },
644 Registration {
647 id: "php-lsp-type-hierarchy".to_string(),
648 method: "textDocument/prepareTypeHierarchy".to_string(),
649 register_options: Some(serde_json::json!({"documentSelector": php_selector})),
650 },
651 Registration {
652 id: "php-lsp-config-change".to_string(),
653 method: "workspace/didChangeConfiguration".to_string(),
654 register_options: Some(serde_json::json!({"section": "php-lsp"})),
655 },
656 ];
657 self.client.register_capability(registrations).await.ok();
658
659 let roots = self.root_paths.read().unwrap().clone();
661 if !roots.is_empty() {
662 {
663 let mut merged = Psr4Map::empty();
664 for root in &roots {
665 merged.extend(Psr4Map::load(root));
666 }
667 *self.psr4.write().unwrap() = merged;
668 }
669 *self.meta.write().unwrap() = PhpStormMeta::load(&roots[0]);
670
671 let token = NumberOrString::String("php-lsp/indexing".to_string());
672 self.client
673 .send_request::<WorkDoneProgressCreate>(WorkDoneProgressCreateParams {
674 token: token.clone(),
675 })
676 .await
677 .ok();
678
679 let docs = Arc::clone(&self.docs);
680 let open_files = self.open_files.clone();
681 let client = self.client.clone();
682 let (exclude_paths, max_indexed_files) = {
683 let cfg = self.config.read().unwrap();
684 (cfg.exclude_paths.clone(), cfg.max_indexed_files)
685 };
686 tokio::spawn(async move {
687 client
688 .send_notification::<ProgressNotification>(ProgressParams {
689 token: token.clone(),
690 value: ProgressParamsValue::WorkDone(WorkDoneProgress::Begin(
691 WorkDoneProgressBegin {
692 title: "php-lsp: indexing workspace".to_string(),
693 cancellable: Some(false),
694 message: None,
695 percentage: None,
696 },
697 )),
698 })
699 .await;
700
701 let mut total = 0usize;
702 for root in roots {
703 let cache = crate::cache::WorkspaceCache::new(&root);
709 total += scan_workspace(
710 root,
711 Arc::clone(&docs),
712 open_files.clone(),
713 cache,
714 &exclude_paths,
715 max_indexed_files,
716 )
717 .await;
718 }
719
720 client
721 .send_notification::<ProgressNotification>(ProgressParams {
722 token,
723 value: ProgressParamsValue::WorkDone(WorkDoneProgress::End(
724 WorkDoneProgressEnd {
725 message: Some(format!("Indexed {total} files")),
726 },
727 )),
728 })
729 .await;
730
731 client
732 .log_message(
733 MessageType::INFO,
734 format!("php-lsp: indexed {total} workspace files"),
735 )
736 .await;
737
738 send_refresh_requests(&client).await;
742
743 let warm_docs = Arc::clone(&docs);
757 tokio::task::spawn_blocking(move || {
758 warm_docs.get_workspace_index_salsa();
761 })
762 .await
763 .ok();
764 drop(docs);
765 client.send_notification::<IndexReadyNotification>(()).await;
766 });
767 }
768
769 self.client
770 .log_message(MessageType::INFO, "php-lsp ready")
771 .await;
772 }
773
774 async fn did_change_configuration(&self, _params: DidChangeConfigurationParams) {
775 let items = vec![ConfigurationItem {
778 scope_uri: None,
779 section: Some("php-lsp".to_string()),
780 }];
781 if let Ok(values) = self.client.configuration(items).await
782 && let Some(value) = values.into_iter().next()
783 {
784 let roots = self.root_paths.read().unwrap().clone();
785
786 let file_cfg = crate::autoload::load_project_config_json(&roots);
789
790 if let Some(ver) = value.get("phpVersion").and_then(|v| v.as_str())
791 && !crate::autoload::is_valid_php_version(ver)
792 {
793 self.client
794 .log_message(
795 tower_lsp::lsp_types::MessageType::WARNING,
796 format!(
797 "php-lsp: unsupported phpVersion {ver:?} — valid values: {}",
798 crate::autoload::SUPPORTED_PHP_VERSIONS.join(", ")
799 ),
800 )
801 .await;
802 }
803
804 let file_obj = file_cfg.as_ref().filter(|v| v.is_object());
805 let merged = LspConfig::merge_project_configs(file_obj, Some(&value));
806 let mut cfg = LspConfig::from_value(&merged);
807
808 let (ver, source) = self.resolve_php_version(cfg.php_version.as_deref());
810 self.client
811 .log_message(
812 tower_lsp::lsp_types::MessageType::INFO,
813 format!("php-lsp: using PHP {ver} ({source})"),
814 )
815 .await;
816 if source != "set by editor" && !crate::autoload::is_valid_php_version(&ver) {
819 self.client
820 .show_message(
821 tower_lsp::lsp_types::MessageType::WARNING,
822 format!(
823 "php-lsp: detected PHP {ver} is outside the supported range ({}); \
824 analysis may be inaccurate",
825 crate::autoload::SUPPORTED_PHP_VERSIONS.join(", ")
826 ),
827 )
828 .await;
829 }
830 cfg.php_version = Some(ver.clone());
831 if let Ok(pv) = ver.parse::<mir_analyzer::PhpVersion>() {
832 self.docs.set_php_version(pv);
833 }
834 *self.config.write().unwrap() = cfg;
835 send_refresh_requests(&self.client).await;
836 }
837 }
838
839 async fn did_change_workspace_folders(&self, params: DidChangeWorkspaceFoldersParams) {
840 {
842 let mut roots = self.root_paths.write().unwrap();
843 for removed in ¶ms.event.removed {
844 if let Ok(path) = removed.uri.to_file_path() {
845 roots.retain(|r| r != &path);
846 }
847 }
848 }
849
850 let (exclude_paths, max_indexed_files) = {
852 let cfg = self.config.read().unwrap();
853 (cfg.exclude_paths.clone(), cfg.max_indexed_files)
854 };
855 for added in ¶ms.event.added {
856 if let Ok(path) = added.uri.to_file_path() {
857 let is_new = {
858 let mut roots = self.root_paths.write().unwrap();
859 if !roots.contains(&path) {
860 roots.push(path.clone());
861 true
862 } else {
863 false
864 }
865 };
866 if is_new {
867 let docs = Arc::clone(&self.docs);
868 let open_files = self.open_files.clone();
869 let ex = exclude_paths.clone();
870 let path_clone = path.clone();
871 let client = self.client.clone();
872 tokio::spawn(async move {
873 let cache = crate::cache::WorkspaceCache::new(&path_clone);
874 scan_workspace(path_clone, docs, open_files, cache, &ex, max_indexed_files)
875 .await;
876 send_refresh_requests(&client).await;
877 });
878 }
879 }
880 }
881 }
882
883 async fn shutdown(&self) -> Result<()> {
884 Ok(())
885 }
886
887 #[tracing::instrument(skip_all)]
888 async fn did_open(&self, params: DidOpenTextDocumentParams) {
889 guard_async("did_open", async move {
890 let uri = params.text_document.uri;
891 let text = params.text_document.text;
892
893 self.set_open_text(uri.clone(), text.clone());
897
898 let docs_for_spawn = Arc::clone(&self.docs);
899 let diag_cfg = self.config.read().unwrap().diagnostics.clone();
900
901 let uri_sem = uri.clone();
906 let (parse_diags, sem_issues) = tokio::task::spawn_blocking(move || {
907 let (_doc, parse_diags) = parse_document(&text);
908 let sem_issues = docs_for_spawn.get_semantic_issues_salsa(&uri_sem);
909 (parse_diags, sem_issues)
910 })
911 .await
912 .unwrap_or_else(|_| (vec![], None));
913
914 self.set_parse_diagnostics(&uri, parse_diags.clone());
915 let stored_source = self.get_open_text(&uri).unwrap_or_default();
916 let doc2 = self.get_doc(&uri);
917 let mut all_diags = parse_diags;
918 if let Some(ref d) = doc2 {
919 all_diags.extend(duplicate_declaration_diagnostics(
920 &stored_source,
921 d,
922 &diag_cfg,
923 ));
924 }
925 if let Some(issues) = sem_issues {
926 all_diags.extend(crate::semantic_diagnostics::issues_to_diagnostics(
927 &issues, &uri, &diag_cfg,
928 ));
929 }
930 self.client
932 .publish_diagnostics(uri.clone(), all_diags, None)
933 .await;
934
935 let dependents = self.compute_dependent_publishes(&uri, &diag_cfg).await;
939 for (dep_uri, dep_diags) in dependents {
940 self.client
941 .publish_diagnostics(dep_uri, dep_diags, None)
942 .await;
943 }
944 })
945 .await
946 }
947
948 #[tracing::instrument(skip_all)]
949 async fn did_change(&self, params: DidChangeTextDocumentParams) {
950 guard_async("did_change", async move {
951 let uri = params.text_document.uri;
952 let text = match params.content_changes.into_iter().last() {
953 Some(c) => c.text,
954 None => return,
955 };
956
957 let version = self.set_open_text(uri.clone(), text.clone());
961
962 let docs = Arc::clone(&self.docs);
963 let open_files = self.open_files.clone();
964 let client = self.client.clone();
965 let diag_cfg = self.config.read().unwrap().diagnostics.clone();
966 tokio::spawn(async move {
967 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
970
971 let (_doc, diagnostics) =
972 tokio::task::spawn_blocking(move || parse_document(&text))
973 .await
974 .unwrap_or_else(|_| (ParsedDoc::default(), vec![]));
975
976 if open_files.current_version(&uri) == Some(version) {
979 open_files.set_parse_diagnostics(&uri, diagnostics.clone());
980
981 let docs_sem = Arc::clone(&docs);
987 let open_files_sem = open_files.clone();
988 let uri_sem = uri.clone();
989 let diag_cfg_sem = diag_cfg.clone();
990 let extra = tokio::task::spawn_blocking(move || {
991 let Some(d) = open_files_sem.get_doc(&docs_sem, &uri_sem) else {
992 return Vec::<Diagnostic>::new();
993 };
994 let source = open_files_sem.text(&uri_sem).unwrap_or_default();
995 let mut out = Vec::new();
996 if let Some(issues) = docs_sem.get_semantic_issues_salsa(&uri_sem) {
997 out.extend(crate::semantic_diagnostics::issues_to_diagnostics(
998 &issues,
999 &uri_sem,
1000 &diag_cfg_sem,
1001 ));
1002 }
1003 out.extend(duplicate_declaration_diagnostics(
1004 &source,
1005 &d,
1006 &diag_cfg_sem,
1007 ));
1008 out
1009 })
1010 .await
1011 .unwrap_or_default();
1012
1013 let mut all_diags = diagnostics;
1014 all_diags.extend(extra);
1015 client
1020 .publish_diagnostics(uri.clone(), all_diags, None)
1021 .await;
1022
1023 let dependents = compute_dependent_publishes_owned(
1032 Arc::clone(&docs),
1033 open_files.clone(),
1034 uri.clone(),
1035 diag_cfg.clone(),
1036 )
1037 .await;
1038 for (dep_uri, dep_diags) in dependents {
1039 client.publish_diagnostics(dep_uri, dep_diags, None).await;
1040 }
1041 }
1042 });
1043 })
1044 .await
1045 }
1046
1047 async fn did_close(&self, params: DidCloseTextDocumentParams) {
1048 let uri = params.text_document.uri;
1049 self.close_open_file(&uri);
1050 self.client.publish_diagnostics(uri, vec![], None).await;
1052 }
1053
1054 async fn will_save(&self, _params: WillSaveTextDocumentParams) {}
1055
1056 async fn will_save_wait_until(
1057 &self,
1058 params: WillSaveTextDocumentParams,
1059 ) -> Result<Option<Vec<TextEdit>>> {
1060 let source = self
1061 .get_open_text(¶ms.text_document.uri)
1062 .unwrap_or_default();
1063 Ok(format_document(&source))
1064 }
1065
1066 async fn did_save(&self, params: DidSaveTextDocumentParams) {
1067 let uri = params.text_document.uri;
1068 let diag_cfg = self.config.read().unwrap().diagnostics.clone();
1074 let all = compute_open_file_diagnostics(&self.docs, &self.open_files, &uri, &diag_cfg);
1075 self.client.publish_diagnostics(uri, all, None).await;
1076 }
1077
1078 async fn did_change_watched_files(&self, params: DidChangeWatchedFilesParams) {
1079 for change in params.changes {
1080 match change.typ {
1081 FileChangeType::CREATED | FileChangeType::CHANGED => {
1082 if let Ok(path) = change.uri.to_file_path()
1083 && let Ok(text) = tokio::fs::read_to_string(&path).await
1084 {
1085 let doc = parse_document_no_diags(&text);
1090 self.index_from_doc_if_not_open(change.uri.clone(), &doc);
1091 }
1092 }
1093 FileChangeType::DELETED => {
1094 self.docs.remove(&change.uri);
1095 }
1096 _ => {}
1097 }
1098 }
1099 send_refresh_requests(&self.client).await;
1101 }
1102
1103 #[tracing::instrument(skip_all)]
1104 async fn completion(&self, params: CompletionParams) -> Result<Option<CompletionResponse>> {
1105 guard_async_result("completion", async move {
1106 let uri = ¶ms.text_document_position.text_document.uri;
1107 let position = params.text_document_position.position;
1108 let source = self.get_open_text(uri).unwrap_or_default();
1109 let doc = match self.get_doc(uri) {
1110 Some(d) => d,
1111 None => return Ok(Some(CompletionResponse::Array(vec![]))),
1112 };
1113 let other_with_returns = self.docs.other_docs_with_returns(uri, &self.open_urls());
1114 let other_docs: Vec<Arc<ParsedDoc>> = other_with_returns
1115 .iter()
1116 .map(|(_, d, _)| d.clone())
1117 .collect();
1118 let other_returns: Vec<Arc<crate::ast::MethodReturnsMap>> = other_with_returns
1119 .iter()
1120 .map(|(_, _, r)| r.clone())
1121 .collect();
1122 let doc_returns = self.docs.get_method_returns_salsa(uri);
1123 let trigger = params
1124 .context
1125 .as_ref()
1126 .and_then(|c| c.trigger_character.as_deref());
1127 let meta_guard = self.meta.read().unwrap();
1128 let meta_opt = if meta_guard.is_empty() {
1129 None
1130 } else {
1131 Some(&*meta_guard)
1132 };
1133 let imports = self.file_imports(uri);
1134 let ctx = CompletionCtx {
1135 source: Some(&source),
1136 position: Some(position),
1137 meta: meta_opt,
1138 doc_uri: Some(uri),
1139 file_imports: Some(&imports),
1140 doc_returns: doc_returns.as_deref(),
1141 other_returns: Some(&other_returns),
1142 };
1143 Ok(Some(CompletionResponse::Array(filtered_completions_at(
1144 &doc,
1145 &other_docs,
1146 trigger,
1147 &ctx,
1148 ))))
1149 })
1150 .await
1151 }
1152
1153 async fn completion_resolve(&self, mut item: CompletionItem) -> Result<CompletionItem> {
1154 if item.documentation.is_some() && item.detail.is_some() {
1155 return Ok(item);
1156 }
1157 let name = item.label.trim_end_matches(':');
1159 let all_indexes = self.docs.all_indexes();
1160 if item.detail.is_none()
1161 && let Some(sig) = signature_for_symbol_from_index(name, &all_indexes)
1162 {
1163 item.detail = Some(sig);
1164 }
1165 if item.documentation.is_none()
1166 && let Some(md) = docs_for_symbol_from_index(name, &all_indexes)
1167 {
1168 item.documentation = Some(Documentation::MarkupContent(MarkupContent {
1169 kind: MarkupKind::Markdown,
1170 value: md,
1171 }));
1172 }
1173 Ok(item)
1174 }
1175
1176 async fn goto_definition(
1177 &self,
1178 params: GotoDefinitionParams,
1179 ) -> Result<Option<GotoDefinitionResponse>> {
1180 guard_async_result("goto_definition", async move {
1181 let uri = ¶ms.text_document_position_params.text_document.uri;
1182 let position = params.text_document_position_params.position;
1183 let source = self.get_open_text(uri).unwrap_or_default();
1184 let doc = match self.get_doc(uri) {
1185 Some(d) => d,
1186 None => return Ok(None),
1187 };
1188 let empty_other_docs: Vec<(Url, Arc<ParsedDoc>)> = vec![];
1190 if let Some(loc) = goto_definition(uri, &source, &doc, &empty_other_docs, position) {
1191 return Ok(Some(GotoDefinitionResponse::Scalar(loc)));
1192 }
1193 if let Some(line_text) = source.lines().nth(position.line as usize)
1197 && let Some(word) = crate::util::word_at_position(&source, position)
1198 && let Some(receiver) = crate::hover::extract_receiver_var_before_cursor(
1199 line_text,
1200 position.character as usize,
1201 )
1202 {
1203 let class_name = if receiver == "$this" {
1204 crate::type_map::enclosing_class_at(&source, &doc, position)
1205 } else {
1206 let doc_returns = self
1207 .docs
1208 .get_method_returns_salsa(uri)
1209 .unwrap_or_else(|| std::sync::Arc::new(Default::default()));
1210 let tm = crate::type_map::TypeMap::from_docs_at_position(
1211 &doc,
1212 &doc_returns,
1213 std::iter::empty(),
1214 None,
1215 position,
1216 );
1217 tm.get(&receiver).map(|s| s.to_string())
1218 };
1219 if let Some(cls) = class_name {
1220 let first_cls = cls.split('|').next().unwrap_or(&cls).to_owned();
1221 let all_indexes = self.docs.all_indexes();
1222 if let Some(loc) =
1223 find_method_in_class_hierarchy(&first_cls, &word, &all_indexes)
1224 {
1225 let refined = self
1226 .docs
1227 .get_doc_salsa(&loc.uri)
1228 .and_then(|doc| {
1229 find_declaration_range(doc.source(), &doc, &word).map(|range| {
1230 Location {
1231 uri: loc.uri.clone(),
1232 range,
1233 }
1234 })
1235 })
1236 .unwrap_or(loc);
1237 return Ok(Some(GotoDefinitionResponse::Scalar(refined)));
1238 }
1239 }
1240 }
1241
1242 let other_indexes = self.docs.other_indexes(uri);
1244 if let Some(word) = crate::util::word_at_position(&source, position)
1245 && let Some(loc) = find_in_indexes(&word, &other_indexes)
1246 {
1247 let refined = self
1248 .docs
1249 .get_doc_salsa(&loc.uri)
1250 .and_then(|doc| {
1251 find_declaration_range(doc.source(), &doc, &word).map(|range| Location {
1252 uri: loc.uri.clone(),
1253 range,
1254 })
1255 })
1256 .unwrap_or(loc);
1257 return Ok(Some(GotoDefinitionResponse::Scalar(refined)));
1258 }
1259
1260 if let Some(word) = word_at_position(&source, position)
1262 && word.contains('\\')
1263 && let Some(loc) = self.psr4_goto(&word).await
1264 {
1265 return Ok(Some(GotoDefinitionResponse::Scalar(loc)));
1266 }
1267
1268 Ok(None)
1269 })
1270 .await
1271 }
1272
1273 async fn references(&self, params: ReferenceParams) -> Result<Option<Vec<Location>>> {
1274 guard_async_result("references", async move {
1275 let uri = ¶ms.text_document_position.text_document.uri;
1276 let position = params.text_document_position.position;
1277 let source = self.get_open_text(uri).unwrap_or_default();
1278 let word = match word_at_position(&source, position) {
1279 Some(w) => w,
1280 None => return Ok(None),
1281 };
1282 if word == "__construct"
1289 && let Some(doc) = self.get_doc(uri)
1290 && let Some(class_name) =
1291 class_name_at_construct_decl(doc.source(), &doc.program().stmts, position)
1292 {
1293 let all_docs = self.docs.all_docs_for_scan();
1294 let include_declaration = params.context.include_declaration;
1295 let short_name = class_name
1301 .rsplit('\\')
1302 .next()
1303 .unwrap_or(class_name.as_str())
1304 .to_owned();
1305 let class_fqn = if class_name.contains('\\') {
1306 Some(class_name.as_str())
1307 } else {
1308 None
1309 };
1310 let mut locations = find_constructor_references(&short_name, &all_docs, class_fqn);
1314 if include_declaration {
1315 let end = Position {
1321 line: position.line,
1322 character: position.character + "__construct".len() as u32,
1323 };
1324 locations.push(Location {
1325 uri: uri.clone(),
1326 range: Range {
1327 start: position,
1328 end,
1329 },
1330 });
1331 }
1332 return Ok(if locations.is_empty() {
1333 None
1334 } else {
1335 Some(locations)
1336 });
1337 }
1338
1339 let doc_opt = self.get_doc(uri);
1340 let (word, kind) = if let Some(doc) = &doc_opt
1344 && let Some(prop_name) =
1345 promoted_property_at_cursor(doc.source(), &doc.program().stmts, position)
1346 {
1347 (prop_name, Some(SymbolKind::Property))
1348 } else if let Some(doc) = &doc_opt {
1349 let stmts = &doc.program().stmts;
1350 if cursor_is_on_method_decl(doc.source(), stmts, position) {
1351 (word, Some(SymbolKind::Method))
1352 } else if let Some(prop_name) =
1353 cursor_is_on_property_decl(doc.source(), stmts, position)
1354 {
1355 (prop_name, Some(SymbolKind::Property))
1356 } else {
1357 let k = symbol_kind_at(&source, position, &word);
1358 (word, k)
1359 }
1360 } else {
1361 let k = symbol_kind_at(&source, position, &word);
1362 (word, k)
1363 };
1364 let all_docs = self.docs.all_docs_for_scan();
1365 let include_declaration = params.context.include_declaration;
1366
1367 let target_fqn: Option<String> = doc_opt.as_ref().and_then(|doc| {
1372 let imports = self.file_imports(uri);
1373 match kind {
1374 Some(SymbolKind::Function) | Some(SymbolKind::Class) => {
1375 let resolved = crate::moniker::resolve_fqn(doc, &word, &imports);
1376 if resolved.contains('\\') {
1377 Some(resolved)
1378 } else {
1379 None
1380 }
1381 }
1382 Some(SymbolKind::Method) => {
1383 let short_owner =
1385 crate::type_map::enclosing_class_at(doc.source(), doc, position)?;
1386 Some(crate::moniker::resolve_fqn(doc, &short_owner, &imports))
1388 }
1389 _ => None,
1390 }
1391 });
1392
1393 if matches!(kind, Some(SymbolKind::Method)) {
1405 self.docs.ensure_all_files_ingested();
1406 }
1407 let owner_short: Option<String> = if matches!(kind, Some(SymbolKind::Method)) {
1413 target_fqn
1414 .as_deref()
1415 .and_then(|fqn| fqn.trim_start_matches('\\').rsplit('\\').next())
1416 .map(|s| s.to_string())
1417 } else {
1418 None
1419 };
1420
1421 let session_method_refs: Option<Vec<Location>> =
1422 if matches!(kind, Some(SymbolKind::Method))
1423 && let Some(sym) = build_mir_symbol(&word, kind, target_fqn.as_deref())
1424 {
1425 let raw = self.docs.session_references_to(&sym);
1426 let session_locs: Vec<Location> = raw
1427 .into_iter()
1428 .filter_map(|(file, line, col_start, col_end)| {
1429 let uri_parsed = Url::parse(&file).ok()?;
1430 if let Some(short) = &owner_short {
1434 let src_opt = self.docs.source_text(&uri_parsed);
1435 let mentions = src_opt
1436 .as_ref()
1437 .map(|src| src.contains(short.as_str()))
1438 .unwrap_or(true);
1439 if !mentions {
1440 return None;
1441 }
1442 }
1443 Some(Location {
1444 uri: uri_parsed,
1445 range: tower_lsp::lsp_types::Range {
1446 start: tower_lsp::lsp_types::Position {
1447 line,
1448 character: col_start,
1449 },
1450 end: tower_lsp::lsp_types::Position {
1451 line,
1452 character: col_end,
1453 },
1454 },
1455 })
1456 })
1457 .collect();
1458 Some(session_locs)
1459 } else {
1460 None
1461 };
1462
1463 let mut locations = if let Some(session_locs) =
1464 session_method_refs.filter(|l| !l.is_empty())
1465 {
1466 let mut combined = session_locs;
1473 if include_declaration {
1474 let end = Position {
1475 line: position.line,
1476 character: position.character + word.len() as u32,
1477 };
1478 combined.push(Location {
1479 uri: uri.clone(),
1480 range: Range {
1481 start: position,
1482 end,
1483 },
1484 });
1485 let mut seen = std::collections::HashSet::new();
1486 combined.retain(|loc| {
1487 seen.insert((
1488 loc.uri.to_string(),
1489 loc.range.start.line,
1490 loc.range.start.character,
1491 loc.range.end.character,
1492 ))
1493 });
1494 }
1495 combined
1496 } else {
1497 match target_fqn.as_deref() {
1498 Some(t) => {
1499 find_references_with_target(&word, &all_docs, include_declaration, kind, t)
1500 }
1501 None => find_references(&word, &all_docs, include_declaration, kind),
1502 }
1503 };
1504
1505 if !matches!(kind, Some(SymbolKind::Method))
1508 && let Some(sym) = build_mir_symbol(&word, kind, target_fqn.as_deref())
1509 {
1510 let extra = self.docs.session_references_to(&sym);
1511 if !extra.is_empty() {
1512 let mut seen: std::collections::HashSet<(String, u32, u32, u32)> = locations
1513 .iter()
1514 .map(|loc| {
1515 (
1516 loc.uri.to_string(),
1517 loc.range.start.line,
1518 loc.range.start.character,
1519 loc.range.end.character,
1520 )
1521 })
1522 .collect();
1523 for (file, line, col_start, col_end) in extra {
1524 let Ok(uri_parsed) = Url::parse(&file) else {
1525 continue;
1526 };
1527 let key = (uri_parsed.to_string(), line, col_start, col_end);
1528 if !seen.insert(key) {
1529 continue;
1530 }
1531 locations.push(Location {
1532 uri: uri_parsed,
1533 range: tower_lsp::lsp_types::Range {
1534 start: tower_lsp::lsp_types::Position {
1535 line,
1536 character: col_start,
1537 },
1538 end: tower_lsp::lsp_types::Position {
1539 line,
1540 character: col_end,
1541 },
1542 },
1543 });
1544 }
1545 }
1546 }
1547
1548 Ok(if locations.is_empty() {
1549 None
1550 } else {
1551 Some(locations)
1552 })
1553 })
1554 .await
1555 }
1556
1557 async fn prepare_rename(
1558 &self,
1559 params: TextDocumentPositionParams,
1560 ) -> Result<Option<PrepareRenameResponse>> {
1561 let uri = ¶ms.text_document.uri;
1562 let source = self.get_open_text(uri).unwrap_or_default();
1563 Ok(prepare_rename(&source, params.position).map(PrepareRenameResponse::Range))
1564 }
1565
1566 async fn rename(&self, params: RenameParams) -> Result<Option<WorkspaceEdit>> {
1567 let uri = ¶ms.text_document_position.text_document.uri;
1568 let position = params.text_document_position.position;
1569 let source = self.get_open_text(uri).unwrap_or_default();
1570 let word = match word_at_position(&source, position) {
1571 Some(w) => w,
1572 None => return Ok(None),
1573 };
1574 if word.starts_with('$') {
1575 let doc = match self.get_doc(uri) {
1576 Some(d) => d,
1577 None => return Ok(None),
1578 };
1579 Ok(Some(rename_variable(
1580 &word,
1581 ¶ms.new_name,
1582 uri,
1583 &doc,
1584 position,
1585 )))
1586 } else if is_after_arrow(&source, position) {
1587 let all_docs = self.docs.all_docs_for_scan();
1588 Ok(Some(rename_property(&word, ¶ms.new_name, &all_docs)))
1589 } else {
1590 let all_docs = self.docs.all_docs_for_scan();
1591 let doc_opt = self.get_doc(uri);
1592 let target_fqn: Option<String> = doc_opt.as_ref().map(|doc| {
1593 let imports = self.file_imports(uri);
1594 crate::moniker::resolve_fqn(doc, &word, &imports)
1595 });
1596 Ok(Some(rename(
1597 &word,
1598 ¶ms.new_name,
1599 &all_docs,
1600 target_fqn.as_deref(),
1601 )))
1602 }
1603 }
1604
1605 async fn signature_help(&self, params: SignatureHelpParams) -> Result<Option<SignatureHelp>> {
1606 let uri = ¶ms.text_document_position_params.text_document.uri;
1607 let position = params.text_document_position_params.position;
1608 let source = self.get_open_text(uri).unwrap_or_default();
1609 let doc = match self.get_doc(uri) {
1610 Some(d) => d,
1611 None => return Ok(None),
1612 };
1613 let all_indexes = self.docs.all_indexes();
1614 Ok(signature_help(&source, &doc, position, &all_indexes))
1615 }
1616
1617 #[tracing::instrument(skip_all)]
1618 async fn hover(&self, params: HoverParams) -> Result<Option<Hover>> {
1619 guard_async_result("hover", async move {
1620 let uri = ¶ms.text_document_position_params.text_document.uri;
1621 let position = params.text_document_position_params.position;
1622 let source = self.get_open_text(uri).unwrap_or_default();
1623 let doc = match self.get_doc(uri) {
1624 Some(d) => d,
1625 None => return Ok(None),
1626 };
1627 let doc_returns = self
1628 .docs
1629 .get_method_returns_salsa(uri)
1630 .unwrap_or_else(|| std::sync::Arc::new(Default::default()));
1631 let other_docs = self.docs.other_docs_with_returns(uri, &self.open_urls());
1632 let result = hover_info(&source, &doc, &doc_returns, position, &other_docs);
1633 if result.is_some() {
1634 return Ok(result);
1635 }
1636 if let Some(word) = crate::util::word_at_position(&source, position) {
1641 let wi = self.docs.get_workspace_index_salsa();
1642 if let Some(h) = class_hover_from_index(&word, &wi.files) {
1644 return Ok(Some(h));
1645 }
1646 if let Some(resolved) = crate::hover::resolve_use_alias(&doc.program().stmts, &word)
1648 && let Some(h) = class_hover_from_index(&resolved, &wi.files)
1649 {
1650 return Ok(Some(h));
1651 }
1652 }
1653 Ok(None)
1654 })
1655 .await
1656 }
1657
1658 async fn document_symbol(
1659 &self,
1660 params: DocumentSymbolParams,
1661 ) -> Result<Option<DocumentSymbolResponse>> {
1662 let uri = ¶ms.text_document.uri;
1663 let doc = match self.get_doc(uri) {
1664 Some(d) => d,
1665 None => return Ok(None),
1666 };
1667 Ok(Some(DocumentSymbolResponse::Nested(document_symbols(
1668 doc.source(),
1669 &doc,
1670 ))))
1671 }
1672
1673 async fn folding_range(&self, params: FoldingRangeParams) -> Result<Option<Vec<FoldingRange>>> {
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 let ranges = folding_ranges(doc.source(), &doc);
1680 Ok(if ranges.is_empty() {
1681 None
1682 } else {
1683 Some(ranges)
1684 })
1685 }
1686
1687 async fn inlay_hint(&self, params: InlayHintParams) -> Result<Option<Vec<InlayHint>>> {
1688 let uri = ¶ms.text_document.uri;
1689 let doc = match self.get_doc(uri) {
1690 Some(d) => d,
1691 None => return Ok(None),
1692 };
1693 let doc_returns = self.docs.get_method_returns_salsa(uri);
1694 let wi = self.docs.get_workspace_index_salsa();
1695 Ok(Some(inlay_hints(
1696 doc.source(),
1697 &doc,
1698 doc_returns.as_deref(),
1699 params.range,
1700 &wi.files,
1701 )))
1702 }
1703
1704 async fn inlay_hint_resolve(&self, mut item: InlayHint) -> Result<InlayHint> {
1705 if item.tooltip.is_some() {
1706 return Ok(item);
1707 }
1708 let func_name = item
1709 .data
1710 .as_ref()
1711 .and_then(|d| d.get("php_lsp_fn"))
1712 .and_then(|v| v.as_str())
1713 .map(str::to_string);
1714 if let Some(name) = func_name {
1715 let all_indexes = self.docs.all_indexes();
1716 if let Some(md) = docs_for_symbol_from_index(&name, &all_indexes) {
1717 item.tooltip = Some(InlayHintTooltip::MarkupContent(MarkupContent {
1718 kind: MarkupKind::Markdown,
1719 value: md,
1720 }));
1721 }
1722 }
1723 Ok(item)
1724 }
1725
1726 async fn symbol(
1727 &self,
1728 params: WorkspaceSymbolParams,
1729 ) -> Result<Option<Vec<SymbolInformation>>> {
1730 let wi = self.docs.get_workspace_index_salsa();
1734 let results = workspace_symbols_from_workspace(¶ms.query, &wi);
1735 Ok(if results.is_empty() {
1736 None
1737 } else {
1738 Some(results)
1739 })
1740 }
1741
1742 async fn symbol_resolve(&self, params: WorkspaceSymbol) -> Result<WorkspaceSymbol> {
1743 let docs = self.docs.docs_for(&self.open_urls());
1745 Ok(resolve_workspace_symbol(params, &docs))
1746 }
1747
1748 #[tracing::instrument(skip_all)]
1749 async fn semantic_tokens_full(
1750 &self,
1751 params: SemanticTokensParams,
1752 ) -> Result<Option<SemanticTokensResult>> {
1753 guard_async_result("semantic_tokens_full", async move {
1754 let uri = ¶ms.text_document.uri;
1755 let doc = match self.get_doc(uri) {
1756 Some(d) => d,
1757 None => {
1758 return Ok(Some(SemanticTokensResult::Tokens(SemanticTokens {
1759 result_id: None,
1760 data: vec![],
1761 })));
1762 }
1763 };
1764 let tokens = semantic_tokens(doc.source(), &doc);
1765 let result_id = token_hash(&tokens);
1766 let tokens_arc = Arc::new(tokens);
1767 self.docs
1768 .store_token_cache(uri, result_id.clone(), Arc::clone(&tokens_arc));
1769 let data = Arc::try_unwrap(tokens_arc).unwrap_or_else(|arc| (*arc).clone());
1770 Ok(Some(SemanticTokensResult::Tokens(SemanticTokens {
1771 result_id: Some(result_id),
1772 data,
1773 })))
1774 })
1775 .await
1776 }
1777
1778 async fn semantic_tokens_range(
1779 &self,
1780 params: SemanticTokensRangeParams,
1781 ) -> Result<Option<SemanticTokensRangeResult>> {
1782 let uri = ¶ms.text_document.uri;
1783 let doc = match self.get_doc(uri) {
1784 Some(d) => d,
1785 None => {
1786 return Ok(Some(SemanticTokensRangeResult::Tokens(SemanticTokens {
1787 result_id: None,
1788 data: vec![],
1789 })));
1790 }
1791 };
1792 let tokens = semantic_tokens_range(doc.source(), &doc, params.range);
1793 Ok(Some(SemanticTokensRangeResult::Tokens(SemanticTokens {
1794 result_id: None,
1795 data: tokens,
1796 })))
1797 }
1798
1799 async fn semantic_tokens_full_delta(
1800 &self,
1801 params: SemanticTokensDeltaParams,
1802 ) -> Result<Option<SemanticTokensFullDeltaResult>> {
1803 let uri = ¶ms.text_document.uri;
1804 let doc = match self.get_doc(uri) {
1805 Some(d) => d,
1806 None => return Ok(None),
1807 };
1808
1809 let new_tokens = Arc::new(semantic_tokens(doc.source(), &doc));
1810 let new_result_id = token_hash(&new_tokens);
1811 let prev_id = ¶ms.previous_result_id;
1812
1813 let result = match self.docs.get_token_cache(uri, prev_id) {
1814 Some(old_tokens) => {
1815 let edits = compute_token_delta(&old_tokens, &new_tokens);
1816 SemanticTokensFullDeltaResult::TokensDelta(SemanticTokensDelta {
1817 result_id: Some(new_result_id.clone()),
1818 edits,
1819 })
1820 }
1821 None => SemanticTokensFullDeltaResult::Tokens(SemanticTokens {
1823 result_id: Some(new_result_id.clone()),
1824 data: (*new_tokens).clone(),
1825 }),
1826 };
1827
1828 self.docs.store_token_cache(uri, new_result_id, new_tokens);
1829 Ok(Some(result))
1830 }
1831
1832 async fn selection_range(
1833 &self,
1834 params: SelectionRangeParams,
1835 ) -> Result<Option<Vec<SelectionRange>>> {
1836 let uri = ¶ms.text_document.uri;
1837 let doc = match self.get_doc(uri) {
1838 Some(d) => d,
1839 None => return Ok(None),
1840 };
1841 let ranges = selection_ranges(&doc, ¶ms.positions);
1842 Ok(if ranges.is_empty() {
1843 None
1844 } else {
1845 Some(ranges)
1846 })
1847 }
1848
1849 async fn prepare_call_hierarchy(
1850 &self,
1851 params: CallHierarchyPrepareParams,
1852 ) -> Result<Option<Vec<CallHierarchyItem>>> {
1853 let uri = ¶ms.text_document_position_params.text_document.uri;
1854 let position = params.text_document_position_params.position;
1855 let source = self.get_open_text(uri).unwrap_or_default();
1856 let word = match word_at_position(&source, position) {
1857 Some(w) => w,
1858 None => return Ok(None),
1859 };
1860 let all_docs = self.docs.all_docs_for_scan();
1861 Ok(prepare_call_hierarchy(&word, &all_docs).map(|item| vec![item]))
1862 }
1863
1864 async fn incoming_calls(
1865 &self,
1866 params: CallHierarchyIncomingCallsParams,
1867 ) -> Result<Option<Vec<CallHierarchyIncomingCall>>> {
1868 let all_docs = self.docs.all_docs_for_scan();
1869 let calls = incoming_calls(¶ms.item, &all_docs);
1870 Ok(if calls.is_empty() { None } else { Some(calls) })
1871 }
1872
1873 async fn outgoing_calls(
1874 &self,
1875 params: CallHierarchyOutgoingCallsParams,
1876 ) -> Result<Option<Vec<CallHierarchyOutgoingCall>>> {
1877 let all_docs = self.docs.all_docs_for_scan();
1878 let calls = outgoing_calls(¶ms.item, &all_docs);
1879 Ok(if calls.is_empty() { None } else { Some(calls) })
1880 }
1881
1882 async fn document_highlight(
1883 &self,
1884 params: DocumentHighlightParams,
1885 ) -> Result<Option<Vec<DocumentHighlight>>> {
1886 let uri = ¶ms.text_document_position_params.text_document.uri;
1887 let position = params.text_document_position_params.position;
1888 let source = self.get_open_text(uri).unwrap_or_default();
1889 let doc = match self.get_doc(uri) {
1890 Some(d) => d,
1891 None => return Ok(None),
1892 };
1893 let highlights = document_highlights(&source, &doc, position);
1894 Ok(if highlights.is_empty() {
1895 None
1896 } else {
1897 Some(highlights)
1898 })
1899 }
1900
1901 async fn linked_editing_range(
1902 &self,
1903 params: LinkedEditingRangeParams,
1904 ) -> Result<Option<LinkedEditingRanges>> {
1905 let uri = ¶ms.text_document_position_params.text_document.uri;
1906 let position = params.text_document_position_params.position;
1907 let source = self.get_open_text(uri).unwrap_or_default();
1908 let doc = match self.get_doc(uri) {
1909 Some(d) => d,
1910 None => return Ok(None),
1911 };
1912 let word = match crate::util::word_at_position(&source, position) {
1916 Some(w) => w,
1917 None => return Ok(None),
1918 };
1919 let is_variable = word.starts_with('$');
1920 let cursor_word_range = match crate::util::word_range_at(&source, position) {
1921 Some(r) => r,
1922 None => return Ok(None),
1923 };
1924
1925 let highlights = document_highlights(&source, &doc, position);
1927 if highlights.is_empty() {
1928 return Ok(None);
1929 }
1930
1931 if !highlights.iter().any(|h| h.range == cursor_word_range) {
1941 return Ok(None);
1942 }
1943
1944 let scope_to_class = !is_variable
1953 && crate::type_map::enclosing_class_at(&source, &doc, position).as_deref()
1954 != Some(word.as_str());
1955 let other_class_ranges: Vec<Range> = if scope_to_class {
1956 let cursor_class = crate::type_map::enclosing_class_range_at(&doc, position);
1957 crate::type_map::collect_all_class_ranges(&doc)
1958 .into_iter()
1959 .filter(|r| Some(*r) != cursor_class)
1960 .collect()
1961 } else {
1962 Vec::new()
1963 };
1964 let ranges: Vec<Range> = highlights
1965 .into_iter()
1966 .map(|h| h.range)
1967 .filter(|r| !other_class_ranges.iter().any(|ocr| range_within(*r, *ocr)))
1968 .collect();
1969 if ranges.is_empty() {
1970 return Ok(None);
1971 }
1972
1973 let word_pattern = if is_variable {
1980 r"\$[a-zA-Z_\u00A0-\uFFFF][a-zA-Z0-9_\u00A0-\uFFFF]*".to_string()
1981 } else {
1982 r"[a-zA-Z_\u00A0-\uFFFF][a-zA-Z0-9_\u00A0-\uFFFF]*".to_string()
1983 };
1984 Ok(Some(LinkedEditingRanges {
1985 ranges,
1986 word_pattern: Some(word_pattern),
1987 }))
1988 }
1989
1990 async fn goto_implementation(
1991 &self,
1992 params: tower_lsp::lsp_types::request::GotoImplementationParams,
1993 ) -> Result<Option<tower_lsp::lsp_types::request::GotoImplementationResponse>> {
1994 let uri = ¶ms.text_document_position_params.text_document.uri;
1995 let position = params.text_document_position_params.position;
1996 let source = self.get_open_text(uri).unwrap_or_default();
1997 let imports = self.file_imports(uri);
1998 let word = crate::util::word_at_position(&source, position).unwrap_or_default();
1999 let fqn = imports.get(&word).map(|s| s.as_str());
2000 let open_docs = self.docs.docs_for(&self.open_urls());
2002 let mut locs = find_implementations(&word, fqn, &open_docs);
2003 if locs.is_empty() {
2004 let wi = self.docs.get_workspace_index_salsa();
2007 locs = find_implementations_from_workspace(&word, fqn, &wi);
2008 }
2009 if locs.is_empty() {
2010 Ok(None)
2011 } else {
2012 Ok(Some(GotoDefinitionResponse::Array(locs)))
2013 }
2014 }
2015
2016 async fn goto_declaration(
2017 &self,
2018 params: tower_lsp::lsp_types::request::GotoDeclarationParams,
2019 ) -> Result<Option<tower_lsp::lsp_types::request::GotoDeclarationResponse>> {
2020 let uri = ¶ms.text_document_position_params.text_document.uri;
2021 let position = params.text_document_position_params.position;
2022 let source = self.get_open_text(uri).unwrap_or_default();
2023 let open_docs = self.docs.docs_for(&self.open_urls());
2025 if let Some(loc) = goto_declaration(&source, &open_docs, position) {
2026 return Ok(Some(GotoDefinitionResponse::Scalar(loc)));
2027 }
2028 let all_indexes = self.docs.all_indexes();
2030 Ok(goto_declaration_from_index(&source, &all_indexes, position)
2031 .map(GotoDefinitionResponse::Scalar))
2032 }
2033
2034 async fn goto_type_definition(
2035 &self,
2036 params: tower_lsp::lsp_types::request::GotoTypeDefinitionParams,
2037 ) -> Result<Option<tower_lsp::lsp_types::request::GotoTypeDefinitionResponse>> {
2038 let uri = ¶ms.text_document_position_params.text_document.uri;
2039 let position = params.text_document_position_params.position;
2040 let source = self.get_open_text(uri).unwrap_or_default();
2041 let doc = match self.get_doc(uri) {
2042 Some(d) => d,
2043 None => return Ok(None),
2044 };
2045 let doc_returns = self.docs.get_method_returns_salsa(uri);
2046 let open_docs = self.docs.docs_for(&self.open_urls());
2048 if let Some(loc) =
2049 goto_type_definition(&source, &doc, doc_returns.as_deref(), &open_docs, position)
2050 {
2051 return Ok(Some(GotoDefinitionResponse::Scalar(loc)));
2052 }
2053 let all_indexes = self.docs.all_indexes();
2055 Ok(goto_type_definition_from_index(
2056 &source,
2057 &doc,
2058 doc_returns.as_deref(),
2059 &all_indexes,
2060 position,
2061 )
2062 .map(GotoDefinitionResponse::Scalar))
2063 }
2064
2065 async fn prepare_type_hierarchy(
2066 &self,
2067 params: TypeHierarchyPrepareParams,
2068 ) -> Result<Option<Vec<TypeHierarchyItem>>> {
2069 let uri = ¶ms.text_document_position_params.text_document.uri;
2070 let position = params.text_document_position_params.position;
2071 let source = self.get_open_text(uri).unwrap_or_default();
2072 let wi = self.docs.get_workspace_index_salsa();
2074 Ok(prepare_type_hierarchy_from_workspace(&source, &wi, position).map(|item| vec![item]))
2075 }
2076
2077 async fn supertypes(
2078 &self,
2079 params: TypeHierarchySupertypesParams,
2080 ) -> Result<Option<Vec<TypeHierarchyItem>>> {
2081 let wi = self.docs.get_workspace_index_salsa();
2083 let result = supertypes_of_from_workspace(¶ms.item, &wi);
2084 Ok(if result.is_empty() {
2085 None
2086 } else {
2087 Some(result)
2088 })
2089 }
2090
2091 async fn subtypes(
2092 &self,
2093 params: TypeHierarchySubtypesParams,
2094 ) -> Result<Option<Vec<TypeHierarchyItem>>> {
2095 let wi = self.docs.get_workspace_index_salsa();
2097 let result = subtypes_of_from_workspace(¶ms.item, &wi);
2098 Ok(if result.is_empty() {
2099 None
2100 } else {
2101 Some(result)
2102 })
2103 }
2104
2105 async fn code_lens(&self, params: CodeLensParams) -> Result<Option<Vec<CodeLens>>> {
2106 let uri = ¶ms.text_document.uri;
2107 let doc = match self.get_doc(uri) {
2108 Some(d) => d,
2109 None => return Ok(None),
2110 };
2111 let all_docs = self.docs.all_docs_for_scan();
2112 let lenses = code_lenses(uri, &doc, &all_docs);
2113 Ok(if lenses.is_empty() {
2114 None
2115 } else {
2116 Some(lenses)
2117 })
2118 }
2119
2120 async fn code_lens_resolve(&self, params: CodeLens) -> Result<CodeLens> {
2121 Ok(params)
2123 }
2124
2125 async fn document_link(&self, params: DocumentLinkParams) -> Result<Option<Vec<DocumentLink>>> {
2126 let uri = ¶ms.text_document.uri;
2127 let doc = match self.get_doc(uri) {
2128 Some(d) => d,
2129 None => return Ok(None),
2130 };
2131 let links = document_links(uri, &doc, doc.source());
2132 Ok(if links.is_empty() { None } else { Some(links) })
2133 }
2134
2135 async fn document_link_resolve(&self, params: DocumentLink) -> Result<DocumentLink> {
2136 Ok(params)
2138 }
2139
2140 async fn formatting(&self, params: DocumentFormattingParams) -> Result<Option<Vec<TextEdit>>> {
2141 let uri = ¶ms.text_document.uri;
2142 let source = self.get_open_text(uri).unwrap_or_default();
2143 Ok(format_document(&source))
2144 }
2145
2146 async fn range_formatting(
2147 &self,
2148 params: DocumentRangeFormattingParams,
2149 ) -> Result<Option<Vec<TextEdit>>> {
2150 let uri = ¶ms.text_document.uri;
2151 let source = self.get_open_text(uri).unwrap_or_default();
2152 Ok(format_range(&source, params.range))
2153 }
2154
2155 async fn on_type_formatting(
2156 &self,
2157 params: DocumentOnTypeFormattingParams,
2158 ) -> Result<Option<Vec<TextEdit>>> {
2159 let uri = ¶ms.text_document_position.text_document.uri;
2160 let source = self.get_open_text(uri).unwrap_or_default();
2161 let edits = on_type_format(
2162 &source,
2163 params.text_document_position.position,
2164 ¶ms.ch,
2165 ¶ms.options,
2166 );
2167 Ok(if edits.is_empty() { None } else { Some(edits) })
2168 }
2169
2170 async fn execute_command(
2171 &self,
2172 params: ExecuteCommandParams,
2173 ) -> Result<Option<serde_json::Value>> {
2174 match params.command.as_str() {
2175 "php-lsp.runTest" => {
2176 let file_uri = params
2178 .arguments
2179 .first()
2180 .and_then(|v| v.as_str())
2181 .and_then(|s| Url::parse(s).ok());
2182 let filter = params
2183 .arguments
2184 .get(1)
2185 .and_then(|v| v.as_str())
2186 .unwrap_or("")
2187 .to_string();
2188
2189 let root = self.root_paths.read().unwrap().first().cloned();
2190 let client = self.client.clone();
2191
2192 tokio::spawn(async move {
2193 run_phpunit(&client, &filter, root.as_deref(), file_uri.as_ref()).await;
2194 });
2195
2196 Ok(None)
2197 }
2198 _ => Ok(None),
2199 }
2200 }
2201
2202 async fn will_rename_files(&self, params: RenameFilesParams) -> Result<Option<WorkspaceEdit>> {
2203 let psr4 = self.psr4.read().unwrap();
2204 let all_docs = self.docs.all_docs_for_scan();
2205 let mut merged_changes: std::collections::HashMap<
2206 tower_lsp::lsp_types::Url,
2207 Vec<tower_lsp::lsp_types::TextEdit>,
2208 > = std::collections::HashMap::new();
2209
2210 for file_rename in ¶ms.files {
2211 let old_path = tower_lsp::lsp_types::Url::parse(&file_rename.old_uri)
2212 .ok()
2213 .and_then(|u| u.to_file_path().ok());
2214 let new_path = tower_lsp::lsp_types::Url::parse(&file_rename.new_uri)
2215 .ok()
2216 .and_then(|u| u.to_file_path().ok());
2217
2218 let (Some(old_path), Some(new_path)) = (old_path, new_path) else {
2219 continue;
2220 };
2221
2222 let old_fqn = psr4.file_to_fqn(&old_path);
2223 let new_fqn = psr4.file_to_fqn(&new_path);
2224
2225 let (Some(old_fqn), Some(new_fqn)) = (old_fqn, new_fqn) else {
2226 continue;
2227 };
2228
2229 let edit = use_edits_for_rename(&old_fqn, &new_fqn, &all_docs);
2230 if let Some(changes) = edit.changes {
2231 for (uri, edits) in changes {
2232 merged_changes.entry(uri).or_default().extend(edits);
2233 }
2234 }
2235 }
2236
2237 Ok(if merged_changes.is_empty() {
2238 None
2239 } else {
2240 Some(WorkspaceEdit {
2241 changes: Some(merged_changes),
2242 ..Default::default()
2243 })
2244 })
2245 }
2246
2247 async fn did_rename_files(&self, params: RenameFilesParams) {
2248 for file_rename in ¶ms.files {
2249 if let Ok(old_uri) = tower_lsp::lsp_types::Url::parse(&file_rename.old_uri) {
2251 self.docs.remove(&old_uri);
2252 }
2253 if let Ok(new_uri) = tower_lsp::lsp_types::Url::parse(&file_rename.new_uri)
2255 && let Ok(path) = new_uri.to_file_path()
2256 && let Ok(text) = tokio::fs::read_to_string(&path).await
2257 {
2258 self.index_if_not_open(new_uri, &text);
2259 }
2260 }
2261 }
2262
2263 async fn will_create_files(&self, params: CreateFilesParams) -> Result<Option<WorkspaceEdit>> {
2266 let psr4 = self.psr4.read().unwrap();
2267 let mut changes: std::collections::HashMap<Url, Vec<TextEdit>> =
2268 std::collections::HashMap::new();
2269
2270 for file in ¶ms.files {
2271 let Ok(uri) = Url::parse(&file.uri) else {
2272 continue;
2273 };
2274 if !uri.path().ends_with(".php") {
2277 continue;
2278 }
2279
2280 let stub = if let Ok(path) = uri.to_file_path()
2281 && let Some(fqn) = psr4.file_to_fqn(&path)
2282 {
2283 let (ns, class_name) = match fqn.rfind('\\') {
2284 Some(pos) => (&fqn[..pos], &fqn[pos + 1..]),
2285 None => ("", fqn.as_str()),
2286 };
2287 if ns.is_empty() {
2288 format!("<?php\n\ndeclare(strict_types=1);\n\nclass {class_name}\n{{\n}}\n")
2289 } else {
2290 format!(
2291 "<?php\n\ndeclare(strict_types=1);\n\nnamespace {ns};\n\nclass {class_name}\n{{\n}}\n"
2292 )
2293 }
2294 } else {
2295 "<?php\n\n".to_string()
2296 };
2297
2298 changes.insert(
2299 uri,
2300 vec![TextEdit {
2301 range: Range {
2302 start: Position {
2303 line: 0,
2304 character: 0,
2305 },
2306 end: Position {
2307 line: 0,
2308 character: 0,
2309 },
2310 },
2311 new_text: stub,
2312 }],
2313 );
2314 }
2315
2316 Ok(if changes.is_empty() {
2317 None
2318 } else {
2319 Some(WorkspaceEdit {
2320 changes: Some(changes),
2321 ..Default::default()
2322 })
2323 })
2324 }
2325
2326 async fn did_create_files(&self, params: CreateFilesParams) {
2327 for file in ¶ms.files {
2328 if let Ok(uri) = Url::parse(&file.uri)
2329 && let Ok(path) = uri.to_file_path()
2330 && let Ok(text) = tokio::fs::read_to_string(&path).await
2331 {
2332 self.index_if_not_open(uri, &text);
2333 }
2334 }
2335 send_refresh_requests(&self.client).await;
2336 }
2337
2338 async fn will_delete_files(&self, params: DeleteFilesParams) -> Result<Option<WorkspaceEdit>> {
2343 let psr4 = self.psr4.read().unwrap();
2344 let all_docs = self.docs.all_docs_for_scan();
2345 let mut merged_changes: std::collections::HashMap<Url, Vec<TextEdit>> =
2346 std::collections::HashMap::new();
2347
2348 for file in ¶ms.files {
2349 let path = Url::parse(&file.uri)
2350 .ok()
2351 .and_then(|u| u.to_file_path().ok());
2352 let Some(path) = path else { continue };
2353 let Some(fqn) = psr4.file_to_fqn(&path) else {
2354 continue;
2355 };
2356
2357 let edit = use_edits_for_delete(&fqn, &all_docs);
2358 if let Some(changes) = edit.changes {
2359 for (uri, edits) in changes {
2360 merged_changes.entry(uri).or_default().extend(edits);
2361 }
2362 }
2363 }
2364
2365 Ok(if merged_changes.is_empty() {
2366 None
2367 } else {
2368 Some(WorkspaceEdit {
2369 changes: Some(merged_changes),
2370 ..Default::default()
2371 })
2372 })
2373 }
2374
2375 async fn did_delete_files(&self, params: DeleteFilesParams) {
2376 for file in ¶ms.files {
2377 if let Ok(uri) = Url::parse(&file.uri) {
2378 self.docs.remove(&uri);
2379 self.client.publish_diagnostics(uri, vec![], None).await;
2381 }
2382 }
2383 send_refresh_requests(&self.client).await;
2384 }
2385
2386 async fn moniker(&self, params: MonikerParams) -> Result<Option<Vec<Moniker>>> {
2389 let uri = ¶ms.text_document_position_params.text_document.uri;
2390 let position = params.text_document_position_params.position;
2391 let source = self.get_open_text(uri).unwrap_or_default();
2392 let doc = match self.get_doc(uri) {
2393 Some(d) => d,
2394 None => return Ok(None),
2395 };
2396 let imports = self.file_imports(uri);
2397 Ok(moniker_at(&source, &doc, position, &imports).map(|m| vec![m]))
2398 }
2399
2400 async fn inline_value(&self, params: InlineValueParams) -> Result<Option<Vec<InlineValue>>> {
2403 let uri = ¶ms.text_document.uri;
2404 let source = self.get_open_text(uri).unwrap_or_default();
2405 let values = inline_values_in_range(&source, params.range);
2406 Ok(if values.is_empty() {
2407 None
2408 } else {
2409 Some(values)
2410 })
2411 }
2412
2413 async fn diagnostic(
2414 &self,
2415 params: DocumentDiagnosticParams,
2416 ) -> Result<DocumentDiagnosticReportResult> {
2417 let uri = ¶ms.text_document.uri;
2418 let source = self.get_open_text(uri).unwrap_or_default();
2419
2420 let parse_diags = self.get_parse_diagnostics(uri).unwrap_or_default();
2421 let doc = match self.get_doc(uri) {
2422 Some(d) => d,
2423 None => {
2424 let _version = self
2426 .open_files
2427 .all_with_diagnostics()
2428 .iter()
2429 .find(|(u, _, _)| u == uri)
2430 .and_then(|(_, _, v)| *v)
2431 .unwrap_or(1);
2432 let result_id = compute_diagnostic_result_id(&parse_diags, uri.as_str());
2433 return Ok(DocumentDiagnosticReportResult::Report(
2434 DocumentDiagnosticReport::Full(RelatedFullDocumentDiagnosticReport {
2435 related_documents: None,
2436 full_document_diagnostic_report: FullDocumentDiagnosticReport {
2437 result_id: Some(result_id),
2438 items: parse_diags,
2439 },
2440 }),
2441 ));
2442 }
2443 };
2444 let (diag_cfg, php_version) = {
2445 let cfg = self.config.read().unwrap();
2446 (cfg.diagnostics.clone(), cfg.php_version.clone())
2447 };
2448 let _ = php_version;
2450
2451 let docs = Arc::clone(&self.docs);
2453 let uri_owned = uri.clone();
2454 let diag_cfg_sem = diag_cfg.clone();
2455 let sem_diags = tokio::task::spawn_blocking(move || {
2456 docs.get_semantic_issues_salsa(&uri_owned)
2457 .map(|issues| {
2458 crate::semantic_diagnostics::issues_to_diagnostics(
2459 &issues,
2460 &uri_owned,
2461 &diag_cfg_sem,
2462 )
2463 })
2464 .unwrap_or_default()
2465 })
2466 .await
2467 .map_err(|e| {
2468 use std::borrow::Cow;
2469 tower_lsp::jsonrpc::Error {
2470 code: tower_lsp::jsonrpc::ErrorCode::InternalError,
2471 message: Cow::Owned(format!("diagnostic analysis failed: {}", e)),
2472 data: None,
2473 }
2474 })?;
2475
2476 let mut items = parse_diags;
2477 items.extend(sem_diags);
2478 items.extend(duplicate_declaration_diagnostics(&source, &doc, &diag_cfg));
2479
2480 let _version = self
2482 .open_files
2483 .all_with_diagnostics()
2484 .iter()
2485 .find(|(u, _, _)| u == uri)
2486 .and_then(|(_, _, v)| *v)
2487 .unwrap_or(1);
2488 let result_id = compute_diagnostic_result_id(&items, uri.as_str());
2489
2490 Ok(DocumentDiagnosticReportResult::Report(
2491 DocumentDiagnosticReport::Full(RelatedFullDocumentDiagnosticReport {
2492 related_documents: None,
2493 full_document_diagnostic_report: FullDocumentDiagnosticReport {
2494 result_id: Some(result_id),
2495 items,
2496 },
2497 }),
2498 ))
2499 }
2500
2501 async fn workspace_diagnostic(
2502 &self,
2503 params: WorkspaceDiagnosticParams,
2504 ) -> Result<WorkspaceDiagnosticReportResult> {
2505 let all_parse_diags = self.all_open_files_with_diagnostics();
2506 let (diag_cfg, php_version) = {
2507 let cfg = self.config.read().unwrap();
2508 (cfg.diagnostics.clone(), cfg.php_version.clone())
2509 };
2510
2511 let _ = php_version;
2513
2514 let previous_map: std::collections::HashMap<Url, String> = params
2520 .previous_result_ids
2521 .into_iter()
2522 .map(|p| (p.uri, p.value))
2523 .collect();
2524
2525 let docs = Arc::clone(&self.docs);
2533 let diag_cfg_sweep = diag_cfg.clone();
2534 let items = tokio::task::spawn_blocking(move || {
2535 all_parse_diags
2536 .into_iter()
2537 .filter_map(|(uri, parse_diags, version)| {
2538 let doc = docs.get_doc_salsa(&uri)?;
2539
2540 let source = doc.source().to_string();
2541 let sem_diags = docs
2542 .get_semantic_issues_salsa(&uri)
2543 .map(|issues| {
2544 crate::semantic_diagnostics::issues_to_diagnostics(
2545 &issues,
2546 &uri,
2547 &diag_cfg_sweep,
2548 )
2549 })
2550 .unwrap_or_default();
2551 let mut all_diags = parse_diags;
2552 all_diags.extend(sem_diags);
2553 all_diags.extend(duplicate_declaration_diagnostics(
2554 &source,
2555 &doc,
2556 &diag_cfg_sweep,
2557 ));
2558
2559 let result_id = compute_diagnostic_result_id(&all_diags, uri.as_str());
2560
2561 if previous_map.get(&uri) == Some(&result_id) {
2564 Some(WorkspaceDocumentDiagnosticReport::Unchanged(
2565 WorkspaceUnchangedDocumentDiagnosticReport {
2566 uri,
2567 version,
2568 unchanged_document_diagnostic_report:
2569 UnchangedDocumentDiagnosticReport { result_id },
2570 },
2571 ))
2572 } else {
2573 Some(WorkspaceDocumentDiagnosticReport::Full(
2574 WorkspaceFullDocumentDiagnosticReport {
2575 uri,
2576 version,
2577 full_document_diagnostic_report: FullDocumentDiagnosticReport {
2578 result_id: Some(result_id),
2579 items: all_diags,
2580 },
2581 },
2582 ))
2583 }
2584 })
2585 .collect::<Vec<_>>()
2586 })
2587 .await
2588 .map_err(|e| {
2589 use std::borrow::Cow;
2590 tower_lsp::jsonrpc::Error {
2591 code: tower_lsp::jsonrpc::ErrorCode::InternalError,
2592 message: Cow::Owned(format!("workspace_diagnostic analysis failed: {}", e)),
2593 data: None,
2594 }
2595 })?;
2596
2597 Ok(WorkspaceDiagnosticReportResult::Report(
2598 WorkspaceDiagnosticReport { items },
2599 ))
2600 }
2601
2602 async fn code_action(&self, params: CodeActionParams) -> Result<Option<CodeActionResponse>> {
2603 let uri = ¶ms.text_document.uri;
2604 let source = self.get_open_text(uri).unwrap_or_default();
2605 let doc = match self.get_doc(uri) {
2606 Some(d) => d,
2607 None => return Ok(None),
2608 };
2609 let other_docs = self.docs.other_docs(uri, &self.open_urls());
2610
2611 let diag_cfg = self.config.read().unwrap().diagnostics.clone();
2618 let docs_sem = Arc::clone(&self.docs);
2619 let uri_sem = uri.clone();
2620 let diag_cfg_sem = diag_cfg.clone();
2621 let sem_diags = tokio::task::spawn_blocking(move || {
2622 docs_sem
2623 .get_semantic_issues_salsa(&uri_sem)
2624 .map(|issues| {
2625 crate::semantic_diagnostics::issues_to_diagnostics(
2626 &issues,
2627 &uri_sem,
2628 &diag_cfg_sem,
2629 )
2630 })
2631 .unwrap_or_default()
2632 })
2633 .await
2634 .unwrap_or_default();
2635
2636 let mut actions: Vec<CodeActionOrCommand> = Vec::new();
2638 for diag in &sem_diags {
2639 if diag.code != Some(NumberOrString::String("UndefinedClass".to_string())) {
2640 continue;
2641 }
2642 if diag.range.start.line < params.range.start.line
2644 || diag.range.start.line > params.range.end.line
2645 {
2646 continue;
2647 }
2648 let class_name = diag
2650 .message
2651 .strip_prefix("Class ")
2652 .and_then(|s| s.strip_suffix(" does not exist"))
2653 .unwrap_or("")
2654 .trim();
2655 if class_name.is_empty() {
2656 continue;
2657 }
2658
2659 for (_other_uri, other_doc) in &other_docs {
2661 if let Some(fqn) = find_fqn_for_class(other_doc, class_name) {
2662 let edit = build_use_import_edit(&source, uri, &fqn);
2663 let action = CodeAction {
2664 title: format!("Add use {fqn}"),
2665 kind: Some(CodeActionKind::QUICKFIX),
2666 edit: Some(edit),
2667 diagnostics: Some(vec![diag.clone()]),
2668 ..Default::default()
2669 };
2670 actions.push(CodeActionOrCommand::CodeAction(action));
2671 break; }
2673 }
2674 }
2675
2676 for tag in DEFERRED_ACTION_TAGS {
2679 actions.extend(defer_actions(
2680 self.generate_deferred_actions(tag, &source, &doc, params.range, uri),
2681 tag,
2682 uri,
2683 params.range,
2684 ));
2685 }
2686
2687 actions.extend(extract_variable_actions(&source, params.range, uri));
2689 actions.extend(extract_method_actions(&source, &doc, params.range, uri));
2690 actions.extend(extract_constant_actions(&source, params.range, uri));
2691 actions.extend(inline_variable_actions(&source, params.range, uri));
2693 if let Some(action) = organize_imports_action(&source, uri) {
2695 actions.push(action);
2696 }
2697
2698 Ok(if actions.is_empty() {
2699 None
2700 } else {
2701 Some(actions)
2702 })
2703 }
2704
2705 async fn code_action_resolve(&self, item: CodeAction) -> Result<CodeAction> {
2706 let data = match &item.data {
2707 Some(d) => d.clone(),
2708 None => return Ok(item),
2709 };
2710 let kind_tag = match data.get("php_lsp_resolve").and_then(|v| v.as_str()) {
2711 Some(k) => k.to_string(),
2712 None => return Ok(item),
2713 };
2714 let uri: Url = match data
2715 .get("uri")
2716 .and_then(|v| v.as_str())
2717 .and_then(|s| Url::parse(s).ok())
2718 {
2719 Some(u) => u,
2720 None => return Ok(item),
2721 };
2722 let range: Range = match data
2723 .get("range")
2724 .and_then(|v| serde_json::from_value(v.clone()).ok())
2725 {
2726 Some(r) => r,
2727 None => return Ok(item),
2728 };
2729
2730 let source = self.get_open_text(&uri).unwrap_or_default();
2731 let doc = match self.get_doc(&uri) {
2732 Some(d) => d,
2733 None => return Ok(item),
2734 };
2735
2736 let candidates = self.generate_deferred_actions(&kind_tag, &source, &doc, range, &uri);
2737
2738 for candidate in candidates {
2740 if let CodeActionOrCommand::CodeAction(ca) = candidate
2741 && ca.title == item.title
2742 {
2743 return Ok(ca);
2744 }
2745 }
2746
2747 Ok(item)
2748 }
2749}
2750
2751fn php_file_op() -> FileOperationRegistrationOptions {
2753 FileOperationRegistrationOptions {
2754 filters: vec![FileOperationFilter {
2755 scheme: Some("file".to_string()),
2756 pattern: FileOperationPattern {
2757 glob: "**/*.php".to_string(),
2758 matches: Some(FileOperationPatternKind::File),
2759 options: None,
2760 },
2761 }],
2762 }
2763}
2764
2765fn defer_actions(
2768 actions: Vec<CodeActionOrCommand>,
2769 kind_tag: &str,
2770 uri: &Url,
2771 range: Range,
2772) -> Vec<CodeActionOrCommand> {
2773 actions
2774 .into_iter()
2775 .map(|a| match a {
2776 CodeActionOrCommand::CodeAction(mut ca) => {
2777 ca.edit = None;
2778 ca.data = Some(serde_json::json!({
2779 "php_lsp_resolve": kind_tag,
2780 "uri": uri.to_string(),
2781 "range": range,
2782 }));
2783 CodeActionOrCommand::CodeAction(ca)
2784 }
2785 other => other,
2786 })
2787 .collect()
2788}
2789
2790fn is_after_arrow(source: &str, position: Position) -> bool {
2793 let line = match source.lines().nth(position.line as usize) {
2794 Some(l) => l,
2795 None => return false,
2796 };
2797 let chars: Vec<char> = line.chars().collect();
2798 let col = position.character as usize;
2799 let mut utf16_col = 0usize;
2801 let mut char_idx = 0usize;
2802 for ch in &chars {
2803 if utf16_col >= col {
2804 break;
2805 }
2806 utf16_col += ch.len_utf16();
2807 char_idx += 1;
2808 }
2809 let is_word = |c: char| c.is_alphanumeric() || c == '_';
2811 while char_idx > 0 && is_word(chars[char_idx - 1]) {
2812 char_idx -= 1;
2813 }
2814 char_idx >= 2 && chars[char_idx - 1] == '>' && chars[char_idx - 2] == '-'
2815}
2816
2817fn symbol_kind_at(source: &str, position: Position, word: &str) -> Option<SymbolKind> {
2828 if word.starts_with('$') {
2829 return None; }
2831 let line = source.lines().nth(position.line as usize)?;
2832 let chars: Vec<char> = line.chars().collect();
2833
2834 let col = position.character as usize;
2836 let mut utf16_col = 0usize;
2837 let mut char_idx = 0usize;
2838 for ch in &chars {
2839 if utf16_col >= col {
2840 break;
2841 }
2842 utf16_col += ch.len_utf16();
2843 char_idx += 1;
2844 }
2845
2846 let is_word_char = |c: char| c.is_alphanumeric() || c == '_';
2848 while char_idx > 0 && is_word_char(chars[char_idx - 1]) {
2849 char_idx -= 1;
2850 }
2851
2852 let word_end = {
2854 let mut i = char_idx;
2855 while i < chars.len() && is_word_char(chars[i]) {
2856 i += 1;
2857 }
2858 while i < chars.len() && chars[i] == ' ' {
2860 i += 1;
2861 }
2862 i
2863 };
2864 let next_is_call = word_end < chars.len() && chars[word_end] == '(';
2865
2866 if char_idx >= 2 && chars[char_idx - 1] == '>' && chars[char_idx - 2] == '-' {
2868 return if next_is_call {
2869 Some(SymbolKind::Method)
2870 } else {
2871 Some(SymbolKind::Property)
2872 };
2873 }
2874 if char_idx >= 3
2875 && chars[char_idx - 1] == '>'
2876 && chars[char_idx - 2] == '-'
2877 && chars[char_idx - 3] == '?'
2878 {
2879 return if next_is_call {
2880 Some(SymbolKind::Method)
2881 } else {
2882 Some(SymbolKind::Property)
2883 };
2884 }
2885
2886 if char_idx >= 2 && chars[char_idx - 1] == ':' && chars[char_idx - 2] == ':' {
2888 return Some(SymbolKind::Method);
2889 }
2890
2891 if word
2893 .chars()
2894 .next()
2895 .map(|c| c.is_uppercase())
2896 .unwrap_or(false)
2897 {
2898 return Some(SymbolKind::Class);
2899 }
2900
2901 Some(SymbolKind::Function)
2903}
2904
2905fn range_within(inner: Range, outer: Range) -> bool {
2911 let start_ok =
2912 (inner.start.line, inner.start.character) >= (outer.start.line, outer.start.character);
2913 let end_ok = (inner.end.line, inner.end.character) <= (outer.end.line, outer.end.character);
2914 start_ok && end_ok
2915}
2916
2917fn position_to_byte_offset(source: &str, position: Position) -> Option<u32> {
2918 let mut byte_offset = 0usize;
2919 for (idx, line) in source.split('\n').enumerate() {
2920 if idx as u32 == position.line {
2921 let line_content = line.trim_end_matches('\r');
2923 let mut col = 0u32;
2924 for (byte_idx, ch) in line_content.char_indices() {
2925 if col >= position.character {
2926 return Some((byte_offset + byte_idx) as u32);
2927 }
2928 col += ch.len_utf16() as u32;
2929 }
2930 return Some((byte_offset + line_content.len()) as u32);
2931 }
2932 byte_offset += line.len() + 1; }
2934 None
2935}
2936
2937fn cursor_is_on_method_decl(source: &str, stmts: &[Stmt<'_, '_>], position: Position) -> bool {
2944 let Some(cursor) = position_to_byte_offset(source, position) else {
2945 return false;
2946 };
2947
2948 fn name_offset_in_member(source: &str, member_span: php_ast::Span, name: &str) -> Option<u32> {
2954 let s = member_span.start as usize;
2955 let e = (member_span.end as usize).min(source.len());
2956 source
2957 .get(s..e)?
2958 .find(name)
2959 .map(|off| member_span.start + off as u32)
2960 }
2961 fn check(source: &str, stmts: &[Stmt<'_, '_>], cursor: u32) -> bool {
2962 for stmt in stmts {
2963 match &stmt.kind {
2964 StmtKind::Class(c) => {
2965 for member in c.members.iter() {
2966 if let ClassMemberKind::Method(m) = &member.kind {
2967 let name = m.name.to_string();
2968 let start =
2969 name_offset_in_member(source, member.span, &name).unwrap_or(0);
2970 let end = start + name.len() as u32;
2971 if cursor >= start && cursor < end {
2972 return true;
2973 }
2974 }
2975 }
2976 }
2977 StmtKind::Interface(i) => {
2978 for member in i.members.iter() {
2979 if let ClassMemberKind::Method(m) = &member.kind {
2980 let name = m.name.to_string();
2981 let start =
2982 name_offset_in_member(source, member.span, &name).unwrap_or(0);
2983 let end = start + name.len() as u32;
2984 if cursor >= start && cursor < end {
2985 return true;
2986 }
2987 }
2988 }
2989 }
2990 StmtKind::Trait(t) => {
2991 for member in t.members.iter() {
2992 if let ClassMemberKind::Method(m) = &member.kind {
2993 let name = m.name.to_string();
2994 let start =
2995 name_offset_in_member(source, member.span, &name).unwrap_or(0);
2996 let end = start + name.len() as u32;
2997 if cursor >= start && cursor < end {
2998 return true;
2999 }
3000 }
3001 }
3002 }
3003 StmtKind::Enum(e) => {
3004 for member in e.members.iter() {
3005 if let EnumMemberKind::Method(m) = &member.kind {
3006 let name = m.name.to_string();
3007 let start =
3008 name_offset_in_member(source, member.span, &name).unwrap_or(0);
3009 let end = start + name.len() as u32;
3010 if cursor >= start && cursor < end {
3011 return true;
3012 }
3013 }
3014 }
3015 }
3016 StmtKind::Namespace(ns) => {
3017 if let NamespaceBody::Braced(inner) = &ns.body
3018 && check(source, inner, cursor)
3019 {
3020 return true;
3021 }
3022 }
3023 _ => {}
3024 }
3025 }
3026 false
3027 }
3028
3029 check(source, stmts, cursor)
3030}
3031
3032fn cursor_is_on_property_decl(
3037 source: &str,
3038 stmts: &[Stmt<'_, '_>],
3039 position: Position,
3040) -> Option<String> {
3041 let cursor = position_to_byte_offset(source, position)?;
3042
3043 fn name_offset_in_member(source: &str, member_span: php_ast::Span, name: &str) -> Option<u32> {
3044 let s = member_span.start as usize;
3045 let e = (member_span.end as usize).min(source.len());
3046 source
3047 .get(s..e)?
3048 .find(name)
3049 .map(|off| member_span.start + off as u32)
3050 }
3051 fn check(source: &str, stmts: &[Stmt<'_, '_>], cursor: u32) -> Option<String> {
3052 for stmt in stmts {
3053 match &stmt.kind {
3054 StmtKind::Class(c) => {
3055 for member in c.members.iter() {
3056 if let ClassMemberKind::Property(p) = &member.kind {
3057 let name = p.name.to_string();
3058 let start =
3059 name_offset_in_member(source, member.span, &name).unwrap_or(0);
3060 let end = start + name.len() as u32;
3061 if cursor >= start && cursor < end {
3062 return Some(name);
3063 }
3064 }
3065 }
3066 }
3067 StmtKind::Trait(t) => {
3068 for member in t.members.iter() {
3069 if let ClassMemberKind::Property(p) = &member.kind {
3070 let name = p.name.to_string();
3071 let start =
3072 name_offset_in_member(source, member.span, &name).unwrap_or(0);
3073 let end = start + name.len() as u32;
3074 if cursor >= start && cursor < end {
3075 return Some(name);
3076 }
3077 }
3078 }
3079 }
3080 StmtKind::Namespace(ns) => {
3081 if let NamespaceBody::Braced(inner) = &ns.body
3082 && let Some(name) = check(source, inner, cursor)
3083 {
3084 return Some(name);
3085 }
3086 }
3087 _ => {}
3088 }
3089 }
3090 None
3091 }
3092
3093 check(source, stmts, cursor)
3094}
3095
3096fn class_name_at_construct_decl(
3103 source: &str,
3104 stmts: &[Stmt<'_, '_>],
3105 position: Position,
3106) -> Option<String> {
3107 let cursor = position_to_byte_offset(source, position)?;
3108
3109 fn name_offset_in_member(source: &str, member_span: php_ast::Span, name: &str) -> Option<u32> {
3110 let s = member_span.start as usize;
3111 let e = (member_span.end as usize).min(source.len());
3112 source
3113 .get(s..e)?
3114 .find(name)
3115 .map(|off| member_span.start + off as u32)
3116 }
3117 fn check(source: &str, stmts: &[Stmt<'_, '_>], cursor: u32, ns_prefix: &str) -> Option<String> {
3118 let mut current_ns = ns_prefix.to_owned();
3119 for stmt in stmts {
3120 match &stmt.kind {
3121 StmtKind::Class(c) => {
3122 for member in c.members.iter() {
3123 if let ClassMemberKind::Method(m) = &member.kind
3124 && m.name == "__construct"
3125 {
3126 let name = m.name.to_string();
3133 let start =
3134 name_offset_in_member(source, member.span, &name).unwrap_or(0);
3135 let end = start + name.len() as u32;
3136 if cursor >= start && cursor < end {
3137 let short = c.name?;
3138 return Some(if current_ns.is_empty() {
3139 short.to_string()
3140 } else {
3141 format!("{}\\{}", current_ns, short)
3142 });
3143 }
3144 }
3145 }
3146 }
3147 StmtKind::Namespace(ns) => {
3148 let ns_name = ns
3149 .name
3150 .as_ref()
3151 .map(|n| n.to_string_repr().to_string())
3152 .unwrap_or_default();
3153 match &ns.body {
3154 NamespaceBody::Braced(inner) => {
3155 if let Some(name) = check(source, inner, cursor, &ns_name) {
3156 return Some(name);
3157 }
3158 }
3159 NamespaceBody::Simple => {
3160 current_ns = ns_name;
3161 }
3162 }
3163 }
3164 _ => {}
3165 }
3166 }
3167 None
3168 }
3169
3170 check(source, stmts, cursor, "")
3171}
3172
3173fn promoted_property_at_cursor(
3181 source: &str,
3182 stmts: &[Stmt<'_, '_>],
3183 position: Position,
3184) -> Option<String> {
3185 let cursor = position_to_byte_offset(source, position)?;
3186
3187 fn check(source: &str, stmts: &[Stmt<'_, '_>], cursor: u32) -> Option<String> {
3188 for stmt in stmts {
3189 match &stmt.kind {
3190 StmtKind::Class(c) => {
3191 for member in c.members.iter() {
3192 if let ClassMemberKind::Method(m) = &member.kind
3193 && m.name == "__construct"
3194 {
3195 for param in m.params.iter() {
3196 if param.visibility.is_none() {
3197 continue;
3198 }
3199 let name_start =
3200 str_offset(source, ¶m.name.to_string()).unwrap_or(0);
3201 let name_end = name_start + param.name.to_string().len() as u32;
3202 if cursor >= name_start && cursor < name_end {
3203 return Some(
3204 param.name.to_string().trim_start_matches('$').to_string(),
3205 );
3206 }
3207 }
3208 }
3209 }
3210 }
3211 StmtKind::Namespace(ns) => {
3212 if let NamespaceBody::Braced(inner) = &ns.body
3213 && let Some(name) = check(source, inner, cursor)
3214 {
3215 return Some(name);
3216 }
3217 }
3218 _ => {}
3219 }
3220 }
3221 None
3222 }
3223
3224 check(source, stmts, cursor)
3225}
3226
3227const DEFERRED_ACTION_TAGS: &[&str] = &[
3230 "phpdoc",
3231 "implement",
3232 "constructor",
3233 "getters_setters",
3234 "return_type",
3235 "promote",
3236];
3237
3238impl Backend {
3239 fn generate_deferred_actions(
3241 &self,
3242 tag: &str,
3243 source: &str,
3244 doc: &Arc<ParsedDoc>,
3245 range: Range,
3246 uri: &Url,
3247 ) -> Vec<CodeActionOrCommand> {
3248 match tag {
3249 "phpdoc" => phpdoc_actions(uri, doc, source, range),
3250 "implement" => {
3251 let imports = self.file_imports(uri);
3252 implement_missing_actions(
3253 source,
3254 doc,
3255 &self
3256 .docs
3257 .doc_with_others(uri, Arc::clone(doc), &self.open_urls()),
3258 range,
3259 uri,
3260 &imports,
3261 )
3262 }
3263 "constructor" => generate_constructor_actions(source, doc, range, uri),
3264 "getters_setters" => generate_getters_setters_actions(source, doc, range, uri),
3265 "return_type" => add_return_type_actions(source, doc, range, uri),
3266 "promote" => promote_constructor_actions(source, doc, range, uri),
3267 _ => Vec::new(),
3268 }
3269 }
3270
3271 async fn psr4_goto(&self, fqn: &str) -> Option<Location> {
3274 let path = {
3275 let psr4 = self.psr4.read().unwrap();
3276 psr4.resolve(fqn)?
3277 };
3278
3279 let file_uri = Url::from_file_path(&path).ok()?;
3280
3281 if self.docs.get_doc_salsa(&file_uri).is_none() {
3286 let text = tokio::fs::read_to_string(&path).await.ok()?;
3287 self.index_if_not_open(file_uri.clone(), &text);
3288 }
3289
3290 let doc = self.docs.get_doc_salsa(&file_uri)?;
3291
3292 let short_name = fqn.split('\\').next_back()?;
3295 let range = find_declaration_range(doc.source(), &doc, short_name)?;
3296
3297 Some(Location {
3298 uri: file_uri,
3299 range,
3300 })
3301 }
3302}
3303
3304async fn run_phpunit(
3310 client: &Client,
3311 filter: &str,
3312 root: Option<&std::path::Path>,
3313 file_uri: Option<&Url>,
3314) {
3315 let output = tokio::process::Command::new("vendor/bin/phpunit")
3316 .arg("--filter")
3317 .arg(filter)
3318 .current_dir(root.unwrap_or(std::path::Path::new(".")))
3319 .output()
3320 .await;
3321
3322 let (success, message) = match output {
3323 Ok(out) => {
3324 let text = String::from_utf8_lossy(&out.stdout).into_owned()
3325 + &String::from_utf8_lossy(&out.stderr);
3326 let last_line = text
3327 .lines()
3328 .rev()
3329 .find(|l| !l.trim().is_empty())
3330 .unwrap_or("(no output)")
3331 .to_string();
3332 let ok = out.status.success();
3333 let msg = if ok {
3334 format!("✓ {filter}: {last_line}")
3335 } else {
3336 format!("✗ {filter}: {last_line}")
3337 };
3338 (ok, msg)
3339 }
3340 Err(e) => (
3341 false,
3342 format!("php-lsp.runTest: failed to spawn phpunit — {e}"),
3343 ),
3344 };
3345
3346 let msg_type = if success {
3347 MessageType::INFO
3348 } else {
3349 MessageType::ERROR
3350 };
3351 let mut actions = vec![MessageActionItem {
3352 title: "Run Again".to_string(),
3353 properties: Default::default(),
3354 }];
3355 if !success && file_uri.is_some() {
3356 actions.push(MessageActionItem {
3357 title: "Open File".to_string(),
3358 properties: Default::default(),
3359 });
3360 }
3361
3362 let chosen = client
3363 .show_message_request(msg_type, message, Some(actions))
3364 .await;
3365
3366 match chosen {
3367 Ok(Some(ref action)) if action.title == "Run Again" => {
3368 let output2 = tokio::process::Command::new("vendor/bin/phpunit")
3370 .arg("--filter")
3371 .arg(filter)
3372 .current_dir(root.unwrap_or(std::path::Path::new(".")))
3373 .output()
3374 .await;
3375 let msg2 = match output2 {
3376 Ok(out) => {
3377 let text = String::from_utf8_lossy(&out.stdout).into_owned()
3378 + &String::from_utf8_lossy(&out.stderr);
3379 let last_line = text
3380 .lines()
3381 .rev()
3382 .find(|l| !l.trim().is_empty())
3383 .unwrap_or("(no output)")
3384 .to_string();
3385 if out.status.success() {
3386 format!("✓ {filter}: {last_line}")
3387 } else {
3388 format!("✗ {filter}: {last_line}")
3389 }
3390 }
3391 Err(e) => format!("php-lsp.runTest: failed to spawn phpunit — {e}"),
3392 };
3393 client.show_message(MessageType::INFO, msg2).await;
3394 }
3395 Ok(Some(ref action)) if action.title == "Open File" => {
3396 if let Some(uri) = file_uri {
3397 client
3398 .show_document(ShowDocumentParams {
3399 uri: uri.clone(),
3400 external: Some(false),
3401 take_focus: Some(true),
3402 selection: None,
3403 })
3404 .await
3405 .ok();
3406 }
3407 }
3408 _ => {}
3409 }
3410}
3411
3412#[cfg(test)]
3413mod tests {
3414 use super::*;
3415 use crate::config::{DiagnosticsConfig, FeaturesConfig, MAX_INDEXED_FILES};
3416 use crate::use_import::find_use_insert_line;
3417 use tower_lsp::lsp_types::{Position, Range, Url};
3418
3419 #[test]
3421 fn diagnostics_config_default_is_enabled() {
3422 let cfg = DiagnosticsConfig::default();
3423 assert!(cfg.enabled);
3424 assert!(cfg.undefined_variables);
3425 assert!(cfg.undefined_functions);
3426 assert!(cfg.undefined_classes);
3427 assert!(cfg.arity_errors);
3428 assert!(cfg.type_errors);
3429 assert!(cfg.deprecated_calls);
3430 assert!(cfg.duplicate_declarations);
3431 }
3432
3433 #[test]
3434 fn diagnostics_config_from_empty_object_is_enabled() {
3435 let cfg = DiagnosticsConfig::from_value(&serde_json::json!({}));
3436 assert!(cfg.enabled);
3437 assert!(cfg.undefined_variables);
3438 }
3439
3440 #[test]
3441 fn diagnostics_config_from_non_object_uses_defaults() {
3442 let cfg = DiagnosticsConfig::from_value(&serde_json::json!(null));
3443 assert!(cfg.enabled);
3444 }
3445
3446 #[test]
3447 fn diagnostics_config_can_disable_individual_flags() {
3448 let cfg = DiagnosticsConfig::from_value(&serde_json::json!({
3449 "enabled": true,
3450 "undefinedVariables": false,
3451 "undefinedFunctions": false,
3452 "undefinedClasses": true,
3453 "arityErrors": false,
3454 "typeErrors": true,
3455 "deprecatedCalls": false,
3456 "duplicateDeclarations": true,
3457 }));
3458 assert!(cfg.enabled);
3459 assert!(!cfg.undefined_variables);
3460 assert!(!cfg.undefined_functions);
3461 assert!(cfg.undefined_classes);
3462 assert!(!cfg.arity_errors);
3463 assert!(cfg.type_errors);
3464 assert!(!cfg.deprecated_calls);
3465 assert!(cfg.duplicate_declarations);
3466 }
3467
3468 #[test]
3469 fn diagnostics_config_master_switch_disables_all() {
3470 let cfg = DiagnosticsConfig::from_value(&serde_json::json!({"enabled": false}));
3471 assert!(!cfg.enabled);
3472 assert!(cfg.undefined_variables);
3474 }
3475
3476 #[test]
3477 fn diagnostics_config_master_switch_enables_all() {
3478 let cfg = DiagnosticsConfig::from_value(&serde_json::json!({"enabled": true}));
3479 assert!(cfg.enabled);
3480 assert!(cfg.undefined_variables);
3481 }
3482
3483 #[test]
3485 fn lsp_config_default_is_empty() {
3486 let cfg = LspConfig::default();
3487 assert!(cfg.php_version.is_none());
3488 assert!(cfg.exclude_paths.is_empty());
3489 assert!(cfg.diagnostics.enabled);
3490 }
3491
3492 #[test]
3493 fn lsp_config_parses_php_version() {
3494 let cfg =
3495 LspConfig::from_value(&serde_json::json!({"phpVersion": crate::autoload::PHP_8_2}));
3496 assert_eq!(cfg.php_version.as_deref(), Some(crate::autoload::PHP_8_2));
3497 }
3498
3499 #[test]
3500 fn lsp_config_parses_exclude_paths() {
3501 let cfg = LspConfig::from_value(&serde_json::json!({
3502 "excludePaths": ["cache/*", "generated/*"]
3503 }));
3504 assert_eq!(cfg.exclude_paths, vec!["cache/*", "generated/*"]);
3505 }
3506
3507 #[test]
3508 fn lsp_config_parses_diagnostics_section() {
3509 let cfg = LspConfig::from_value(&serde_json::json!({
3510 "diagnostics": {"enabled": false}
3511 }));
3512 assert!(!cfg.diagnostics.enabled);
3513 }
3514
3515 #[test]
3516 fn lsp_config_ignores_missing_fields() {
3517 let cfg = LspConfig::from_value(&serde_json::json!({}));
3518 assert!(cfg.php_version.is_none());
3519 assert!(cfg.exclude_paths.is_empty());
3520 }
3521
3522 #[test]
3523 fn lsp_config_parses_max_indexed_files() {
3524 let cfg = LspConfig::from_value(&serde_json::json!({"maxIndexedFiles": 5000}));
3525 assert_eq!(cfg.max_indexed_files, 5000);
3526 }
3527
3528 #[test]
3529 fn lsp_config_default_max_indexed_files() {
3530 let cfg = LspConfig::default();
3531 assert_eq!(cfg.max_indexed_files, MAX_INDEXED_FILES);
3532 }
3533
3534 #[test]
3536 fn features_config_default_all_enabled() {
3537 let cfg = FeaturesConfig::default();
3538 assert!(cfg.completion);
3539 assert!(cfg.hover);
3540 assert!(cfg.definition);
3541 assert!(cfg.declaration);
3542 assert!(cfg.references);
3543 assert!(cfg.document_symbols);
3544 assert!(cfg.workspace_symbols);
3545 assert!(cfg.rename);
3546 assert!(cfg.signature_help);
3547 assert!(cfg.inlay_hints);
3548 assert!(cfg.semantic_tokens);
3549 assert!(cfg.selection_range);
3550 assert!(cfg.call_hierarchy);
3551 assert!(cfg.document_highlight);
3552 assert!(cfg.implementation);
3553 assert!(cfg.code_action);
3554 assert!(cfg.type_definition);
3555 assert!(cfg.code_lens);
3556 assert!(cfg.formatting);
3557 assert!(cfg.range_formatting);
3558 assert!(cfg.on_type_formatting);
3559 assert!(cfg.document_link);
3560 assert!(cfg.linked_editing_range);
3561 assert!(cfg.inline_values);
3562 }
3563
3564 #[test]
3565 fn features_config_from_empty_object_all_enabled() {
3566 let cfg = FeaturesConfig::from_value(&serde_json::json!({}));
3567 assert!(cfg.completion);
3568 assert!(cfg.hover);
3569 assert!(cfg.call_hierarchy);
3570 assert!(cfg.inline_values);
3571 }
3572
3573 #[test]
3574 fn features_config_can_disable_individual_flags() {
3575 let cfg = FeaturesConfig::from_value(&serde_json::json!({
3576 "callHierarchy": false,
3577 }));
3578 assert!(!cfg.call_hierarchy);
3579 assert!(cfg.completion);
3580 assert!(cfg.hover);
3581 assert!(cfg.definition);
3582 assert!(cfg.inline_values);
3583 }
3584
3585 #[test]
3586 fn lsp_config_parses_features_section() {
3587 let cfg = LspConfig::from_value(&serde_json::json!({
3588 "features": {"callHierarchy": false}
3589 }));
3590 assert!(!cfg.features.call_hierarchy);
3591 assert!(cfg.features.completion);
3592 assert!(cfg.features.hover);
3593 }
3594
3595 #[test]
3597 fn find_use_insert_line_after_php_open_tag() {
3598 let src = "<?php\nfunction foo() {}";
3599 assert_eq!(find_use_insert_line(src), 1);
3600 }
3601
3602 #[test]
3603 fn find_use_insert_line_after_existing_use() {
3604 let src = "<?php\nuse Foo\\Bar;\nuse Baz\\Qux;\nclass Impl {}";
3605 assert_eq!(find_use_insert_line(src), 3);
3606 }
3607
3608 #[test]
3609 fn find_use_insert_line_after_namespace() {
3610 let src = "<?php\nnamespace App\\Services;\nclass Service {}";
3611 assert_eq!(find_use_insert_line(src), 2);
3612 }
3613
3614 #[test]
3615 fn find_use_insert_line_after_namespace_and_use() {
3616 let src = "<?php\nnamespace App;\nuse Foo\\Bar;\nclass Impl {}";
3617 assert_eq!(find_use_insert_line(src), 3);
3618 }
3619
3620 #[test]
3621 fn find_use_insert_line_empty_file() {
3622 assert_eq!(find_use_insert_line(""), 0);
3623 }
3624
3625 #[test]
3627 fn is_after_arrow_with_method_call() {
3628 let src = "<?php\n$obj->method();\n";
3629 let pos = Position {
3631 line: 1,
3632 character: 6,
3633 };
3634 assert!(is_after_arrow(src, pos));
3635 }
3636
3637 #[test]
3638 fn is_after_arrow_without_arrow() {
3639 let src = "<?php\n$obj->method();\n";
3640 let pos = Position {
3642 line: 1,
3643 character: 1,
3644 };
3645 assert!(!is_after_arrow(src, pos));
3646 }
3647
3648 #[test]
3649 fn is_after_arrow_on_standalone_identifier() {
3650 let src = "<?php\nfunction greet() {}\n";
3651 let pos = Position {
3652 line: 1,
3653 character: 10,
3654 };
3655 assert!(!is_after_arrow(src, pos));
3656 }
3657
3658 #[test]
3659 fn is_after_arrow_out_of_bounds_line() {
3660 let src = "<?php\n$x = 1;\n";
3661 let pos = Position {
3662 line: 99,
3663 character: 0,
3664 };
3665 assert!(!is_after_arrow(src, pos));
3666 }
3667
3668 #[test]
3669 fn is_after_arrow_at_start_of_property() {
3670 let src = "<?php\n$this->name;\n";
3671 let pos = Position {
3673 line: 1,
3674 character: 7,
3675 };
3676 assert!(is_after_arrow(src, pos));
3677 }
3678
3679 #[test]
3681 fn php_file_op_matches_php_files() {
3682 let op = php_file_op();
3683 assert_eq!(op.filters.len(), 1);
3684 let filter = &op.filters[0];
3685 assert_eq!(filter.scheme.as_deref(), Some("file"));
3686 assert_eq!(filter.pattern.glob, "**/*.php");
3687 }
3688
3689 #[test]
3691 fn defer_actions_strips_edit_and_adds_data() {
3692 let uri = Url::parse("file:///test.php").unwrap();
3693 let range = Range {
3694 start: Position {
3695 line: 0,
3696 character: 0,
3697 },
3698 end: Position {
3699 line: 0,
3700 character: 5,
3701 },
3702 };
3703 let actions = vec![CodeActionOrCommand::CodeAction(CodeAction {
3704 title: "My Action".to_string(),
3705 kind: Some(CodeActionKind::REFACTOR),
3706 edit: Some(WorkspaceEdit::default()),
3707 data: None,
3708 ..Default::default()
3709 })];
3710 let deferred = defer_actions(actions, "test_kind", &uri, range);
3711 assert_eq!(deferred.len(), 1);
3712 if let CodeActionOrCommand::CodeAction(ca) = &deferred[0] {
3713 assert!(ca.edit.is_none(), "edit should be stripped");
3714 assert!(ca.data.is_some(), "data payload should be set");
3715 let data = ca.data.as_ref().unwrap();
3716 assert_eq!(data["php_lsp_resolve"], "test_kind");
3717 assert_eq!(data["uri"], uri.to_string());
3718 } else {
3719 panic!("expected CodeAction");
3720 }
3721 }
3722
3723 #[test]
3725 fn build_use_import_edit_inserts_after_php_tag() {
3726 let src = "<?php\nclass Foo {}";
3727 let uri = Url::parse("file:///test.php").unwrap();
3728 let edit = build_use_import_edit(src, &uri, "App\\Services\\Bar");
3729 let changes = edit.changes.unwrap();
3730 let edits = changes.get(&uri).unwrap();
3731 assert_eq!(edits.len(), 1);
3732 assert_eq!(edits[0].new_text, "use App\\Services\\Bar;\n");
3733 assert_eq!(edits[0].range.start.line, 1);
3734 }
3735
3736 #[test]
3737 fn build_use_import_edit_inserts_after_existing_use() {
3738 let src = "<?php\nuse Foo\\Bar;\nclass Impl {}";
3739 let uri = Url::parse("file:///test.php").unwrap();
3740 let edit = build_use_import_edit(src, &uri, "Baz\\Qux");
3741 let changes = edit.changes.unwrap();
3742 let edits = changes.get(&uri).unwrap();
3743 assert_eq!(edits[0].range.start.line, 2);
3744 assert_eq!(edits[0].new_text, "use Baz\\Qux;\n");
3745 }
3746
3747 #[test]
3749 fn undefined_class_name_extracted_from_message() {
3750 let msg = "Class MyService does not exist";
3751 let name = msg
3752 .strip_prefix("Class ")
3753 .and_then(|s| s.strip_suffix(" does not exist"))
3754 .unwrap_or("")
3755 .trim();
3756 assert_eq!(name, "MyService");
3757 }
3758
3759 #[test]
3760 fn undefined_function_message_not_matched_by_extraction() {
3761 let msg = "Function myHelper() is not defined";
3764 let name = msg
3765 .strip_prefix("Class ")
3766 .and_then(|s| s.strip_suffix(" does not exist"))
3767 .unwrap_or("")
3768 .trim();
3769 assert!(
3770 name.is_empty(),
3771 "function diagnostic should not extract a class name"
3772 );
3773 }
3774
3775 #[test]
3778 fn position_to_byte_offset_first_line() {
3779 let src = "<?php\nfoo();";
3780 assert_eq!(
3782 position_to_byte_offset(
3783 src,
3784 Position {
3785 line: 0,
3786 character: 0
3787 }
3788 ),
3789 Some(0)
3790 );
3791 assert_eq!(
3793 position_to_byte_offset(
3794 src,
3795 Position {
3796 line: 0,
3797 character: 4
3798 }
3799 ),
3800 Some(4)
3801 );
3802 assert_eq!(
3804 position_to_byte_offset(
3805 src,
3806 Position {
3807 line: 0,
3808 character: 5
3809 }
3810 ),
3811 Some(5)
3812 );
3813 }
3814
3815 #[test]
3816 fn position_to_byte_offset_second_line() {
3817 let src = "<?php\nfoo();";
3818 assert_eq!(
3820 position_to_byte_offset(
3821 src,
3822 Position {
3823 line: 1,
3824 character: 0
3825 }
3826 ),
3827 Some(6)
3828 );
3829 assert_eq!(
3831 position_to_byte_offset(
3832 src,
3833 Position {
3834 line: 1,
3835 character: 3
3836 }
3837 ),
3838 Some(9)
3839 );
3840 }
3841
3842 #[test]
3843 fn position_to_byte_offset_line_boundary_returns_none() {
3844 let src = "<?php";
3846 assert_eq!(
3847 position_to_byte_offset(
3848 src,
3849 Position {
3850 line: 1,
3851 character: 0
3852 }
3853 ),
3854 None
3855 );
3856 assert_eq!(
3857 position_to_byte_offset(
3858 src,
3859 Position {
3860 line: 5,
3861 character: 0
3862 }
3863 ),
3864 None
3865 );
3866 }
3867
3868 #[test]
3871 fn cursor_on_method_decl_name_returns_true() {
3872 let doc = ParsedDoc::parse("<?php\nclass C {\n public function add() {}\n}".to_string());
3875 let source = doc.source();
3876 let stmts = &doc.program().stmts;
3877 for col in 20u32..=22 {
3879 assert!(
3880 cursor_is_on_method_decl(
3881 source,
3882 stmts,
3883 Position {
3884 line: 2,
3885 character: col
3886 }
3887 ),
3888 "expected true at col {col}"
3889 );
3890 }
3891 assert!(!cursor_is_on_method_decl(
3893 source,
3894 stmts,
3895 Position {
3896 line: 2,
3897 character: 19
3898 }
3899 ));
3900 assert!(!cursor_is_on_method_decl(
3901 source,
3902 stmts,
3903 Position {
3904 line: 2,
3905 character: 23
3906 }
3907 ));
3908 }
3909
3910 #[test]
3911 fn cursor_on_free_function_decl_returns_false() {
3912 let doc = ParsedDoc::parse("<?php\nfunction add() {}".to_string());
3914 let source = doc.source();
3915 let stmts = &doc.program().stmts;
3916 assert!(!cursor_is_on_method_decl(
3917 source,
3918 stmts,
3919 Position {
3920 line: 1,
3921 character: 9
3922 }
3923 ));
3924 }
3925
3926 #[test]
3927 fn cursor_on_method_call_site_returns_false() {
3928 let doc = ParsedDoc::parse(
3930 "<?php\nclass C { public function add() {} }\n$c = new C();\n$c->add();".to_string(),
3931 );
3932 let source = doc.source();
3933 let stmts = &doc.program().stmts;
3934 assert!(!cursor_is_on_method_decl(
3935 source,
3936 stmts,
3937 Position {
3938 line: 3,
3939 character: 4
3940 }
3941 ));
3942 }
3943
3944 #[test]
3945 fn cursor_on_interface_method_decl_returns_true() {
3946 let doc = ParsedDoc::parse(
3948 "<?php\ninterface I {\n public function add(): void;\n}".to_string(),
3949 );
3950 let source = doc.source();
3951 let stmts = &doc.program().stmts;
3952 assert!(cursor_is_on_method_decl(
3953 source,
3954 stmts,
3955 Position {
3956 line: 2,
3957 character: 20
3958 }
3959 ));
3960 }
3961
3962 #[test]
3963 fn cursor_on_trait_method_decl_returns_true() {
3964 let doc = ParsedDoc::parse("<?php\ntrait T {\n public function add() {}\n}".to_string());
3966 let source = doc.source();
3967 let stmts = &doc.program().stmts;
3968 assert!(cursor_is_on_method_decl(
3969 source,
3970 stmts,
3971 Position {
3972 line: 2,
3973 character: 20
3974 }
3975 ));
3976 }
3977
3978 #[test]
3979 fn cursor_on_enum_method_decl_returns_true() {
3980 let doc = ParsedDoc::parse(
3982 "<?php\nenum Status {\n public function label(): string { return 'x'; }\n}"
3983 .to_string(),
3984 );
3985 let source = doc.source();
3986 let stmts = &doc.program().stmts;
3987 assert!(cursor_is_on_method_decl(
3988 source,
3989 stmts,
3990 Position {
3991 line: 2,
3992 character: 20
3993 }
3994 ));
3995 }
3996
3997 #[test]
3998 fn cursor_on_method_decl_in_unbraced_namespace_returns_true() {
3999 let doc = ParsedDoc::parse(
4008 "<?php\nnamespace App;\nclass C {\n public function add() {}\n}".to_string(),
4009 );
4010 let source = doc.source();
4011 let stmts = &doc.program().stmts;
4012 assert!(
4013 cursor_is_on_method_decl(
4014 source,
4015 stmts,
4016 Position {
4017 line: 3,
4018 character: 20
4019 }
4020 ),
4021 "method in unbraced namespace must be detected"
4022 );
4023 }
4024
4025 #[test]
4026 fn cursor_on_method_decl_in_braced_namespace_returns_true() {
4027 let doc = ParsedDoc::parse(
4036 "<?php\nnamespace App {\n class C {\n public function add() {}\n }\n}"
4037 .to_string(),
4038 );
4039 let source = doc.source();
4040 let stmts = &doc.program().stmts;
4041 assert!(
4042 cursor_is_on_method_decl(
4043 source,
4044 stmts,
4045 Position {
4046 line: 3,
4047 character: 24
4048 }
4049 ),
4050 "method in braced namespace must be detected"
4051 );
4052 }
4053
4054 #[test]
4057 fn merge_file_only_uses_file_values() {
4058 let file = serde_json::json!({
4059 "phpVersion": "8.1",
4060 "excludePaths": ["vendor/*"],
4061 "maxIndexedFiles": 500,
4062 });
4063 let merged = LspConfig::merge_project_configs(Some(&file), None);
4064 let cfg = LspConfig::from_value(&merged);
4065 assert_eq!(cfg.php_version, Some("8.1".to_string()));
4066 assert_eq!(cfg.exclude_paths, vec!["vendor/*"]);
4067 assert_eq!(cfg.max_indexed_files, 500);
4068 }
4069
4070 #[test]
4071 fn merge_editor_wins_per_key_over_file() {
4072 let file = serde_json::json!({"phpVersion": "8.1", "maxIndexedFiles": 100});
4073 let editor = serde_json::json!({"phpVersion": "8.3", "maxIndexedFiles": 200});
4074 let merged = LspConfig::merge_project_configs(Some(&file), Some(&editor));
4075 let cfg = LspConfig::from_value(&merged);
4076 assert_eq!(cfg.php_version, Some("8.3".to_string()));
4077 assert_eq!(cfg.max_indexed_files, 200);
4078 }
4079
4080 #[test]
4081 fn merge_exclude_paths_concat_not_replace() {
4082 let file = serde_json::json!({"excludePaths": ["cache/*"]});
4083 let editor = serde_json::json!({"excludePaths": ["logs/*"]});
4084 let merged = LspConfig::merge_project_configs(Some(&file), Some(&editor));
4085 let cfg = LspConfig::from_value(&merged);
4086 assert_eq!(cfg.exclude_paths, vec!["cache/*", "logs/*"]);
4088 }
4089
4090 #[test]
4091 fn merge_no_file_uses_editor_only() {
4092 let editor = serde_json::json!({"phpVersion": "8.2", "excludePaths": ["tmp/*"]});
4093 let merged = LspConfig::merge_project_configs(None, Some(&editor));
4094 let cfg = LspConfig::from_value(&merged);
4095 assert_eq!(cfg.php_version, Some("8.2".to_string()));
4096 assert_eq!(cfg.exclude_paths, vec!["tmp/*"]);
4097 }
4098
4099 #[test]
4100 fn merge_both_none_returns_defaults() {
4101 let merged = LspConfig::merge_project_configs(None, None);
4102 let cfg = LspConfig::from_value(&merged);
4103 assert!(cfg.php_version.is_none());
4104 assert!(cfg.exclude_paths.is_empty());
4105 assert_eq!(cfg.max_indexed_files, MAX_INDEXED_FILES);
4106 }
4107
4108 #[test]
4109 fn merge_file_editor_both_have_exclude_paths_all_present() {
4110 let file = serde_json::json!({"excludePaths": ["a/*", "b/*"]});
4111 let editor = serde_json::json!({"excludePaths": ["c/*"]});
4112 let merged = LspConfig::merge_project_configs(Some(&file), Some(&editor));
4113 let cfg = LspConfig::from_value(&merged);
4114 assert_eq!(cfg.exclude_paths, vec!["a/*", "b/*", "c/*"]);
4115 }
4116}