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