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, resolve_receiver_class,
19 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 .map(|class_name| {
208 params_of_method_anywhere(&class_name, &func_name, doc, other_docs, ctx)
209 })
210 .unwrap_or_default();
211 }
212 if before_receiver.ends_with("::") {
213 let empty_imports = HashMap::new();
214 let imports = ctx.file_imports.unwrap_or(&empty_imports);
215 return resolve_static_receiver(source, doc, other_docs, receiver_pos, imports)
216 .map(|class_name| {
217 params_of_method_anywhere(&class_name, &func_name, doc, other_docs, ctx)
218 })
219 .unwrap_or_default();
220 }
221
222 let mut params = params_of_function(doc, &func_name);
223 if params.is_empty() {
224 for other in other_docs {
225 params = params_of_function(other, &func_name);
226 if !params.is_empty() {
227 break;
228 }
229 }
230 }
231 params
232}
233
234fn params_of_method_anywhere(
241 class_name: &str,
242 method_name: &str,
243 doc: &ParsedDoc,
244 other_docs: &[Arc<ParsedDoc>],
245 ctx: &CompletionCtx<'_>,
246) -> Vec<String> {
247 let params = params_of_method(doc, class_name, method_name);
248 if !params.is_empty() {
249 return params;
250 }
251 if let Some(find) = ctx.find_class_doc
252 && let Some(class_doc) = find(class_name)
253 {
254 let params = params_of_method(&class_doc, class_name, method_name);
255 if !params.is_empty() {
256 return params;
257 }
258 }
259 for other in other_docs {
260 let params = params_of_method(other, class_name, method_name);
261 if !params.is_empty() {
262 return params;
263 }
264 }
265 vec![]
266}
267
268pub type ClassDocLookup<'a> = &'a dyn Fn(&str) -> Option<Arc<ParsedDoc>>;
272
273pub type WorkspaceClassSearch<'a> = &'a dyn Fn(&str) -> Vec<(String, CompletionItemKind, String)>;
279
280#[derive(Default)]
283pub struct CompletionCtx<'a> {
284 pub source: Option<&'a str>,
285 pub position: Option<Position>,
286 pub doc_uri: Option<&'a Uri>,
287 pub file_imports: Option<&'a HashMap<String, String>>,
288 pub find_class_doc: Option<ClassDocLookup<'a>>,
294 pub workspace_class_search: Option<WorkspaceClassSearch<'a>>,
299 pub analysis: Option<&'a mir_analyzer::FileAnalysis>,
303 pub session: Option<&'a crate::document::document_store::DocumentStore>,
311 pub laravel: Option<&'a crate::laravel::LaravelIndex>,
316}
317
318pub(crate) fn cursor_in_string_or_comment(source: &str, cursor_byte: usize) -> bool {
326 #[derive(PartialEq)]
327 enum S {
328 Normal,
329 Single,
330 Double,
331 Line,
332 Block,
333 }
334 let bytes = source.as_bytes();
335 let limit = bytes.len().min(cursor_byte);
336 let mut i = 0usize;
337 let mut state = S::Normal;
338 while i < limit {
339 match state {
340 S::Normal => match bytes[i] {
341 b'\'' => {
342 state = S::Single;
343 i += 1;
344 }
345 b'"' => {
346 state = S::Double;
347 i += 1;
348 }
349 b'/' if i + 1 < limit && bytes[i + 1] == b'/' => {
350 state = S::Line;
351 i += 2;
352 }
353 b'#' if !(i + 1 < limit && bytes[i + 1] == b'[') => {
355 state = S::Line;
356 i += 1;
357 }
358 b'/' if i + 1 < limit && bytes[i + 1] == b'*' => {
359 state = S::Block;
360 i += 2;
361 }
362 _ => {
363 i += 1;
364 }
365 },
366 S::Single => match bytes[i] {
367 b'\\' => {
368 i += 2;
369 }
370 b'\'' => {
371 state = S::Normal;
372 i += 1;
373 }
374 _ => {
375 i += 1;
376 }
377 },
378 S::Double => match bytes[i] {
379 b'\\' => {
380 i += 2;
381 }
382 b'"' => {
383 state = S::Normal;
384 i += 1;
385 }
386 _ => {
387 i += 1;
388 }
389 },
390 S::Line => {
391 if bytes[i] == b'\n' {
392 state = S::Normal;
393 }
394 i += 1;
395 }
396 S::Block => {
397 if bytes[i] == b'*' && i + 1 < limit && bytes[i + 1] == b'/' {
398 state = S::Normal;
399 i += 2;
400 } else {
401 i += 1;
402 }
403 }
404 }
405 }
406 state != S::Normal
407}
408
409pub fn filtered_completions_at(
412 doc: &ParsedDoc,
413 other_docs: &[Arc<ParsedDoc>],
414 trigger_character: Option<&str>,
415 ctx: &CompletionCtx<'_>,
416) -> Vec<CompletionItem> {
417 let source = ctx.source;
418 let position = ctx.position;
419
420 let doc_uri = ctx.doc_uri;
421
422 let laravel_completions = source
427 .zip(position)
428 .and_then(|(src, pos)| crate::laravel::completions_for_string_key(src, pos, ctx.laravel));
429
430 let blade_completions = doc_uri
435 .zip(source)
436 .zip(position)
437 .and_then(|((uri, src), pos)| {
438 crate::laravel::blade::completions(uri, src, pos, ctx.laravel)
439 });
440
441 let request_field_completions = source.zip(position).and_then(|(src, pos)| {
445 if !ctx.laravel.is_some_and(|l| l.is_laravel) {
446 return None;
447 }
448 let (receiver, prefix) =
449 crate::laravel::request_fields::method_call_string_prefix(src, pos)?;
450 let fields =
451 crate::laravel::request_fields::harvest_fields(&doc.program().stmts, &receiver);
452 Some(
453 fields
454 .into_iter()
455 .filter(|f| f.starts_with(&prefix))
456 .map(|f| CompletionItem {
457 label: f.clone(),
458 kind: Some(CompletionItemKind::FIELD),
459 insert_text: Some(f),
460 ..Default::default()
461 })
462 .collect::<Vec<_>>(),
463 )
464 });
465
466 let validation_rule_completions = source.zip(position).and_then(|(src, pos)| {
471 if !ctx.laravel.is_some_and(|l| l.is_laravel) {
472 return None;
473 }
474 let prefix = crate::laravel::validation_rules::validation_rule_prefix(doc, src, pos)?;
475 Some(crate::laravel::validation_rules::validation_rule_completions(&prefix))
476 });
477
478 if let (Some(src), Some(pos)) = (source, position) {
483 let cursor_byte = doc.view().byte_of_position(pos) as usize;
484 if cursor_in_string_or_comment(src, cursor_byte)
485 && include_path_prefix(src, pos).is_none()
486 && laravel_completions.is_none()
487 && blade_completions.is_none()
488 && request_field_completions.is_none()
489 && validation_rule_completions.is_none()
490 {
491 return vec![];
492 }
493 }
494 let empty_imports = HashMap::new();
495 let imports = ctx.file_imports.unwrap_or(&empty_imports);
496 let in_class_scope = matches!(
497 (source, position),
498 (Some(src), Some(pos)) if enclosing_class_at(src, doc, pos).is_some()
499 );
500
501 match trigger_character {
502 Some("$") => {
503 let mut items = superglobal_completions();
504 items.extend(
505 symbol_completions(doc)
506 .into_iter()
507 .filter(|i| i.kind == Some(CompletionItemKind::VARIABLE)),
508 );
509 items
510 }
511 Some(">") => {
512 if let (Some(src), Some(pos)) = (source, position)
514 && let Some(class_names) = resolve_receiver_class(src, doc, pos, ctx.analysis)
515 {
516 let mut items = Vec::new();
518 let mut seen = std::collections::HashSet::new();
519 for class_name in class_names.split('|') {
520 let class_name = class_name.trim();
521 for item in all_instance_members(
522 class_name,
523 doc,
524 other_docs,
525 ctx.find_class_doc,
526 ctx.session,
527 in_class_scope,
528 ) {
529 if seen.insert(item.label.clone()) {
530 items.push(item);
531 }
532 }
533 }
534 if !items.is_empty() {
535 return items;
536 }
537 }
538 symbol_completions(doc)
540 .into_iter()
541 .filter(|i| i.kind == Some(CompletionItemKind::METHOD))
542 .collect()
543 }
544 Some(":") => {
545 if let (Some(src), Some(pos)) = (source, position)
547 && let Some(class_name) =
548 resolve_static_receiver(src, doc, other_docs, pos, imports)
549 {
550 let items = all_static_members(
551 &class_name,
552 doc,
553 other_docs,
554 ctx.find_class_doc,
555 ctx.session,
556 in_class_scope,
557 );
558 if !items.is_empty() {
559 return items;
560 }
561 }
562 vec![]
563 }
564 Some("[") => {
565 if let (Some(src), Some(pos)) = (source, position) {
567 let line = src.lines().nth(pos.line as usize).unwrap_or("");
568 let col = utf16_offset_to_byte(line, pos.character as usize);
569 let before = &line[..col];
570 if before.trim_end_matches('[').trim_end().ends_with('#') {
571 return attribute_completions(src, pos, doc, other_docs, imports);
572 }
573 }
574 vec![]
575 }
576 Some("(") => {
577 if let (Some(src), Some(pos)) = (source, position) {
579 let params = resolve_call_params(src, doc, other_docs, pos, ctx);
580 if !params.is_empty() {
581 return params
582 .into_iter()
583 .map(|p| CompletionItem {
584 label: format!("{p}:"),
585 kind: Some(CompletionItemKind::VARIABLE),
586 ..Default::default()
587 })
588 .collect();
589 }
590 if let Some(attr_class) = resolve_attribute_class(src, pos) {
592 let mut attr_params = params_of_method(doc, &attr_class, "__construct");
593 if attr_params.is_empty() {
594 for other in other_docs {
595 attr_params = params_of_method(other, &attr_class, "__construct");
596 if !attr_params.is_empty() {
597 break;
598 }
599 }
600 }
601 if !attr_params.is_empty() {
602 return attr_params
603 .into_iter()
604 .map(|p| CompletionItem {
605 label: format!("{p}:"),
606 kind: Some(CompletionItemKind::VARIABLE),
607 detail: Some(format!("#{attr_class} argument")),
608 ..Default::default()
609 })
610 .collect();
611 }
612 }
613 }
614 vec![]
615 }
616 _ => {
617 if let (Some(src), Some(pos)) = (source, position) {
620 let line = src.lines().nth(pos.line as usize).unwrap_or("");
621 let col = utf16_offset_to_byte(line, pos.character as usize);
622 let before = &line[..col];
623 let pre_colon = before.trim_end_matches(|c: char| c.is_alphanumeric() || c == '_');
624 if pre_colon.ends_with("::") {
625 let colon_end_char = pre_colon.encode_utf16().count() as u32;
626 let colon_pos = tower_lsp_server::ls_types::Position {
627 line: pos.line,
628 character: colon_end_char,
629 };
630 if let Some(class_name) =
631 resolve_static_receiver(src, doc, other_docs, colon_pos, imports)
632 {
633 let items = all_static_members(
634 &class_name,
635 doc,
636 other_docs,
637 ctx.find_class_doc,
638 ctx.session,
639 in_class_scope,
640 );
641 if !items.is_empty() {
642 return items;
643 }
644 }
645 }
646 }
647
648 if let (Some(src), Some(pos)) = (source, position) {
652 let line = src.lines().nth(pos.line as usize).unwrap_or("");
653 let col = utf16_offset_to_byte(line, pos.character as usize);
654 let before = &line[..col];
655 let pre_arrow = before.trim_end_matches(|c: char| c.is_alphanumeric() || c == '_');
657 let has_arrow = pre_arrow.ends_with("->") || pre_arrow.ends_with("?->");
658 if has_arrow {
659 let arrow_end_char = pre_arrow.encode_utf16().count() as u32;
665 let arrow_pos = tower_lsp_server::ls_types::Position {
666 line: pos.line,
667 character: arrow_end_char,
668 };
669 if let Some(cls) = resolve_receiver_class(src, doc, arrow_pos, ctx.analysis) {
670 let mut items = Vec::new();
671 let mut seen = std::collections::HashSet::new();
672 for class_name in cls.split('|') {
673 for item in all_instance_members(
674 class_name.trim(),
675 doc,
676 other_docs,
677 ctx.find_class_doc,
678 ctx.session,
679 in_class_scope,
680 ) {
681 if seen.insert(item.label.clone()) {
682 items.push(item);
683 }
684 }
685 }
686 if !items.is_empty() {
687 let prefix = before.strip_prefix(pre_arrow).unwrap_or("").to_string();
689 if !prefix.is_empty() {
690 let fq = crate::text::FuzzyQuery::new(&prefix);
691 items.retain(|i| {
692 let match_against = if i.label.starts_with('$') {
693 i.label.strip_prefix('$').unwrap_or(&i.label)
694 } else {
695 &i.label
696 };
697 fq.camel_match(match_against)
698 });
699 for item in &mut items {
700 let match_against = if item.label.starts_with('$') {
701 item.label.strip_prefix('$').unwrap_or(&item.label)
702 } else {
703 &item.label
704 };
705 item.sort_text =
706 Some(crate::text::camel_sort_key(&prefix, match_against));
707 item.filter_text = Some(item.label.clone());
708 }
709 }
710 return items;
711 }
712 }
713 }
714 }
715
716 if let (Some(src), Some(pos)) = (source, position) {
718 let line = src.lines().nth(pos.line as usize).unwrap_or("");
719 let col = utf16_offset_to_byte(line, pos.character as usize);
720 let before = &line[..col];
721 let pre_ident =
722 before.trim_end_matches(|c: char| c.is_alphanumeric() || c == '_' || c == '\\');
723 if pre_ident.trim_end().ends_with("#[") || pre_ident.trim_end() == "#[" {
724 let items = attribute_completions(src, pos, doc, other_docs, imports);
725 if !items.is_empty() {
726 return items;
727 }
728 }
729 }
730
731 if let (Some(src), Some(pos)) = (source, position)
734 && let Some((use_kind, use_prefix)) = use_completion_prefix(src, pos)
735 {
736 let mut use_items: Vec<CompletionItem> = Vec::new();
737 for other in other_docs {
738 collect_fqns_with_prefix(
739 &other.program().stmts,
740 "",
741 &use_prefix,
742 use_kind,
743 &mut use_items,
744 );
745 }
746 collect_fqns_with_prefix(
748 &doc.program().stmts,
749 "",
750 &use_prefix,
751 use_kind,
752 &mut use_items,
753 );
754 if !use_items.is_empty() {
755 return use_items;
756 }
757 }
758
759 if let Some(items) = blade_completions {
765 return items;
766 }
767
768 if let (Some(src), Some(pos), Some(uri)) = (source, position, doc_uri)
770 && let Some(prefix) = include_path_prefix(src, pos)
771 {
772 let items = include_path_completions(uri, &prefix);
775 return items;
776 }
777
778 if let Some(items) = laravel_completions {
780 return items;
781 }
782 if let Some(items) = request_field_completions {
783 return items;
784 }
785 if let Some(items) = validation_rule_completions {
786 return items;
787 }
788
789 let other_classes_cell: std::cell::OnceCell<
794 Vec<Vec<(String, CompletionItemKind, String)>>,
795 > = std::cell::OnceCell::new();
796 let other_classes = || {
797 other_classes_cell.get_or_init(|| {
798 other_docs
799 .iter()
800 .map(|other| {
801 let mut classes = Vec::new();
802 collect_classes_with_ns(&other.program().stmts, "", &mut classes);
803 classes
804 })
805 .collect()
806 })
807 };
808
809 if let (Some(src), Some(pos)) = (source, position)
811 && let Some(prefix) = typed_prefix(Some(src), Some(pos))
812 && prefix.contains('\\')
813 {
814 let is_use = use_completion_prefix(src, pos).is_some();
816 if !is_use {
817 let prefix_lc = prefix.trim_start_matches('\\').to_lowercase();
818 let mut ns_items: Vec<CompletionItem> = Vec::new();
819 for classes in other_classes() {
820 for (label, kind, fqn) in classes {
821 if fqn
822 .get(..prefix_lc.len())
823 .is_some_and(|s| s.eq_ignore_ascii_case(&prefix_lc))
824 {
825 ns_items.push(CompletionItem {
826 label: label.clone(),
827 kind: Some(*kind),
828 insert_text: Some(label.clone()),
829 detail: Some(fqn.clone()),
830 ..Default::default()
831 });
832 }
833 }
834 }
835 let mut classes = Vec::new();
836 collect_classes_with_ns(&doc.program().stmts, "", &mut classes);
837 for (label, kind, fqn) in classes {
838 if fqn
839 .get(..prefix_lc.len())
840 .is_some_and(|s| s.eq_ignore_ascii_case(&prefix_lc))
841 {
842 ns_items.push(CompletionItem {
843 label: label.clone(),
844 kind: Some(kind),
845 insert_text: Some(label),
846 detail: Some(fqn),
847 ..Default::default()
848 });
849 }
850 }
851 if !ns_items.is_empty() {
852 return ns_items;
853 }
854 }
855 }
856
857 if let (Some(src), Some(pos)) = (source, position)
859 && let Some(match_items) = match_arm_completions(
860 src,
861 doc,
862 other_docs,
863 pos,
864 ctx.analysis,
865 ctx.find_class_doc,
866 )
867 && !match_items.is_empty()
868 {
869 let mut all = match_items;
870 let mut normal_items = keyword_completions();
872 normal_items.extend(magic_constant_completions());
873 normal_items.extend(builtin_completions());
874 normal_items.extend(superglobal_completions());
875 normal_items.extend(symbol_completions(doc));
876 all.extend(normal_items);
877
878 let mut seen = std::collections::HashSet::new();
880 all.retain(|i| seen.insert(i.label.clone()));
881
882 return all;
883 }
884
885 let mut magic_items: Vec<CompletionItem> = Vec::new();
887 if let (Some(src), Some(pos)) = (source, position)
888 && enclosing_class_at(src, doc, pos).is_some()
889 {
890 magic_items.extend(magic_method_completions());
891 }
892
893 let prefix = typed_prefix(source, position).unwrap_or_default();
899 let ident_query = (!prefix.is_empty() && !prefix.contains('\\'))
900 .then(|| crate::text::FuzzyQuery::new(&prefix));
901 let keep_static =
902 |label: &str| ident_query.as_ref().is_none_or(|fq| fq.camel_match(label));
903
904 let mut items = keyword_completions_matching(&keep_static);
905 items.extend(magic_constant_completions_matching(&keep_static));
906 items.extend(builtin_completions_matching(&keep_static));
907 items.extend(superglobal_completions_matching(&keep_static));
908 let sym_items = if let (Some(_src), Some(pos)) = (source, position) {
910 symbol_completions_before(doc, pos.line)
911 } else {
912 symbol_completions(doc)
913 };
914 items.extend(sym_items);
915 items.extend(magic_items);
916
917 let cur_ns = current_file_namespace(&doc.program().stmts);
918
919 let use_pos = source.map(use_insert_position);
924
925 let push_class_item = |items: &mut Vec<CompletionItem>,
929 label: &str,
930 kind: CompletionItemKind,
931 fqn: &str| {
932 let additional_text_edits = if let Some(pos) = use_pos {
933 let in_same_ns = !cur_ns.is_empty() && fqn == format!("{}\\{}", cur_ns, label);
934 let is_global = !fqn.contains('\\');
935 let already = imports.contains_key(label);
936 if !in_same_ns && !is_global && !already {
937 Some(vec![TextEdit {
938 range: Range {
939 start: pos,
940 end: pos,
941 },
942 new_text: format!("use {};\n", fqn),
943 }])
944 } else {
945 None
946 }
947 } else {
948 None
949 };
950 items.push(CompletionItem {
951 label: label.to_string(),
952 kind: Some(kind),
953 detail: if fqn.contains('\\') {
954 Some(fqn.to_string())
955 } else {
956 None
957 },
958 additional_text_edits,
959 ..Default::default()
960 });
961 };
962
963 for (other, classes) in other_docs.iter().zip(other_classes()) {
964 for (label, kind, fqn) in classes {
966 push_class_item(&mut items, label, *kind, fqn);
967 }
968 let cross: Vec<CompletionItem> = symbol_completions(other)
970 .into_iter()
971 .filter(|i| {
972 !matches!(
973 i.kind,
974 Some(CompletionItemKind::CLASS)
975 | Some(CompletionItemKind::INTERFACE)
976 | Some(CompletionItemKind::ENUM)
977 ) && i.kind != Some(CompletionItemKind::VARIABLE)
978 })
979 .collect();
980 items.extend(cross);
981 }
982
983 if !prefix.is_empty()
988 && !prefix.contains('\\')
989 && let Some(search) = ctx.workspace_class_search
990 {
991 for (label, kind, fqn) in search(&prefix) {
992 push_class_item(&mut items, &label, kind, &fqn);
993 }
994 }
995
996 let mut seen = std::collections::HashSet::new();
997 items.retain(|i| seen.insert(i.label.clone()));
998
999 if prefix.contains('\\') {
1000 let ns_prefix = prefix.trim_start_matches('\\').to_lowercase();
1002 items.retain(|i| {
1003 let fqn = i.detail.as_deref().unwrap_or(&i.label);
1004 fqn.get(..ns_prefix.len())
1005 .is_some_and(|s| s.eq_ignore_ascii_case(&ns_prefix))
1006 });
1007 } else if !prefix.is_empty() {
1008 let fq = crate::text::FuzzyQuery::new(&prefix);
1009 items.retain(|i| fq.camel_match(&i.label));
1010 for item in &mut items {
1011 item.sort_text = Some(camel_sort_key(&prefix, &item.label));
1012 item.filter_text = Some(item.label.clone());
1013 }
1014 }
1015 items
1016 }
1017 }
1018}
1019
1020#[cfg(test)]
1021mod tests {
1022 use super::*;
1023
1024 #[test]
1025 fn method_params_ignore_same_short_name_in_other_namespace() {
1026 let doc = ParsedDoc::parse("<?php".to_string());
1027 let others = [
1028 Arc::new(ParsedDoc::parse(
1029 "<?php\nnamespace Other;\nclass Svc { public function run(int $wrong) {} }"
1030 .to_string(),
1031 )),
1032 Arc::new(ParsedDoc::parse(
1033 "<?php\nnamespace App;\nclass Svc { public function run(int $right) {} }"
1034 .to_string(),
1035 )),
1036 ];
1037 let params =
1038 params_of_method_anywhere("App\\Svc", "run", &doc, &others, &CompletionCtx::default());
1039 assert_eq!(params, ["right"]);
1040 }
1041
1042 fn labels(items: &[CompletionItem]) -> Vec<&str> {
1043 items.iter().map(|i| i.label.as_str()).collect()
1044 }
1045
1046 #[test]
1047 fn keywords_list_is_non_empty() {
1048 let kws = keyword_completions();
1049 assert!(
1050 kws.len() >= 20,
1051 "expected at least 20 keywords, got {}",
1052 kws.len()
1053 );
1054 }
1055
1056 #[test]
1057 fn keywords_contain_common_php_keywords() {
1058 let kws = keyword_completions();
1059 let ls = labels(&kws);
1060 for expected in &[
1061 "function",
1062 "class",
1063 "return",
1064 "foreach",
1065 "match",
1066 "namespace",
1067 ] {
1068 assert!(ls.contains(expected), "missing keyword: {expected}");
1069 }
1070 }
1071
1072 #[test]
1073 fn type_keywords_present() {
1074 let kws = keyword_completions();
1075 let ls = labels(&kws);
1076 for expected in &[
1077 "bool", "float", "int", "iterable", "mixed", "never", "object", "string", "void",
1078 "parent",
1079 ] {
1080 assert!(ls.contains(expected), "missing type keyword: {expected}");
1081 }
1082 }
1083
1084 #[test]
1085 fn all_keyword_items_have_keyword_kind() {
1086 for item in keyword_completions() {
1087 assert_eq!(item.kind, Some(CompletionItemKind::KEYWORD));
1088 }
1089 }
1090
1091 #[test]
1092 fn magic_constants_all_present() {
1093 let items = magic_constant_completions();
1094 let ls: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
1095 for name in &[
1096 "__FILE__",
1097 "__DIR__",
1098 "__LINE__",
1099 "__CLASS__",
1100 "__FUNCTION__",
1101 "__METHOD__",
1102 "__NAMESPACE__",
1103 "__TRAIT__",
1104 ] {
1105 assert!(ls.contains(name), "missing magic constant: {name}");
1106 }
1107 }
1108
1109 #[test]
1110 fn magic_constants_have_constant_kind() {
1111 for item in magic_constant_completions() {
1112 assert_eq!(
1113 item.kind,
1114 Some(CompletionItemKind::CONSTANT),
1115 "{} should have CONSTANT kind",
1116 item.label
1117 );
1118 }
1119 }
1120
1121 #[test]
1122 fn resolve_attribute_class_extracts_name() {
1123 let src = "<?php\n#[Route(\n";
1124 let pos = Position {
1126 line: 1,
1127 character: 8,
1128 };
1129 let result = resolve_attribute_class(src, pos);
1130 assert_eq!(result.as_deref(), Some("Route"));
1131 }
1132
1133 #[test]
1134 fn resolve_attribute_class_fqn_extracts_short_name() {
1135 let src = "<?php\n#[\\Symfony\\Component\\Routing\\Route(\n";
1136 let pos = Position {
1137 line: 1,
1138 character: 38,
1139 };
1140 let result = resolve_attribute_class(src, pos);
1141 assert_eq!(result.as_deref(), Some("Route"));
1142 }
1143
1144 #[test]
1145 fn resolve_attribute_class_returns_none_for_regular_call() {
1146 let src = "<?php\nsomeFunction(\n";
1147 let pos = Position {
1148 line: 1,
1149 character: 14,
1150 };
1151 let result = resolve_attribute_class(src, pos);
1152 assert!(result.is_none(), "should not match regular function call");
1153 }
1154}