1use std::sync::Arc;
2
3use tower_lsp::Client;
4use tower_lsp::lsp_types::*;
5
6use php_ast::{
7 ClassMember, ClassMemberKind, EnumMember, EnumMemberKind, ExprKind, NamespaceBody, Stmt,
8 StmtKind,
9};
10
11use crate::ast::{ParsedDoc, str_offset};
12use crate::navigation::definition::find_declaration_range;
13use crate::navigation::references::SymbolKind;
14
15use crate::actions::generate_action::{
16 generate_constructor_actions, generate_getters_setters_actions,
17};
18use crate::actions::implement_action::implement_missing_actions;
19use crate::actions::phpdoc_action::phpdoc_actions;
20use crate::actions::promote_action::promote_constructor_actions;
21use crate::actions::type_action::add_return_type_actions;
22
23use super::Backend;
24
25pub(super) fn php_file_op() -> FileOperationRegistrationOptions {
26 FileOperationRegistrationOptions {
27 filters: vec![FileOperationFilter {
28 scheme: Some("file".to_string()),
29 pattern: FileOperationPattern {
30 glob: "**/*.php".to_string(),
31 matches: Some(FileOperationPatternKind::File),
32 options: None,
33 },
34 }],
35 }
36}
37
38pub(super) fn defer_actions(
41 actions: Vec<CodeActionOrCommand>,
42 kind_tag: &str,
43 uri: &Url,
44 range: Range,
45) -> Vec<CodeActionOrCommand> {
46 actions
47 .into_iter()
48 .map(|a| match a {
49 CodeActionOrCommand::CodeAction(mut ca) => {
50 ca.edit = None;
51 ca.data = Some(serde_json::json!({
52 "php_lsp_resolve": kind_tag,
53 "uri": uri.to_string(),
54 "range": range,
55 }));
56 CodeActionOrCommand::CodeAction(ca)
57 }
58 other => other,
59 })
60 .collect()
61}
62
63pub(super) fn is_after_arrow(source: &str, position: Position) -> bool {
66 let line = match source.lines().nth(position.line as usize) {
67 Some(l) => l,
68 None => return false,
69 };
70 let chars: Vec<char> = line.chars().collect();
71 let col = position.character as usize;
72 let mut utf16_col = 0usize;
74 let mut char_idx = 0usize;
75 for ch in &chars {
76 if utf16_col >= col {
77 break;
78 }
79 utf16_col += ch.len_utf16();
80 char_idx += 1;
81 }
82 let is_word = |c: char| c.is_alphanumeric() || c == '_';
84 while char_idx > 0 && is_word(chars[char_idx - 1]) {
85 char_idx -= 1;
86 }
87 char_idx >= 2 && chars[char_idx - 1] == '>' && chars[char_idx - 2] == '-'
88}
89
90pub(super) fn symbol_kind_at(source: &str, position: Position, word: &str) -> Option<SymbolKind> {
101 if word.starts_with('$') {
102 return None; }
104 let line = source.lines().nth(position.line as usize)?;
105 let chars: Vec<char> = line.chars().collect();
106
107 let col = position.character as usize;
109 let mut utf16_col = 0usize;
110 let mut char_idx = 0usize;
111 for ch in &chars {
112 if utf16_col >= col {
113 break;
114 }
115 utf16_col += ch.len_utf16();
116 char_idx += 1;
117 }
118
119 let is_word_char = |c: char| c.is_alphanumeric() || c == '_';
121 while char_idx > 0 && is_word_char(chars[char_idx - 1]) {
122 char_idx -= 1;
123 }
124
125 let word_end = {
127 let mut i = char_idx;
128 while i < chars.len() && is_word_char(chars[i]) {
129 i += 1;
130 }
131 while i < chars.len() && chars[i] == ' ' {
133 i += 1;
134 }
135 i
136 };
137 let next_is_call = word_end < chars.len() && chars[word_end] == '(';
138
139 if char_idx >= 2 && chars[char_idx - 1] == '>' && chars[char_idx - 2] == '-' {
141 return if next_is_call {
142 Some(SymbolKind::Method)
143 } else {
144 Some(SymbolKind::Property)
145 };
146 }
147 if char_idx >= 3
148 && chars[char_idx - 1] == '>'
149 && chars[char_idx - 2] == '-'
150 && chars[char_idx - 3] == '?'
151 {
152 return if next_is_call {
153 Some(SymbolKind::Method)
154 } else {
155 Some(SymbolKind::Property)
156 };
157 }
158
159 if char_idx >= 2 && chars[char_idx - 1] == ':' && chars[char_idx - 2] == ':' {
161 return Some(SymbolKind::Method);
162 }
163
164 if word
166 .chars()
167 .next()
168 .map(|c| c.is_uppercase())
169 .unwrap_or(false)
170 {
171 return Some(SymbolKind::Class);
172 }
173
174 Some(SymbolKind::Function)
176}
177
178pub(super) fn range_within(inner: Range, outer: Range) -> bool {
184 let start_ok =
185 (inner.start.line, inner.start.character) >= (outer.start.line, outer.start.character);
186 let end_ok = (inner.end.line, inner.end.character) <= (outer.end.line, outer.end.character);
187 start_ok && end_ok
188}
189
190pub(super) fn position_to_byte_offset(source: &str, position: Position) -> Option<u32> {
191 let mut byte_offset = 0usize;
192 for (idx, line) in source.split('\n').enumerate() {
193 if idx as u32 == position.line {
194 let line_content = line.trim_end_matches('\r');
196 let mut col = 0u32;
197 for (byte_idx, ch) in line_content.char_indices() {
198 if col >= position.character {
199 return Some((byte_offset + byte_idx) as u32);
200 }
201 col += ch.len_utf16() as u32;
202 }
203 return Some((byte_offset + line_content.len()) as u32);
204 }
205 byte_offset += line.len() + 1; }
207 None
208}
209
210pub(super) fn cursor_is_on_method_decl(
217 source: &str,
218 stmts: &[Stmt<'_, '_>],
219 position: Position,
220) -> bool {
221 let Some(cursor) = position_to_byte_offset(source, position) else {
222 return false;
223 };
224
225 fn name_offset_in_member(source: &str, member_span: php_ast::Span, name: &str) -> Option<u32> {
231 let s = member_span.start as usize;
232 let e = (member_span.end as usize).min(source.len());
233 source
234 .get(s..e)?
235 .find(name)
236 .map(|off| member_span.start + off as u32)
237 }
238 fn check(source: &str, stmts: &[Stmt<'_, '_>], cursor: u32) -> bool {
239 for stmt in stmts {
240 match &stmt.kind {
241 StmtKind::Class(c) => {
242 for member in c.body.members.iter() {
243 if let ClassMemberKind::Method(m) = &member.kind {
244 let name = m.name.to_string();
245 let start =
246 name_offset_in_member(source, member.span, &name).unwrap_or(0);
247 let end = start + name.len() as u32;
248 if cursor >= start && cursor < end {
249 return true;
250 }
251 }
252 }
253 }
254 StmtKind::Interface(i) => {
255 for member in i.body.members.iter() {
256 if let ClassMemberKind::Method(m) = &member.kind {
257 let name = m.name.to_string();
258 let start =
259 name_offset_in_member(source, member.span, &name).unwrap_or(0);
260 let end = start + name.len() as u32;
261 if cursor >= start && cursor < end {
262 return true;
263 }
264 }
265 }
266 }
267 StmtKind::Trait(t) => {
268 for member in t.body.members.iter() {
269 if let ClassMemberKind::Method(m) = &member.kind {
270 let name = m.name.to_string();
271 let start =
272 name_offset_in_member(source, member.span, &name).unwrap_or(0);
273 let end = start + name.len() as u32;
274 if cursor >= start && cursor < end {
275 return true;
276 }
277 }
278 }
279 }
280 StmtKind::Enum(e) => {
281 for member in e.body.members.iter() {
282 if let EnumMemberKind::Method(m) = &member.kind {
283 let name = m.name.to_string();
284 let start =
285 name_offset_in_member(source, member.span, &name).unwrap_or(0);
286 let end = start + name.len() as u32;
287 if cursor >= start && cursor < end {
288 return true;
289 }
290 }
291 }
292 }
293 StmtKind::Namespace(ns) => {
294 if let NamespaceBody::Braced(inner) = &ns.body
295 && check(source, &inner.stmts, cursor)
296 {
297 return true;
298 }
299 }
300 _ => {}
301 }
302 }
303 false
304 }
305
306 check(source, stmts, cursor)
307}
308
309pub(super) fn cursor_is_on_property_decl(
314 source: &str,
315 stmts: &[Stmt<'_, '_>],
316 position: Position,
317) -> Option<String> {
318 let cursor = position_to_byte_offset(source, position)?;
319
320 fn name_offset_in_member(source: &str, member_span: php_ast::Span, name: &str) -> Option<u32> {
321 let s = member_span.start as usize;
322 let e = (member_span.end as usize).min(source.len());
323 source
324 .get(s..e)?
325 .find(name)
326 .map(|off| member_span.start + off as u32)
327 }
328 fn check(source: &str, stmts: &[Stmt<'_, '_>], cursor: u32) -> Option<String> {
329 for stmt in stmts {
330 match &stmt.kind {
331 StmtKind::Class(c) => {
332 for member in c.body.members.iter() {
333 if let ClassMemberKind::Property(p) = &member.kind {
334 let name = p.name.to_string();
335 let start =
336 name_offset_in_member(source, member.span, &name).unwrap_or(0);
337 let end = start + name.len() as u32;
338 if cursor >= start && cursor < end {
339 return Some(name);
340 }
341 }
342 }
343 }
344 StmtKind::Trait(t) => {
345 for member in t.body.members.iter() {
346 if let ClassMemberKind::Property(p) = &member.kind {
347 let name = p.name.to_string();
348 let start =
349 name_offset_in_member(source, member.span, &name).unwrap_or(0);
350 let end = start + name.len() as u32;
351 if cursor >= start && cursor < end {
352 return Some(name);
353 }
354 }
355 }
356 }
357 StmtKind::Namespace(ns) => {
358 if let NamespaceBody::Braced(inner) = &ns.body
359 && let Some(name) = check(source, &inner.stmts, cursor)
360 {
361 return Some(name);
362 }
363 }
364 _ => {}
365 }
366 }
367 None
368 }
369
370 check(source, stmts, cursor)
371}
372
373pub(super) fn cursor_is_on_constant_decl(
379 source: &str,
380 stmts: &[Stmt<'_, '_>],
381 position: Position,
382) -> Option<(String, Option<String>)> {
383 let cursor = position_to_byte_offset(source, position)?;
384
385 fn name_offset_in_member(source: &str, member_span: php_ast::Span, name: &str) -> Option<u32> {
386 let s = member_span.start as usize;
387 let e = (member_span.end as usize).min(source.len());
388 source
389 .get(s..e)?
390 .find(name)
391 .map(|off| member_span.start + off as u32)
392 }
393
394 fn check_members(source: &str, members: &[ClassMember<'_, '_>], cursor: u32) -> Option<String> {
395 for member in members {
396 if let ClassMemberKind::ClassConst(c) = &member.kind {
397 let name = c.name.to_string();
398 let start = name_offset_in_member(source, member.span, &name).unwrap_or(0);
399 let end = start + name.len() as u32;
400 if cursor >= start && cursor < end {
401 return Some(name);
402 }
403 }
404 }
405 None
406 }
407
408 fn check_enum_members(
409 source: &str,
410 members: &[EnumMember<'_, '_>],
411 cursor: u32,
412 ) -> Option<String> {
413 for member in members {
414 if let EnumMemberKind::ClassConst(c) = &member.kind {
415 let name = c.name.to_string();
416 let start = name_offset_in_member(source, member.span, &name).unwrap_or(0);
417 let end = start + name.len() as u32;
418 if cursor >= start && cursor < end {
419 return Some(name);
420 }
421 }
422 }
423 None
424 }
425
426 fn check(
427 source: &str,
428 stmts: &[Stmt<'_, '_>],
429 cursor: u32,
430 ) -> Option<(String, Option<String>)> {
431 for stmt in stmts {
432 match &stmt.kind {
433 StmtKind::Class(c) => {
434 if let Some(const_name) = check_members(source, &c.body.members, cursor) {
435 let owner = c.name.map(|n| n.to_string());
436 return Some((const_name, owner));
437 }
438 }
439 StmtKind::Interface(i) => {
440 if let Some(const_name) = check_members(source, &i.body.members, cursor) {
441 return Some((const_name, Some(i.name.to_string())));
442 }
443 }
444 StmtKind::Trait(t) => {
445 if let Some(const_name) = check_members(source, &t.body.members, cursor) {
446 return Some((const_name, Some(t.name.to_string())));
447 }
448 }
449 StmtKind::Enum(e) => {
450 if let Some(const_name) = check_enum_members(source, &e.body.members, cursor) {
451 return Some((const_name, Some(e.name.to_string())));
452 }
453 }
454 StmtKind::Const(items) => {
455 for item in items.iter() {
456 let name = item.name.to_string();
457 let s = item.span.start as usize;
458 let e = (item.span.end as usize).min(source.len());
459 if let Some(off) = source.get(s..e).and_then(|sl| sl.find(&name)) {
460 let start = item.span.start + off as u32;
461 let end = start + name.len() as u32;
462 if cursor >= start && cursor < end {
463 return Some((name, None));
464 }
465 }
466 }
467 }
468 StmtKind::Expression(expr) => {
469 if let ExprKind::FunctionCall(f) = &expr.kind
471 && let ExprKind::Identifier(id) = &f.name.kind
472 && id.as_str() == "define"
473 && let Some(first_arg) = f.args.first()
474 && let ExprKind::String(s) = &first_arg.value.kind
475 {
476 let start = first_arg.value.span.start + 1;
478 let end = start + s.len() as u32;
479 if cursor >= start && cursor < end {
480 return Some((s.to_string(), None));
481 }
482 }
483 }
484 StmtKind::Namespace(ns) => {
485 if let NamespaceBody::Braced(inner) = &ns.body
486 && let Some(result) = check(source, &inner.stmts, cursor)
487 {
488 return Some(result);
489 }
490 }
491 _ => {}
492 }
493 }
494 None
495 }
496
497 check(source, stmts, cursor)
498}
499
500pub(super) fn class_name_at_construct_decl(
507 source: &str,
508 stmts: &[Stmt<'_, '_>],
509 position: Position,
510) -> Option<String> {
511 let cursor = position_to_byte_offset(source, position)?;
512
513 fn name_offset_in_member(source: &str, member_span: php_ast::Span, name: &str) -> Option<u32> {
514 let s = member_span.start as usize;
515 let e = (member_span.end as usize).min(source.len());
516 source
517 .get(s..e)?
518 .find(name)
519 .map(|off| member_span.start + off as u32)
520 }
521 fn check(source: &str, stmts: &[Stmt<'_, '_>], cursor: u32, ns_prefix: &str) -> Option<String> {
522 let mut current_ns = ns_prefix.to_owned();
523 for stmt in stmts {
524 match &stmt.kind {
525 StmtKind::Class(c) => {
526 for member in c.body.members.iter() {
527 if let ClassMemberKind::Method(m) = &member.kind
528 && m.name == "__construct"
529 {
530 let name = m.name.to_string();
537 let start =
538 name_offset_in_member(source, member.span, &name).unwrap_or(0);
539 let end = start + name.len() as u32;
540 if cursor >= start && cursor < end {
541 let short = c.name?;
542 return Some(if current_ns.is_empty() {
543 short.to_string()
544 } else {
545 format!("{}\\{}", current_ns, short)
546 });
547 }
548 }
549 }
550 }
551 StmtKind::Namespace(ns) => {
552 let ns_name = ns
553 .name
554 .as_ref()
555 .map(|n| n.to_string_repr().to_string())
556 .unwrap_or_default();
557 match &ns.body {
558 NamespaceBody::Braced(inner) => {
559 if let Some(name) = check(source, &inner.stmts, cursor, &ns_name) {
560 return Some(name);
561 }
562 }
563 NamespaceBody::Simple => {
564 current_ns = ns_name;
565 }
566 }
567 }
568 _ => {}
569 }
570 }
571 None
572 }
573
574 check(source, stmts, cursor, "")
575}
576
577pub(super) fn promoted_property_at_cursor(
585 source: &str,
586 stmts: &[Stmt<'_, '_>],
587 position: Position,
588) -> Option<String> {
589 let cursor = position_to_byte_offset(source, position)?;
590
591 fn check(source: &str, stmts: &[Stmt<'_, '_>], cursor: u32) -> Option<String> {
592 for stmt in stmts {
593 match &stmt.kind {
594 StmtKind::Class(c) => {
595 for member in c.body.members.iter() {
596 if let ClassMemberKind::Method(m) = &member.kind
597 && m.name == "__construct"
598 {
599 for param in m.params.iter() {
600 if param.visibility.is_none() {
601 continue;
602 }
603 let name_start =
604 str_offset(source, ¶m.name.to_string()).unwrap_or(0);
605 let name_end = name_start + param.name.to_string().len() as u32;
606 if cursor >= name_start && cursor < name_end {
607 return Some(
608 param.name.to_string().trim_start_matches('$').to_string(),
609 );
610 }
611 }
612 }
613 }
614 }
615 StmtKind::Namespace(ns) => {
616 if let NamespaceBody::Braced(inner) = &ns.body
617 && let Some(name) = check(source, &inner.stmts, cursor)
618 {
619 return Some(name);
620 }
621 }
622 _ => {}
623 }
624 }
625 None
626 }
627
628 check(source, stmts, cursor)
629}
630
631pub(super) const DEFERRED_ACTION_TAGS: &[&str] = &[
634 "phpdoc",
635 "implement",
636 "constructor",
637 "getters_setters",
638 "return_type",
639 "promote",
640];
641
642impl Backend {
643 pub(super) async fn cached_analysis_async(
650 &self,
651 uri: &Url,
652 ) -> Option<Arc<mir_analyzer::FileAnalysis>> {
653 if let Some(hit) = self.docs.cached_analysis_if_fresh(uri) {
654 return Some(hit);
655 }
656 let docs = Arc::clone(&self.docs);
657 let uri = uri.clone();
658 tokio::task::spawn_blocking(move || docs.cached_analysis(&uri))
659 .await
660 .unwrap_or(None)
661 }
662
663 pub(super) async fn workspace_index_async(
668 &self,
669 ) -> Arc<crate::db::workspace_index::WorkspaceIndexData> {
670 let docs = Arc::clone(&self.docs);
671 match tokio::task::spawn_blocking(move || docs.get_workspace_index_salsa()).await {
672 Ok(wi) => wi,
673 Err(_) => self.docs.get_workspace_index_salsa(),
676 }
677 }
678
679 pub(super) fn generate_deferred_actions(
681 &self,
682 tag: &str,
683 source: &str,
684 doc: &Arc<ParsedDoc>,
685 range: Range,
686 uri: &Url,
687 ) -> Vec<CodeActionOrCommand> {
688 match tag {
689 "phpdoc" => phpdoc_actions(uri, doc, source, range),
690 "implement" => {
691 let imports = self.file_imports(uri);
692 implement_missing_actions(
693 source,
694 doc,
695 &self
696 .docs
697 .doc_with_others(uri, Arc::clone(doc), &self.open_urls()),
698 range,
699 uri,
700 &imports,
701 )
702 }
703 "constructor" => generate_constructor_actions(source, doc, range, uri),
704 "getters_setters" => generate_getters_setters_actions(source, doc, range, uri),
705 "return_type" => add_return_type_actions(source, doc, range, uri),
706 "promote" => promote_constructor_actions(source, doc, range, uri),
707 _ => Vec::new(),
708 }
709 }
710
711 pub(super) async fn psr4_goto(&self, fqn: &str) -> Option<Location> {
714 let path = self.psr4.load().resolve(fqn)?;
715
716 let file_uri = Url::from_file_path(&path).ok()?;
717
718 if self.docs.get_doc_salsa(&file_uri).is_none() {
723 let text = tokio::fs::read_to_string(&path).await.ok()?;
724 self.index_if_not_open(file_uri.clone(), &text);
725 }
726
727 let doc = self.docs.get_doc_salsa(&file_uri)?;
728
729 let short_name = fqn.split('\\').next_back()?;
732 let range = find_declaration_range(doc.source(), &doc, short_name)?;
733
734 Some(Location {
735 uri: file_uri,
736 range,
737 })
738 }
739
740 pub async fn apply_workspace_edit(&self, edit: WorkspaceEdit) -> bool {
743 self.client
744 .apply_edit(edit)
745 .await
746 .ok()
747 .map(|result| result.applied)
748 .unwrap_or(false)
749 }
750}
751
752pub(super) async fn run_phpunit(
758 client: &Client,
759 filter: &str,
760 root: Option<&std::path::Path>,
761 file_uri: Option<&Url>,
762) {
763 let output = tokio::process::Command::new("vendor/bin/phpunit")
764 .arg("--filter")
765 .arg(filter)
766 .current_dir(root.unwrap_or(std::path::Path::new(".")))
767 .output()
768 .await;
769
770 let (success, message) = match output {
771 Ok(out) => {
772 let text = String::from_utf8_lossy(&out.stdout).into_owned()
773 + &String::from_utf8_lossy(&out.stderr);
774 let last_line = text
775 .lines()
776 .rev()
777 .find(|l| !l.trim().is_empty())
778 .unwrap_or("(no output)")
779 .to_string();
780 let ok = out.status.success();
781 let msg = if ok {
782 format!("✓ {filter}: {last_line}")
783 } else {
784 format!("✗ {filter}: {last_line}")
785 };
786 (ok, msg)
787 }
788 Err(e) => (
789 false,
790 format!("php-lsp.runTest: failed to spawn phpunit — {e}"),
791 ),
792 };
793
794 let msg_type = if success {
795 MessageType::INFO
796 } else {
797 MessageType::ERROR
798 };
799 let mut actions = vec![MessageActionItem {
800 title: "Run Again".to_string(),
801 properties: Default::default(),
802 }];
803 if !success && file_uri.is_some() {
804 actions.push(MessageActionItem {
805 title: "Open File".to_string(),
806 properties: Default::default(),
807 });
808 }
809
810 let chosen = client
811 .show_message_request(msg_type, message, Some(actions))
812 .await;
813
814 match chosen {
815 Ok(Some(ref action)) if action.title == "Run Again" => {
816 let output2 = tokio::process::Command::new("vendor/bin/phpunit")
818 .arg("--filter")
819 .arg(filter)
820 .current_dir(root.unwrap_or(std::path::Path::new(".")))
821 .output()
822 .await;
823 let msg2 = match output2 {
824 Ok(out) => {
825 let text = String::from_utf8_lossy(&out.stdout).into_owned()
826 + &String::from_utf8_lossy(&out.stderr);
827 let last_line = text
828 .lines()
829 .rev()
830 .find(|l| !l.trim().is_empty())
831 .unwrap_or("(no output)")
832 .to_string();
833 if out.status.success() {
834 format!("✓ {filter}: {last_line}")
835 } else {
836 format!("✗ {filter}: {last_line}")
837 }
838 }
839 Err(e) => format!("php-lsp.runTest: failed to spawn phpunit — {e}"),
840 };
841 client.show_message(MessageType::INFO, msg2).await;
842 }
843 Ok(Some(ref action)) if action.title == "Open File" => {
844 if let Some(uri) = file_uri {
845 client
846 .show_document(ShowDocumentParams {
847 uri: uri.clone(),
848 external: Some(false),
849 take_focus: Some(true),
850 selection: None,
851 })
852 .await
853 .ok();
854 }
855 }
856 _ => {}
857 }
858}