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