1mod attribute;
2use attribute::attribute_completions;
3
4mod include_path;
5use include_path::{include_path_completions, include_path_prefix};
6
7mod keyword;
8pub use keyword::{
9 keyword_completions, keyword_completions_matching, magic_constant_completions,
10 magic_constant_completions_matching,
11};
12
13mod match_arm;
14use match_arm::match_arm_completions;
15
16mod member;
17use member::{
18 all_instance_members, all_static_members, magic_method_completions, mir_method_params,
19 resolve_receiver_class, resolve_static_receiver,
20};
21
22mod namespace;
23use namespace::{
24 collect_classes_with_ns, collect_fqns_with_prefix, current_file_namespace, typed_prefix,
25 use_completion_prefix, use_insert_position,
26};
27
28mod symbols;
29pub use symbols::{
30 builtin_completions, builtin_completions_matching, superglobal_completions,
31 superglobal_completions_matching, symbol_completions, symbol_completions_before,
32};
33
34use std::sync::Arc;
35
36use tower_lsp_server::ls_types::{
37 CompletionItem, CompletionItemKind, InsertTextFormat, Position, Range, TextEdit, Uri,
38};
39
40use tower_lsp_server::ls_types::{Documentation, MarkupContent, MarkupKind};
41
42use crate::document::ast::{ParsedDoc, format_type_hint};
43use crate::hover::format_params_str;
44use crate::lang::docblock::parse_docblock;
45use crate::text::{camel_sort_key, utf16_offset_to_byte};
46use crate::types::type_map::{enclosing_class_at, params_of_function, params_of_method};
47use std::collections::HashMap;
48
49fn callable_item(label: &str, kind: CompletionItemKind, has_params: bool) -> CompletionItem {
55 if has_params {
56 CompletionItem {
57 label: label.to_string(),
58 kind: Some(kind),
59 insert_text: Some(format!("{}($1)", label)),
60 insert_text_format: Some(InsertTextFormat::SNIPPET),
61 ..Default::default()
62 }
63 } else {
64 CompletionItem {
65 label: label.to_string(),
66 kind: Some(kind),
67 insert_text: Some(format!("{}()", label)),
68 ..Default::default()
69 }
70 }
71}
72
73fn named_arg_item(
78 label: &str,
79 kind: CompletionItemKind,
80 params: &[php_ast::Param<'_, '_>],
81) -> Option<CompletionItem> {
82 if params.is_empty() {
83 return None;
84 }
85 let named_label = format!(
86 "{}({})",
87 label,
88 params
89 .iter()
90 .map(|p| format!("{}:", p.name))
91 .collect::<Vec<_>>()
92 .join(", ")
93 );
94 let snippet = format!(
95 "{}({})",
96 label,
97 params
98 .iter()
99 .enumerate()
100 .map(|(i, p)| format!("{}: ${}", p.name, i + 1))
101 .collect::<Vec<_>>()
102 .join(", ")
103 );
104 Some(CompletionItem {
105 label: named_label,
106 kind: Some(kind),
107 insert_text: Some(snippet),
108 insert_text_format: Some(InsertTextFormat::SNIPPET),
109 detail: Some("named args".to_string()),
110 ..Default::default()
111 })
112}
113
114fn build_function_sig(
117 name: &str,
118 params: &[php_ast::Param<'_, '_>],
119 return_type: Option<&php_ast::TypeHint<'_, '_>>,
120) -> String {
121 let params_str = format_params_str(params);
122 let ret = return_type
123 .map(|r| format!(": {}", format_type_hint(r)))
124 .unwrap_or_default();
125 format!("function {}({}){}", name, params_str, ret)
126}
127
128pub(super) fn documentation_from_comment(
134 comment: Option<&php_ast::Comment<'_>>,
135) -> Option<Documentation> {
136 let md = parse_docblock(comment?.text).to_markdown();
137 if md.is_empty() {
138 None
139 } else {
140 Some(Documentation::MarkupContent(MarkupContent {
141 kind: MarkupKind::Markdown,
142 value: md,
143 }))
144 }
145}
146
147fn resolve_attribute_class(source: &str, position: Position) -> Option<String> {
150 let line = source.lines().nth(position.line as usize)?;
151 let col = utf16_offset_to_byte(line, position.character as usize);
152 let before = line[..col].trim_end_matches('(').trim_end();
153 let hash_pos = before.rfind("#[")?;
155 let after_bracket = before[hash_pos + 2..].trim_start();
156 let name: String = after_bracket
158 .trim_start_matches('\\')
159 .rsplit('\\')
160 .next()
161 .unwrap_or("")
162 .chars()
163 .take_while(|c| c.is_alphanumeric() || *c == '_')
164 .collect();
165 if name.is_empty() { None } else { Some(name) }
166}
167
168fn resolve_call_params(
169 source: &str,
170 doc: &ParsedDoc,
171 other_docs: &[Arc<ParsedDoc>],
172 position: Position,
173 ctx: &CompletionCtx<'_>,
174) -> Vec<String> {
175 let line = match source.lines().nth(position.line as usize) {
176 Some(l) => l,
177 None => return vec![],
178 };
179 let col = utf16_offset_to_byte(line, position.character as usize);
180 let before = &line[..col];
181 let before = before.strip_suffix('(').unwrap_or(before);
182 let func_name: String = before
183 .chars()
184 .rev()
185 .take_while(|&c| c.is_alphanumeric() || c == '_')
186 .collect::<String>()
187 .chars()
188 .rev()
189 .collect();
190 if func_name.is_empty() {
191 return vec![];
192 }
193
194 let before_receiver = &before[..before.len() - func_name.len()];
200 let receiver_col = crate::text::byte_to_utf16(line, before_receiver.len());
201 let receiver_pos = Position {
202 line: position.line,
203 character: receiver_col,
204 };
205 if before_receiver.ends_with("->") || before_receiver.ends_with("?->") {
206 return resolve_receiver_class(source, doc, receiver_pos, ctx.analysis)
207 .unwrap_or_default()
208 .iter()
209 .map(|class_name| {
210 params_of_method_anywhere(class_name, &func_name, doc, other_docs, ctx)
211 })
212 .find(|params| !params.is_empty())
213 .unwrap_or_default();
214 }
215 if before_receiver.ends_with("::") {
216 let empty_imports = HashMap::new();
217 let imports = ctx.file_imports.unwrap_or(&empty_imports);
218 return resolve_static_receiver(source, doc, other_docs, receiver_pos, imports)
219 .map(|class_name| {
220 params_of_method_anywhere(&class_name, &func_name, doc, other_docs, ctx)
221 })
222 .unwrap_or_default();
223 }
224
225 let mut params = params_of_function(doc, &func_name);
226 if params.is_empty() {
227 for other in other_docs {
228 params = params_of_function(other, &func_name);
229 if !params.is_empty() {
230 break;
231 }
232 }
233 }
234 params
235}
236
237fn params_of_method_anywhere(
242 class_name: &str,
243 method_name: &str,
244 doc: &ParsedDoc,
245 other_docs: &[Arc<ParsedDoc>],
246 ctx: &CompletionCtx<'_>,
247) -> Vec<String> {
248 let params = params_of_method(doc, class_name, method_name);
249 if !params.is_empty() {
250 return params;
251 }
252 if let Some(find) = ctx.find_class_doc
253 && let Some(class_doc) = find(class_name)
254 {
255 let params = params_of_method(&class_doc, class_name, method_name);
256 if !params.is_empty() {
257 return params;
258 }
259 }
260 for other in other_docs {
261 let params = params_of_method(other, class_name, method_name);
262 if !params.is_empty() {
263 return params;
264 }
265 }
266 ctx.session
267 .and_then(|docs| mir_method_params(docs, doc, class_name, method_name))
268 .unwrap_or_default()
269}
270
271pub type ClassDocLookup<'a> = &'a dyn Fn(&str) -> Option<Arc<ParsedDoc>>;
275
276pub type WorkspaceClassSearch<'a> = &'a dyn Fn(&str) -> Vec<(String, CompletionItemKind, String)>;
282
283#[derive(Default)]
286pub struct CompletionCtx<'a> {
287 pub source: Option<&'a str>,
288 pub position: Option<Position>,
289 pub doc_uri: Option<&'a Uri>,
290 pub file_imports: Option<&'a HashMap<String, String>>,
291 pub find_class_doc: Option<ClassDocLookup<'a>>,
297 pub workspace_class_search: Option<WorkspaceClassSearch<'a>>,
302 pub analysis: Option<&'a mir_analyzer::FileAnalysis>,
306 pub session: Option<&'a crate::document::document_store::DocumentStore>,
314 pub laravel: Option<&'a crate::laravel::LaravelIndex>,
319}
320
321pub(crate) fn cursor_in_string_or_comment(source: &str, cursor_byte: usize) -> bool {
329 #[derive(PartialEq)]
330 enum S {
331 Normal,
332 Single,
333 Double,
334 Line,
335 Block,
336 }
337 let bytes = source.as_bytes();
338 let limit = bytes.len().min(cursor_byte);
339 let mut i = 0usize;
340 let mut state = S::Normal;
341 while i < limit {
342 match state {
343 S::Normal => match bytes[i] {
344 b'\'' => {
345 state = S::Single;
346 i += 1;
347 }
348 b'"' => {
349 state = S::Double;
350 i += 1;
351 }
352 b'/' if i + 1 < limit && bytes[i + 1] == b'/' => {
353 state = S::Line;
354 i += 2;
355 }
356 b'#' if !(i + 1 < limit && bytes[i + 1] == b'[') => {
358 state = S::Line;
359 i += 1;
360 }
361 b'/' if i + 1 < limit && bytes[i + 1] == b'*' => {
362 state = S::Block;
363 i += 2;
364 }
365 _ => {
366 i += 1;
367 }
368 },
369 S::Single => match bytes[i] {
370 b'\\' => {
371 i += 2;
372 }
373 b'\'' => {
374 state = S::Normal;
375 i += 1;
376 }
377 _ => {
378 i += 1;
379 }
380 },
381 S::Double => match bytes[i] {
382 b'\\' => {
383 i += 2;
384 }
385 b'"' => {
386 state = S::Normal;
387 i += 1;
388 }
389 _ => {
390 i += 1;
391 }
392 },
393 S::Line => {
394 if bytes[i] == b'\n' {
395 state = S::Normal;
396 }
397 i += 1;
398 }
399 S::Block => {
400 if bytes[i] == b'*' && i + 1 < limit && bytes[i + 1] == b'/' {
401 state = S::Normal;
402 i += 2;
403 } else {
404 i += 1;
405 }
406 }
407 }
408 }
409 state != S::Normal
410}
411
412pub fn filtered_completions_at(
415 doc: &ParsedDoc,
416 other_docs: &[Arc<ParsedDoc>],
417 trigger_character: Option<&str>,
418 ctx: &CompletionCtx<'_>,
419) -> Vec<CompletionItem> {
420 let source = ctx.source;
421 let position = ctx.position;
422
423 let doc_uri = ctx.doc_uri;
424
425 let laravel_completions = source
430 .zip(position)
431 .and_then(|(src, pos)| crate::laravel::completions_for_string_key(src, pos, ctx.laravel));
432
433 let blade_completions = doc_uri
438 .zip(source)
439 .zip(position)
440 .and_then(|((uri, src), pos)| {
441 crate::laravel::blade::completions(uri, src, pos, ctx.laravel)
442 });
443
444 let request_field_completions = source.zip(position).and_then(|(src, pos)| {
448 if !ctx.laravel.is_some_and(|l| l.is_laravel) {
449 return None;
450 }
451 let (receiver, prefix) =
452 crate::laravel::request_fields::method_call_string_prefix(src, pos)?;
453 let fields =
454 crate::laravel::request_fields::harvest_fields(&doc.program().stmts, &receiver);
455 Some(
456 fields
457 .into_iter()
458 .filter(|f| f.starts_with(&prefix))
459 .map(|f| CompletionItem {
460 label: f.clone(),
461 kind: Some(CompletionItemKind::FIELD),
462 insert_text: Some(f),
463 ..Default::default()
464 })
465 .collect::<Vec<_>>(),
466 )
467 });
468
469 let validation_rule_completions = source.zip(position).and_then(|(src, pos)| {
474 if !ctx.laravel.is_some_and(|l| l.is_laravel) {
475 return None;
476 }
477 let prefix = crate::laravel::validation_rules::validation_rule_prefix(doc, src, pos)?;
478 Some(crate::laravel::validation_rules::validation_rule_completions(&prefix))
479 });
480
481 if let (Some(src), Some(pos)) = (source, position) {
486 let cursor_byte = doc.view().byte_of_position(pos) as usize;
487 if cursor_in_string_or_comment(src, cursor_byte)
488 && include_path_prefix(src, pos).is_none()
489 && laravel_completions.is_none()
490 && blade_completions.is_none()
491 && request_field_completions.is_none()
492 && validation_rule_completions.is_none()
493 {
494 return vec![];
495 }
496 }
497 let empty_imports = HashMap::new();
498 let imports = ctx.file_imports.unwrap_or(&empty_imports);
499 let in_class_scope = matches!(
500 (source, position),
501 (Some(src), Some(pos)) if enclosing_class_at(src, doc, pos).is_some()
502 );
503
504 match trigger_character {
505 Some("$") => {
506 let mut items = superglobal_completions();
507 items.extend(
508 symbol_completions(doc)
509 .into_iter()
510 .filter(|i| i.kind == Some(CompletionItemKind::VARIABLE)),
511 );
512 items
513 }
514 Some(">") => {
515 if let (Some(src), Some(pos)) = (source, position)
517 && let Some(class_names) = resolve_receiver_class(src, doc, pos, ctx.analysis)
518 {
519 let mut items = Vec::new();
521 let mut seen = std::collections::HashSet::new();
522 for class_name in &class_names {
523 for item in all_instance_members(
524 class_name,
525 doc,
526 other_docs,
527 ctx.find_class_doc,
528 ctx.session,
529 in_class_scope,
530 ) {
531 if seen.insert(item.label.clone()) {
532 items.push(item);
533 }
534 }
535 }
536 if !items.is_empty() {
537 return items;
538 }
539 }
540 symbol_completions(doc)
542 .into_iter()
543 .filter(|i| i.kind == Some(CompletionItemKind::METHOD))
544 .collect()
545 }
546 Some(":") => {
547 if let (Some(src), Some(pos)) = (source, position)
549 && let Some(class_name) =
550 resolve_static_receiver(src, doc, other_docs, pos, imports)
551 {
552 let items = all_static_members(
553 &class_name,
554 doc,
555 other_docs,
556 ctx.find_class_doc,
557 ctx.session,
558 in_class_scope,
559 );
560 if !items.is_empty() {
561 return items;
562 }
563 }
564 vec![]
565 }
566 Some("[") => {
567 if let (Some(src), Some(pos)) = (source, position) {
569 let line = src.lines().nth(pos.line as usize).unwrap_or("");
570 let col = utf16_offset_to_byte(line, pos.character as usize);
571 let before = &line[..col];
572 if before.trim_end_matches('[').trim_end().ends_with('#') {
573 return attribute_completions(src, pos, doc, other_docs, imports);
574 }
575 }
576 vec![]
577 }
578 Some("(") => {
579 if let (Some(src), Some(pos)) = (source, position) {
581 let params = resolve_call_params(src, doc, other_docs, pos, ctx);
582 if !params.is_empty() {
583 return params
584 .into_iter()
585 .map(|p| CompletionItem {
586 label: format!("{p}:"),
587 kind: Some(CompletionItemKind::VARIABLE),
588 ..Default::default()
589 })
590 .collect();
591 }
592 if let Some(attr_class) = resolve_attribute_class(src, pos) {
594 let mut attr_params = params_of_method(doc, &attr_class, "__construct");
595 if attr_params.is_empty() {
596 for other in other_docs {
597 attr_params = params_of_method(other, &attr_class, "__construct");
598 if !attr_params.is_empty() {
599 break;
600 }
601 }
602 }
603 if attr_params.is_empty()
604 && let Some(docs) = ctx.session
605 {
606 attr_params = mir_method_params(docs, doc, &attr_class, "__construct")
607 .unwrap_or_default();
608 }
609 if !attr_params.is_empty() {
610 return attr_params
611 .into_iter()
612 .map(|p| CompletionItem {
613 label: format!("{p}:"),
614 kind: Some(CompletionItemKind::VARIABLE),
615 detail: Some(format!("#{attr_class} argument")),
616 ..Default::default()
617 })
618 .collect();
619 }
620 }
621 }
622 vec![]
623 }
624 _ => {
625 if let (Some(src), Some(pos)) = (source, position) {
628 let line = src.lines().nth(pos.line as usize).unwrap_or("");
629 let col = utf16_offset_to_byte(line, pos.character as usize);
630 let before = &line[..col];
631 let pre_colon = before.trim_end_matches(|c: char| c.is_alphanumeric() || c == '_');
632 if pre_colon.ends_with("::") {
633 let colon_end_char = pre_colon.encode_utf16().count() as u32;
634 let colon_pos = tower_lsp_server::ls_types::Position {
635 line: pos.line,
636 character: colon_end_char,
637 };
638 if let Some(class_name) =
639 resolve_static_receiver(src, doc, other_docs, colon_pos, imports)
640 {
641 let items = all_static_members(
642 &class_name,
643 doc,
644 other_docs,
645 ctx.find_class_doc,
646 ctx.session,
647 in_class_scope,
648 );
649 if !items.is_empty() {
650 return items;
651 }
652 }
653 }
654 }
655
656 if let (Some(src), Some(pos)) = (source, position) {
660 let line = src.lines().nth(pos.line as usize).unwrap_or("");
661 let col = utf16_offset_to_byte(line, pos.character as usize);
662 let before = &line[..col];
663 let pre_arrow = before.trim_end_matches(|c: char| c.is_alphanumeric() || c == '_');
665 let has_arrow = pre_arrow.ends_with("->") || pre_arrow.ends_with("?->");
666 if has_arrow {
667 let arrow_end_char = pre_arrow.encode_utf16().count() as u32;
673 let arrow_pos = tower_lsp_server::ls_types::Position {
674 line: pos.line,
675 character: arrow_end_char,
676 };
677 if let Some(cls) = resolve_receiver_class(src, doc, arrow_pos, ctx.analysis) {
678 let mut items = Vec::new();
679 let mut seen = std::collections::HashSet::new();
680 for class_name in &cls {
681 for item in all_instance_members(
682 class_name,
683 doc,
684 other_docs,
685 ctx.find_class_doc,
686 ctx.session,
687 in_class_scope,
688 ) {
689 if seen.insert(item.label.clone()) {
690 items.push(item);
691 }
692 }
693 }
694 if !items.is_empty() {
695 let prefix = before.strip_prefix(pre_arrow).unwrap_or("").to_string();
697 if !prefix.is_empty() {
698 let fq = crate::text::FuzzyQuery::new(&prefix);
699 items.retain(|i| {
700 let match_against = if i.label.starts_with('$') {
701 i.label.strip_prefix('$').unwrap_or(&i.label)
702 } else {
703 &i.label
704 };
705 fq.camel_match(match_against)
706 });
707 for item in &mut items {
708 let match_against = if item.label.starts_with('$') {
709 item.label.strip_prefix('$').unwrap_or(&item.label)
710 } else {
711 &item.label
712 };
713 item.sort_text =
714 Some(crate::text::camel_sort_key(&prefix, match_against));
715 item.filter_text = Some(item.label.clone());
716 }
717 }
718 return items;
719 }
720 }
721 }
722 }
723
724 if let (Some(src), Some(pos)) = (source, position) {
726 let line = src.lines().nth(pos.line as usize).unwrap_or("");
727 let col = utf16_offset_to_byte(line, pos.character as usize);
728 let before = &line[..col];
729 let pre_ident =
730 before.trim_end_matches(|c: char| c.is_alphanumeric() || c == '_' || c == '\\');
731 if pre_ident.trim_end().ends_with("#[") || pre_ident.trim_end() == "#[" {
732 let items = attribute_completions(src, pos, doc, other_docs, imports);
733 if !items.is_empty() {
734 return items;
735 }
736 }
737 }
738
739 if let (Some(src), Some(pos)) = (source, position)
742 && let Some((use_kind, use_prefix)) = use_completion_prefix(src, pos)
743 {
744 let mut use_items: Vec<CompletionItem> = Vec::new();
745 for other in other_docs {
746 collect_fqns_with_prefix(
747 &other.program().stmts,
748 "",
749 &use_prefix,
750 use_kind,
751 &mut use_items,
752 );
753 }
754 collect_fqns_with_prefix(
756 &doc.program().stmts,
757 "",
758 &use_prefix,
759 use_kind,
760 &mut use_items,
761 );
762 if !use_items.is_empty() {
763 return use_items;
764 }
765 }
766
767 if let Some(items) = blade_completions {
773 return items;
774 }
775
776 if let (Some(src), Some(pos), Some(uri)) = (source, position, doc_uri)
778 && let Some(prefix) = include_path_prefix(src, pos)
779 {
780 let items = include_path_completions(uri, &prefix);
783 return items;
784 }
785
786 if let Some(items) = laravel_completions {
788 return items;
789 }
790 if let Some(items) = request_field_completions {
791 return items;
792 }
793 if let Some(items) = validation_rule_completions {
794 return items;
795 }
796
797 let other_classes_cell: std::cell::OnceCell<
802 Vec<Vec<(String, CompletionItemKind, String)>>,
803 > = std::cell::OnceCell::new();
804 let other_classes = || {
805 other_classes_cell.get_or_init(|| {
806 other_docs
807 .iter()
808 .map(|other| {
809 let mut classes = Vec::new();
810 collect_classes_with_ns(&other.program().stmts, "", &mut classes);
811 classes
812 })
813 .collect()
814 })
815 };
816
817 if let (Some(src), Some(pos)) = (source, position)
819 && let Some(prefix) = typed_prefix(Some(src), Some(pos))
820 && prefix.contains('\\')
821 {
822 let is_use = use_completion_prefix(src, pos).is_some();
824 if !is_use {
825 let prefix_lc = prefix.trim_start_matches('\\').to_lowercase();
826 let mut ns_items: Vec<CompletionItem> = Vec::new();
827 for classes in other_classes() {
828 for (label, kind, fqn) in classes {
829 if fqn
830 .get(..prefix_lc.len())
831 .is_some_and(|s| s.eq_ignore_ascii_case(&prefix_lc))
832 {
833 ns_items.push(CompletionItem {
834 label: label.clone(),
835 kind: Some(*kind),
836 insert_text: Some(label.clone()),
837 detail: Some(fqn.clone()),
838 ..Default::default()
839 });
840 }
841 }
842 }
843 let mut classes = Vec::new();
844 collect_classes_with_ns(&doc.program().stmts, "", &mut classes);
845 for (label, kind, fqn) in classes {
846 if fqn
847 .get(..prefix_lc.len())
848 .is_some_and(|s| s.eq_ignore_ascii_case(&prefix_lc))
849 {
850 ns_items.push(CompletionItem {
851 label: label.clone(),
852 kind: Some(kind),
853 insert_text: Some(label),
854 detail: Some(fqn),
855 ..Default::default()
856 });
857 }
858 }
859 if !ns_items.is_empty() {
860 return ns_items;
861 }
862 }
863 }
864
865 if let (Some(src), Some(pos)) = (source, position)
867 && let Some(match_items) = match_arm_completions(
868 src,
869 doc,
870 other_docs,
871 pos,
872 ctx.analysis,
873 ctx.find_class_doc,
874 )
875 && !match_items.is_empty()
876 {
877 let mut all = match_items;
878 let mut normal_items = keyword_completions();
880 normal_items.extend(magic_constant_completions());
881 normal_items.extend(builtin_completions());
882 normal_items.extend(superglobal_completions());
883 normal_items.extend(symbol_completions(doc));
884 all.extend(normal_items);
885
886 let mut seen = std::collections::HashSet::new();
888 all.retain(|i| seen.insert(i.label.clone()));
889
890 return all;
891 }
892
893 let mut magic_items: Vec<CompletionItem> = Vec::new();
895 if let (Some(src), Some(pos)) = (source, position)
896 && enclosing_class_at(src, doc, pos).is_some()
897 {
898 magic_items.extend(magic_method_completions());
899 }
900
901 let prefix = typed_prefix(source, position).unwrap_or_default();
907 let ident_query = (!prefix.is_empty() && !prefix.contains('\\'))
908 .then(|| crate::text::FuzzyQuery::new(&prefix));
909 let keep_static =
910 |label: &str| ident_query.as_ref().is_none_or(|fq| fq.camel_match(label));
911
912 let mut items = keyword_completions_matching(&keep_static);
913 items.extend(magic_constant_completions_matching(&keep_static));
914 items.extend(builtin_completions_matching(&keep_static));
915 items.extend(superglobal_completions_matching(&keep_static));
916 let sym_items = if let (Some(_src), Some(pos)) = (source, position) {
918 symbol_completions_before(doc, pos.line)
919 } else {
920 symbol_completions(doc)
921 };
922 items.extend(sym_items);
923 items.extend(magic_items);
924
925 let cur_ns = current_file_namespace(&doc.program().stmts);
926
927 let use_pos = source.map(use_insert_position);
932
933 let push_class_item = |items: &mut Vec<CompletionItem>,
937 label: &str,
938 kind: CompletionItemKind,
939 fqn: &str| {
940 let additional_text_edits = if let Some(pos) = use_pos {
941 let in_same_ns = !cur_ns.is_empty() && fqn == format!("{}\\{}", cur_ns, label);
942 let is_global = !fqn.contains('\\');
943 let already = imports.contains_key(label);
944 if !in_same_ns && !is_global && !already {
945 Some(vec![TextEdit {
946 range: Range {
947 start: pos,
948 end: pos,
949 },
950 new_text: format!("use {};\n", fqn),
951 }])
952 } else {
953 None
954 }
955 } else {
956 None
957 };
958 items.push(CompletionItem {
959 label: label.to_string(),
960 kind: Some(kind),
961 detail: if fqn.contains('\\') {
962 Some(fqn.to_string())
963 } else {
964 None
965 },
966 additional_text_edits,
967 ..Default::default()
968 });
969 };
970
971 for (other, classes) in other_docs.iter().zip(other_classes()) {
972 for (label, kind, fqn) in classes {
974 push_class_item(&mut items, label, *kind, fqn);
975 }
976 let cross: Vec<CompletionItem> = symbol_completions(other)
978 .into_iter()
979 .filter(|i| {
980 !matches!(
981 i.kind,
982 Some(CompletionItemKind::CLASS)
983 | Some(CompletionItemKind::INTERFACE)
984 | Some(CompletionItemKind::ENUM)
985 ) && i.kind != Some(CompletionItemKind::VARIABLE)
986 })
987 .collect();
988 items.extend(cross);
989 }
990
991 if !prefix.is_empty()
996 && !prefix.contains('\\')
997 && let Some(search) = ctx.workspace_class_search
998 {
999 for (label, kind, fqn) in search(&prefix) {
1000 push_class_item(&mut items, &label, kind, &fqn);
1001 }
1002 }
1003
1004 let mut seen = std::collections::HashSet::new();
1005 items.retain(|i| seen.insert(i.label.clone()));
1006
1007 if prefix.contains('\\') {
1008 let ns_prefix = prefix.trim_start_matches('\\').to_lowercase();
1010 items.retain(|i| {
1011 let fqn = i.detail.as_deref().unwrap_or(&i.label);
1012 fqn.get(..ns_prefix.len())
1013 .is_some_and(|s| s.eq_ignore_ascii_case(&ns_prefix))
1014 });
1015 } else if !prefix.is_empty() {
1016 let fq = crate::text::FuzzyQuery::new(&prefix);
1017 items.retain(|i| fq.camel_match(&i.label));
1018 for item in &mut items {
1019 item.sort_text = Some(camel_sort_key(&prefix, &item.label));
1020 item.filter_text = Some(item.label.clone());
1021 }
1022 }
1023 items
1024 }
1025 }
1026}
1027
1028#[cfg(test)]
1029mod tests {
1030 use super::*;
1031
1032 #[test]
1033 fn method_params_ignore_same_short_name_in_other_namespace() {
1034 let doc = ParsedDoc::parse("<?php".to_string());
1035 let others = [
1036 Arc::new(ParsedDoc::parse(
1037 "<?php\nnamespace Other;\nclass Svc { public function run(int $wrong) {} }"
1038 .to_string(),
1039 )),
1040 Arc::new(ParsedDoc::parse(
1041 "<?php\nnamespace App;\nclass Svc { public function run(int $right) {} }"
1042 .to_string(),
1043 )),
1044 ];
1045 let params =
1046 params_of_method_anywhere("App\\Svc", "run", &doc, &others, &CompletionCtx::default());
1047 assert_eq!(params, ["right"]);
1048 }
1049
1050 fn labels(items: &[CompletionItem]) -> Vec<&str> {
1051 items.iter().map(|i| i.label.as_str()).collect()
1052 }
1053
1054 #[test]
1055 fn keywords_list_is_non_empty() {
1056 let kws = keyword_completions();
1057 assert!(
1058 kws.len() >= 20,
1059 "expected at least 20 keywords, got {}",
1060 kws.len()
1061 );
1062 }
1063
1064 #[test]
1065 fn keywords_contain_common_php_keywords() {
1066 let kws = keyword_completions();
1067 let ls = labels(&kws);
1068 for expected in &[
1069 "function",
1070 "class",
1071 "return",
1072 "foreach",
1073 "match",
1074 "namespace",
1075 ] {
1076 assert!(ls.contains(expected), "missing keyword: {expected}");
1077 }
1078 }
1079
1080 #[test]
1081 fn type_keywords_present() {
1082 let kws = keyword_completions();
1083 let ls = labels(&kws);
1084 for expected in &[
1085 "bool", "float", "int", "iterable", "mixed", "never", "object", "string", "void",
1086 "parent",
1087 ] {
1088 assert!(ls.contains(expected), "missing type keyword: {expected}");
1089 }
1090 }
1091
1092 #[test]
1093 fn all_keyword_items_have_keyword_kind() {
1094 for item in keyword_completions() {
1095 assert_eq!(item.kind, Some(CompletionItemKind::KEYWORD));
1096 }
1097 }
1098
1099 #[test]
1100 fn magic_constants_all_present() {
1101 let items = magic_constant_completions();
1102 let ls: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
1103 for name in &[
1104 "__FILE__",
1105 "__DIR__",
1106 "__LINE__",
1107 "__CLASS__",
1108 "__FUNCTION__",
1109 "__METHOD__",
1110 "__NAMESPACE__",
1111 "__TRAIT__",
1112 ] {
1113 assert!(ls.contains(name), "missing magic constant: {name}");
1114 }
1115 }
1116
1117 #[test]
1118 fn magic_constants_have_constant_kind() {
1119 for item in magic_constant_completions() {
1120 assert_eq!(
1121 item.kind,
1122 Some(CompletionItemKind::CONSTANT),
1123 "{} should have CONSTANT kind",
1124 item.label
1125 );
1126 }
1127 }
1128
1129 #[test]
1130 fn resolve_attribute_class_extracts_name() {
1131 let src = "<?php\n#[Route(\n";
1132 let pos = Position {
1134 line: 1,
1135 character: 8,
1136 };
1137 let result = resolve_attribute_class(src, pos);
1138 assert_eq!(result.as_deref(), Some("Route"));
1139 }
1140
1141 #[test]
1142 fn resolve_attribute_class_fqn_extracts_short_name() {
1143 let src = "<?php\n#[\\Symfony\\Component\\Routing\\Route(\n";
1144 let pos = Position {
1145 line: 1,
1146 character: 38,
1147 };
1148 let result = resolve_attribute_class(src, pos);
1149 assert_eq!(result.as_deref(), Some("Route"));
1150 }
1151
1152 #[test]
1153 fn resolve_attribute_class_returns_none_for_regular_call() {
1154 let src = "<?php\nsomeFunction(\n";
1155 let pos = Position {
1156 line: 1,
1157 character: 14,
1158 };
1159 let result = resolve_attribute_class(src, pos);
1160 assert!(result.is_none(), "should not match regular function call");
1161 }
1162}