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_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_matching,
32 symbol_completions_matching,
33};
34
35use std::sync::Arc;
36
37use tower_lsp_server::ls_types::{
38 CompletionItem, CompletionItemKind, InsertTextFormat, Position, Range, TextEdit, Uri,
39};
40
41use crate::document::ast::ParsedDoc;
42use crate::text::{camel_sort_key, utf16_offset_to_byte};
43use crate::types::type_map::{enclosing_class_at, params_of_function, params_of_method};
44use std::collections::HashMap;
45
46fn callable_item(label: &str, kind: CompletionItemKind, has_params: bool) -> CompletionItem {
52 if has_params {
53 CompletionItem {
54 label: label.to_string(),
55 kind: Some(kind),
56 insert_text: Some(format!("{}($1)", label)),
57 insert_text_format: Some(InsertTextFormat::SNIPPET),
58 ..Default::default()
59 }
60 } else {
61 CompletionItem {
62 label: label.to_string(),
63 kind: Some(kind),
64 insert_text: Some(format!("{}()", label)),
65 ..Default::default()
66 }
67 }
68}
69
70fn named_arg_label(label: &str, params: &[php_ast::Param<'_, '_>]) -> String {
72 let mut out = format!("{label}(");
73 for (i, p) in params.iter().enumerate() {
74 if i > 0 {
75 out.push_str(", ");
76 }
77 out.push_str(&p.name.to_string());
78 out.push(':');
79 }
80 out.push(')');
81 out
82}
83
84fn named_arg_item(
88 named_label: String,
89 kind: CompletionItemKind,
90 params: &[php_ast::Param<'_, '_>],
91) -> CompletionItem {
92 let name = &named_label[..named_label.find('(').unwrap_or(named_label.len())];
93 let snippet = format!(
94 "{}({})",
95 name,
96 params
97 .iter()
98 .enumerate()
99 .map(|(i, p)| format!("{}: ${}", p.name, i + 1))
100 .collect::<Vec<_>>()
101 .join(", ")
102 );
103 CompletionItem {
104 label: named_label,
105 kind: Some(kind),
106 insert_text: Some(snippet),
107 insert_text_format: Some(InsertTextFormat::SNIPPET),
108 detail: Some("named args".to_string()),
109 ..Default::default()
110 }
111}
112
113fn resolve_attribute_class(source: &str, position: Position) -> Option<String> {
116 let line = source.lines().nth(position.line as usize)?;
117 let col = utf16_offset_to_byte(line, position.character as usize);
118 let before = line[..col].trim_end_matches('(').trim_end();
119 let hash_pos = before.rfind("#[")?;
121 let after_bracket = before[hash_pos + 2..].trim_start();
122 let name: String = after_bracket
124 .trim_start_matches('\\')
125 .rsplit('\\')
126 .next()
127 .unwrap_or("")
128 .chars()
129 .take_while(|c| c.is_alphanumeric() || *c == '_')
130 .collect();
131 if name.is_empty() { None } else { Some(name) }
132}
133
134fn resolve_call_params(
135 source: &str,
136 doc: &ParsedDoc,
137 other_docs: &[Arc<ParsedDoc>],
138 position: Position,
139 ctx: &CompletionCtx<'_>,
140) -> Vec<String> {
141 let line = match source.lines().nth(position.line as usize) {
142 Some(l) => l,
143 None => return vec![],
144 };
145 let col = utf16_offset_to_byte(line, position.character as usize);
146 let before = &line[..col];
147 let before = before.strip_suffix('(').unwrap_or(before);
148 let func_name: String = before
149 .chars()
150 .rev()
151 .take_while(|&c| c.is_alphanumeric() || c == '_')
152 .collect::<String>()
153 .chars()
154 .rev()
155 .collect();
156 if func_name.is_empty() {
157 return vec![];
158 }
159
160 let before_receiver = &before[..before.len() - func_name.len()];
166 let receiver_col = crate::text::byte_to_utf16(line, before_receiver.len());
167 let receiver_pos = Position {
168 line: position.line,
169 character: receiver_col,
170 };
171 if before_receiver.ends_with("->") || before_receiver.ends_with("?->") {
172 return resolve_receiver_class(source, doc, receiver_pos, ctx.analysis)
173 .unwrap_or_default()
174 .iter()
175 .map(|class_name| {
176 params_of_method_anywhere(class_name, &func_name, doc, other_docs, ctx)
177 })
178 .find(|params| !params.is_empty())
179 .unwrap_or_default();
180 }
181 if before_receiver.ends_with("::") {
182 let empty_imports = HashMap::new();
183 let imports = ctx.file_imports.unwrap_or(&empty_imports);
184 return resolve_static_receiver(source, doc, other_docs, receiver_pos, imports)
185 .map(|class_name| {
186 params_of_method_anywhere(&class_name, &func_name, doc, other_docs, ctx)
187 })
188 .unwrap_or_default();
189 }
190
191 let mut params = params_of_function(doc, &func_name);
192 if params.is_empty() {
193 for other in other_docs {
194 params = params_of_function(other, &func_name);
195 if !params.is_empty() {
196 break;
197 }
198 }
199 }
200 params
201}
202
203fn params_of_method_anywhere(
208 class_name: &str,
209 method_name: &str,
210 doc: &ParsedDoc,
211 other_docs: &[Arc<ParsedDoc>],
212 ctx: &CompletionCtx<'_>,
213) -> Vec<String> {
214 let params = params_of_method(doc, class_name, method_name);
215 if !params.is_empty() {
216 return params;
217 }
218 if let Some(find) = ctx.find_class_doc
219 && let Some(class_doc) = find(class_name)
220 {
221 let params = params_of_method(&class_doc, class_name, method_name);
222 if !params.is_empty() {
223 return params;
224 }
225 }
226 for other in other_docs {
227 let params = params_of_method(other, class_name, method_name);
228 if !params.is_empty() {
229 return params;
230 }
231 }
232 ctx.session
233 .and_then(|docs| mir_method_params(docs, doc, class_name, method_name))
234 .unwrap_or_default()
235}
236
237pub type ClassDocLookup<'a> = &'a dyn Fn(&str) -> Option<Arc<ParsedDoc>>;
241
242pub type WorkspaceClassSearch<'a> = &'a dyn Fn(&str) -> Vec<(String, CompletionItemKind, String)>;
248
249#[derive(Default)]
252pub struct CompletionCtx<'a> {
253 pub source: Option<&'a str>,
254 pub position: Option<Position>,
255 pub doc_uri: Option<&'a Uri>,
256 pub file_imports: Option<&'a HashMap<String, String>>,
257 pub find_class_doc: Option<ClassDocLookup<'a>>,
263 pub workspace_class_search: Option<WorkspaceClassSearch<'a>>,
268 pub analysis: Option<&'a mir_analyzer::FileAnalysis>,
272 pub session: Option<&'a crate::document::document_store::DocumentStore>,
280 pub laravel: Option<&'a crate::laravel::LaravelIndex>,
285 pub incomplete: Option<&'a std::sync::atomic::AtomicBool>,
288}
289
290const UNFILTERED_COMPLETION_LIMIT: usize = 1000;
294
295pub(crate) fn cursor_in_string_or_comment(source: &str, cursor_byte: usize) -> bool {
303 #[derive(PartialEq)]
304 enum S {
305 Normal,
306 Single,
307 Double,
308 Line,
309 Block,
310 }
311 let bytes = source.as_bytes();
312 let limit = bytes.len().min(cursor_byte);
313 let mut i = 0usize;
314 let mut state = S::Normal;
315 while i < limit {
316 match state {
317 S::Normal => match bytes[i] {
318 b'\'' => {
319 state = S::Single;
320 i += 1;
321 }
322 b'"' => {
323 state = S::Double;
324 i += 1;
325 }
326 b'/' if i + 1 < limit && bytes[i + 1] == b'/' => {
327 state = S::Line;
328 i += 2;
329 }
330 b'#' if !(i + 1 < limit && bytes[i + 1] == b'[') => {
332 state = S::Line;
333 i += 1;
334 }
335 b'/' if i + 1 < limit && bytes[i + 1] == b'*' => {
336 state = S::Block;
337 i += 2;
338 }
339 _ => {
340 i += 1;
341 }
342 },
343 S::Single => match bytes[i] {
344 b'\\' => {
345 i += 2;
346 }
347 b'\'' => {
348 state = S::Normal;
349 i += 1;
350 }
351 _ => {
352 i += 1;
353 }
354 },
355 S::Double => match bytes[i] {
356 b'\\' => {
357 i += 2;
358 }
359 b'"' => {
360 state = S::Normal;
361 i += 1;
362 }
363 _ => {
364 i += 1;
365 }
366 },
367 S::Line => {
368 if bytes[i] == b'\n' {
369 state = S::Normal;
370 }
371 i += 1;
372 }
373 S::Block => {
374 if bytes[i] == b'*' && i + 1 < limit && bytes[i + 1] == b'/' {
375 state = S::Normal;
376 i += 2;
377 } else {
378 i += 1;
379 }
380 }
381 }
382 }
383 state != S::Normal
384}
385
386pub fn filtered_completions_at(
389 doc: &ParsedDoc,
390 other_docs: &[Arc<ParsedDoc>],
391 trigger_character: Option<&str>,
392 ctx: &CompletionCtx<'_>,
393) -> Vec<CompletionItem> {
394 let source = ctx.source;
395 let position = ctx.position;
396
397 let doc_uri = ctx.doc_uri;
398
399 let laravel_completions = source
404 .zip(position)
405 .and_then(|(src, pos)| crate::laravel::completions_for_string_key(src, pos, ctx.laravel));
406
407 let blade_completions = doc_uri
412 .zip(source)
413 .zip(position)
414 .and_then(|((uri, src), pos)| {
415 crate::laravel::blade::completions(uri, src, pos, ctx.laravel)
416 });
417
418 let request_field_completions = source.zip(position).and_then(|(src, pos)| {
422 if !ctx.laravel.is_some_and(|l| l.is_laravel) {
423 return None;
424 }
425 let (receiver, prefix) =
426 crate::laravel::request_fields::method_call_string_prefix(src, pos)?;
427 let fields =
428 crate::laravel::request_fields::harvest_fields(&doc.program().stmts, &receiver);
429 Some(
430 fields
431 .into_iter()
432 .filter(|f| f.starts_with(&prefix))
433 .map(|f| CompletionItem {
434 label: f.clone(),
435 kind: Some(CompletionItemKind::FIELD),
436 insert_text: Some(f),
437 ..Default::default()
438 })
439 .collect::<Vec<_>>(),
440 )
441 });
442
443 let validation_rule_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 prefix = crate::laravel::validation_rules::validation_rule_prefix(doc, src, pos)?;
452 Some(crate::laravel::validation_rules::validation_rule_completions(&prefix))
453 });
454
455 if let (Some(src), Some(pos)) = (source, position) {
460 let cursor_byte = doc.view().byte_of_position(pos) as usize;
461 if cursor_in_string_or_comment(src, cursor_byte)
462 && include_path_prefix(src, pos).is_none()
463 && laravel_completions.is_none()
464 && blade_completions.is_none()
465 && request_field_completions.is_none()
466 && validation_rule_completions.is_none()
467 {
468 return vec![];
469 }
470 }
471 let empty_imports = HashMap::new();
472 let imports = ctx.file_imports.unwrap_or(&empty_imports);
473 let in_class_scope = matches!(
474 (source, position),
475 (Some(src), Some(pos)) if enclosing_class_at(src, doc, pos).is_some()
476 );
477
478 match trigger_character {
479 Some("$") => {
480 let mut items = superglobal_completions();
481 items.extend(symbol_completions_matching(doc, &|_, kind| {
482 kind == CompletionItemKind::VARIABLE
483 }));
484 items
485 }
486 Some(">") => {
487 if let (Some(src), Some(pos)) = (source, position)
489 && let Some(class_names) = resolve_receiver_class(src, doc, pos, ctx.analysis)
490 {
491 let mut items = Vec::new();
493 let mut seen = std::collections::HashSet::new();
494 for class_name in &class_names {
495 for item in all_instance_members(
496 class_name,
497 doc,
498 other_docs,
499 ctx.find_class_doc,
500 ctx.session,
501 in_class_scope,
502 ) {
503 if seen.insert(item.label.clone()) {
504 items.push(item);
505 }
506 }
507 }
508 if !items.is_empty() {
509 return items;
510 }
511 }
512 symbol_completions_matching(doc, &|_, kind| kind == CompletionItemKind::METHOD)
514 }
515 Some(":") => {
516 if let (Some(src), Some(pos)) = (source, position)
518 && let Some(class_name) =
519 resolve_static_receiver(src, doc, other_docs, pos, imports)
520 {
521 let items = all_static_members(
522 &class_name,
523 doc,
524 other_docs,
525 ctx.find_class_doc,
526 ctx.session,
527 in_class_scope,
528 );
529 if !items.is_empty() {
530 return items;
531 }
532 }
533 vec![]
534 }
535 Some("[") => {
536 if let (Some(src), Some(pos)) = (source, position) {
538 let line = src.lines().nth(pos.line as usize).unwrap_or("");
539 let col = utf16_offset_to_byte(line, pos.character as usize);
540 let before = &line[..col];
541 if before.trim_end_matches('[').trim_end().ends_with('#') {
542 return attribute_completions(src, pos, doc, other_docs, imports);
543 }
544 }
545 vec![]
546 }
547 Some("(") => {
548 if let (Some(src), Some(pos)) = (source, position) {
550 let params = resolve_call_params(src, doc, other_docs, pos, ctx);
551 if !params.is_empty() {
552 return params
553 .into_iter()
554 .map(|p| CompletionItem {
555 label: format!("{p}:"),
556 kind: Some(CompletionItemKind::VARIABLE),
557 ..Default::default()
558 })
559 .collect();
560 }
561 if let Some(attr_class) = resolve_attribute_class(src, pos) {
563 let mut attr_params = params_of_method(doc, &attr_class, "__construct");
564 if attr_params.is_empty() {
565 for other in other_docs {
566 attr_params = params_of_method(other, &attr_class, "__construct");
567 if !attr_params.is_empty() {
568 break;
569 }
570 }
571 }
572 if attr_params.is_empty()
573 && let Some(docs) = ctx.session
574 {
575 attr_params = mir_method_params(docs, doc, &attr_class, "__construct")
576 .unwrap_or_default();
577 }
578 if !attr_params.is_empty() {
579 return attr_params
580 .into_iter()
581 .map(|p| CompletionItem {
582 label: format!("{p}:"),
583 kind: Some(CompletionItemKind::VARIABLE),
584 detail: Some(format!("#{attr_class} argument")),
585 ..Default::default()
586 })
587 .collect();
588 }
589 }
590 }
591 vec![]
592 }
593 _ => {
594 if let (Some(src), Some(pos)) = (source, position) {
597 let line = src.lines().nth(pos.line as usize).unwrap_or("");
598 let col = utf16_offset_to_byte(line, pos.character as usize);
599 let before = &line[..col];
600 let pre_colon = before.trim_end_matches(|c: char| c.is_alphanumeric() || c == '_');
601 if pre_colon.ends_with("::") {
602 let colon_end_char = pre_colon.encode_utf16().count() as u32;
603 let colon_pos = tower_lsp_server::ls_types::Position {
604 line: pos.line,
605 character: colon_end_char,
606 };
607 if let Some(class_name) =
608 resolve_static_receiver(src, doc, other_docs, colon_pos, imports)
609 {
610 let items = all_static_members(
611 &class_name,
612 doc,
613 other_docs,
614 ctx.find_class_doc,
615 ctx.session,
616 in_class_scope,
617 );
618 if !items.is_empty() {
619 return items;
620 }
621 }
622 }
623 }
624
625 if let (Some(src), Some(pos)) = (source, position) {
629 let line = src.lines().nth(pos.line as usize).unwrap_or("");
630 let col = utf16_offset_to_byte(line, pos.character as usize);
631 let before = &line[..col];
632 let pre_arrow = before.trim_end_matches(|c: char| c.is_alphanumeric() || c == '_');
634 let has_arrow = pre_arrow.ends_with("->") || pre_arrow.ends_with("?->");
635 if has_arrow {
636 let arrow_end_char = pre_arrow.encode_utf16().count() as u32;
642 let arrow_pos = tower_lsp_server::ls_types::Position {
643 line: pos.line,
644 character: arrow_end_char,
645 };
646 if let Some(cls) = resolve_receiver_class(src, doc, arrow_pos, ctx.analysis) {
647 let mut items = Vec::new();
648 let mut seen = std::collections::HashSet::new();
649 for class_name in &cls {
650 for item in all_instance_members(
651 class_name,
652 doc,
653 other_docs,
654 ctx.find_class_doc,
655 ctx.session,
656 in_class_scope,
657 ) {
658 if seen.insert(item.label.clone()) {
659 items.push(item);
660 }
661 }
662 }
663 if !items.is_empty() {
664 let prefix = before.strip_prefix(pre_arrow).unwrap_or("").to_string();
666 if !prefix.is_empty() {
667 let fq = crate::text::FuzzyQuery::new(&prefix);
668 items.retain(|i| {
669 let match_against = if i.label.starts_with('$') {
670 i.label.strip_prefix('$').unwrap_or(&i.label)
671 } else {
672 &i.label
673 };
674 fq.camel_match(match_against)
675 });
676 for item in &mut items {
677 let match_against = if item.label.starts_with('$') {
678 item.label.strip_prefix('$').unwrap_or(&item.label)
679 } else {
680 &item.label
681 };
682 item.sort_text =
683 Some(crate::text::camel_sort_key(&prefix, match_against));
684 item.filter_text = Some(item.label.clone());
685 }
686 }
687 return items;
688 }
689 }
690 }
691 }
692
693 if let (Some(src), Some(pos)) = (source, position) {
695 let line = src.lines().nth(pos.line as usize).unwrap_or("");
696 let col = utf16_offset_to_byte(line, pos.character as usize);
697 let before = &line[..col];
698 let pre_ident =
699 before.trim_end_matches(|c: char| c.is_alphanumeric() || c == '_' || c == '\\');
700 if pre_ident.trim_end().ends_with("#[") || pre_ident.trim_end() == "#[" {
701 let items = attribute_completions(src, pos, doc, other_docs, imports);
702 if !items.is_empty() {
703 return items;
704 }
705 }
706 }
707
708 if let (Some(src), Some(pos)) = (source, position)
711 && let Some((use_kind, use_prefix)) = use_completion_prefix(src, pos)
712 {
713 let mut use_items: Vec<CompletionItem> = Vec::new();
714 for other in other_docs {
715 collect_fqns_with_prefix(
716 &other.program().stmts,
717 "",
718 &use_prefix,
719 use_kind,
720 &mut use_items,
721 );
722 }
723 collect_fqns_with_prefix(
725 &doc.program().stmts,
726 "",
727 &use_prefix,
728 use_kind,
729 &mut use_items,
730 );
731 if !use_items.is_empty() {
732 return use_items;
733 }
734 }
735
736 if let Some(items) = blade_completions {
742 return items;
743 }
744
745 if let (Some(src), Some(pos), Some(uri)) = (source, position, doc_uri)
747 && let Some(prefix) = include_path_prefix(src, pos)
748 {
749 let items = include_path_completions(uri, &prefix);
752 return items;
753 }
754
755 if let Some(items) = laravel_completions {
757 return items;
758 }
759 if let Some(items) = request_field_completions {
760 return items;
761 }
762 if let Some(items) = validation_rule_completions {
763 return items;
764 }
765
766 let other_classes_cell: std::cell::OnceCell<
771 Vec<Vec<(String, CompletionItemKind, String)>>,
772 > = std::cell::OnceCell::new();
773 let other_classes = || {
774 other_classes_cell.get_or_init(|| {
775 other_docs
776 .iter()
777 .map(|other| {
778 let mut classes = Vec::new();
779 collect_classes_with_ns(&other.program().stmts, "", &mut classes);
780 classes
781 })
782 .collect()
783 })
784 };
785
786 if let (Some(src), Some(pos)) = (source, position)
788 && let Some(prefix) = typed_prefix(Some(src), Some(pos))
789 && prefix.contains('\\')
790 {
791 let is_use = use_completion_prefix(src, pos).is_some();
793 if !is_use {
794 let prefix_lc = prefix.trim_start_matches('\\').to_lowercase();
795 let mut ns_items: Vec<CompletionItem> = Vec::new();
796 for classes in other_classes() {
797 for (label, kind, fqn) in classes {
798 if fqn
799 .get(..prefix_lc.len())
800 .is_some_and(|s| s.eq_ignore_ascii_case(&prefix_lc))
801 {
802 ns_items.push(CompletionItem {
803 label: label.clone(),
804 kind: Some(*kind),
805 insert_text: Some(label.clone()),
806 detail: Some(fqn.clone()),
807 ..Default::default()
808 });
809 }
810 }
811 }
812 let mut classes = Vec::new();
813 collect_classes_with_ns(&doc.program().stmts, "", &mut classes);
814 for (label, kind, fqn) in classes {
815 if fqn
816 .get(..prefix_lc.len())
817 .is_some_and(|s| s.eq_ignore_ascii_case(&prefix_lc))
818 {
819 ns_items.push(CompletionItem {
820 label: label.clone(),
821 kind: Some(kind),
822 insert_text: Some(label),
823 detail: Some(fqn),
824 ..Default::default()
825 });
826 }
827 }
828 if !ns_items.is_empty() {
829 return ns_items;
830 }
831 }
832 }
833
834 if let (Some(src), Some(pos)) = (source, position)
836 && let Some(match_items) = match_arm_completions(
837 src,
838 doc,
839 other_docs,
840 pos,
841 ctx.analysis,
842 ctx.find_class_doc,
843 )
844 && !match_items.is_empty()
845 {
846 let mut all = match_items;
847 let mut normal_items = keyword_completions();
849 normal_items.extend(magic_constant_completions());
850 normal_items.extend(builtin_completions());
851 normal_items.extend(superglobal_completions());
852 normal_items.extend(symbol_completions(doc));
853 all.extend(normal_items);
854
855 let mut seen = std::collections::HashSet::new();
857 all.retain(|i| seen.insert(i.label.clone()));
858
859 return all;
860 }
861
862 let mut magic_items: Vec<CompletionItem> = Vec::new();
864 if let (Some(src), Some(pos)) = (source, position)
865 && enclosing_class_at(src, doc, pos).is_some()
866 {
867 magic_items.extend(magic_method_completions());
868 }
869
870 let prefix = typed_prefix(source, position).unwrap_or_default();
876 let ident_query = (!prefix.is_empty() && !prefix.contains('\\'))
877 .then(|| crate::text::FuzzyQuery::new(&prefix));
878 let keep_static =
879 |label: &str| ident_query.as_ref().is_none_or(|fq| fq.camel_match(label));
880
881 let mut items = keyword_completions_matching(&keep_static);
882 items.extend(magic_constant_completions_matching(&keep_static));
883 items.extend(builtin_completions_matching(&keep_static));
884 items.extend(superglobal_completions_matching(&keep_static));
885 let keep_symbol = |label: &str, _| keep_static(label);
887 let sym_items = if let (Some(_src), Some(pos)) = (source, position) {
888 symbol_completions_before_matching(doc, pos.line, &keep_symbol)
889 } else {
890 symbol_completions_matching(doc, &keep_symbol)
891 };
892 items.extend(sym_items);
893 items.extend(magic_items);
894
895 let at = position.unwrap_or_default();
896 let cur_ns = current_namespace(doc, at);
897
898 let use_pos = source.map(|_| use_insert_position(doc, at));
903
904 let push_class_item = |items: &mut Vec<CompletionItem>,
908 label: &str,
909 kind: CompletionItemKind,
910 fqn: &str| {
911 let mut insert_text = None;
912 let mut additional_text_edits = None;
913 if let Some(pos) = use_pos {
914 let in_same_ns = !cur_ns.is_empty() && fqn == format!("{}\\{}", cur_ns, label);
915 let is_global = !fqn.contains('\\');
916 let imported_as = imports
917 .iter()
918 .find(|(alias, _)| alias.eq_ignore_ascii_case(label))
919 .map(|(_, target)| target.trim_start_matches('\\'));
920 match imported_as {
921 Some(target) if target.eq_ignore_ascii_case(fqn) => {}
922 Some(_) => insert_text = Some(format!("\\{fqn}")),
923 None if in_same_ns || (is_global && cur_ns.is_empty()) => {}
924 None if is_global => insert_text = Some(format!("\\{fqn}")),
925 None => {
926 additional_text_edits = Some(vec![TextEdit {
927 range: Range {
928 start: pos,
929 end: pos,
930 },
931 new_text: format!("use {};\n", fqn),
932 }])
933 }
934 }
935 }
936 items.push(CompletionItem {
937 label: label.to_string(),
938 kind: Some(kind),
939 detail: if fqn.contains('\\') {
940 Some(fqn.to_string())
941 } else {
942 None
943 },
944 insert_text,
945 additional_text_edits,
946 ..Default::default()
947 });
948 };
949
950 let mark_incomplete = || {
951 if let Some(flag) = ctx.incomplete {
952 flag.store(true, std::sync::atomic::Ordering::Relaxed);
953 }
954 };
955 for (other, classes) in other_docs.iter().zip(other_classes()) {
956 if prefix.is_empty() && items.len() > UNFILTERED_COMPLETION_LIMIT {
959 mark_incomplete();
960 break;
961 }
962 for (label, kind, fqn) in classes {
964 push_class_item(&mut items, label, *kind, fqn);
965 }
966 items.extend(symbol_completions_matching(other, &|label, kind| {
968 !matches!(
969 kind,
970 CompletionItemKind::CLASS
971 | CompletionItemKind::INTERFACE
972 | CompletionItemKind::ENUM
973 | CompletionItemKind::VARIABLE
974 ) && keep_static(label)
975 }));
976 }
977
978 if !prefix.is_empty()
983 && !prefix.contains('\\')
984 && let Some(search) = ctx.workspace_class_search
985 {
986 for (label, kind, fqn) in search(&prefix) {
987 push_class_item(&mut items, &label, kind, &fqn);
988 }
989 }
990
991 let mut seen = std::collections::HashSet::new();
994 items.retain(|i| {
995 let qualified = i.insert_text.as_ref().filter(|t| t.starts_with('\\'));
996 seen.insert((i.label.clone(), qualified.cloned()))
997 });
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 } else if items.len() > UNFILTERED_COMPLETION_LIMIT {
1015 items.truncate(UNFILTERED_COMPLETION_LIMIT);
1018 mark_incomplete();
1019 }
1020 items
1021 }
1022 }
1023}
1024
1025#[cfg(test)]
1026mod tests {
1027 use super::*;
1028
1029 #[test]
1030 fn method_params_ignore_same_short_name_in_other_namespace() {
1031 let doc = ParsedDoc::parse("<?php".to_string());
1032 let others = [
1033 Arc::new(ParsedDoc::parse(
1034 "<?php\nnamespace Other;\nclass Svc { public function run(int $wrong) {} }"
1035 .to_string(),
1036 )),
1037 Arc::new(ParsedDoc::parse(
1038 "<?php\nnamespace App;\nclass Svc { public function run(int $right) {} }"
1039 .to_string(),
1040 )),
1041 ];
1042 let params =
1043 params_of_method_anywhere("App\\Svc", "run", &doc, &others, &CompletionCtx::default());
1044 assert_eq!(params, ["right"]);
1045 }
1046
1047 fn labels(items: &[CompletionItem]) -> Vec<&str> {
1048 items.iter().map(|i| i.label.as_str()).collect()
1049 }
1050
1051 #[test]
1052 fn keywords_list_is_non_empty() {
1053 let kws = keyword_completions();
1054 assert!(
1055 kws.len() >= 20,
1056 "expected at least 20 keywords, got {}",
1057 kws.len()
1058 );
1059 }
1060
1061 #[test]
1062 fn keywords_contain_common_php_keywords() {
1063 let kws = keyword_completions();
1064 let ls = labels(&kws);
1065 for expected in &[
1066 "function",
1067 "class",
1068 "return",
1069 "foreach",
1070 "match",
1071 "namespace",
1072 ] {
1073 assert!(ls.contains(expected), "missing keyword: {expected}");
1074 }
1075 }
1076
1077 #[test]
1078 fn type_keywords_present() {
1079 let kws = keyword_completions();
1080 let ls = labels(&kws);
1081 for expected in &[
1082 "bool", "float", "int", "iterable", "mixed", "never", "object", "string", "void",
1083 "parent",
1084 ] {
1085 assert!(ls.contains(expected), "missing type keyword: {expected}");
1086 }
1087 }
1088
1089 #[test]
1090 fn all_keyword_items_have_keyword_kind() {
1091 for item in keyword_completions() {
1092 assert_eq!(item.kind, Some(CompletionItemKind::KEYWORD));
1093 }
1094 }
1095
1096 #[test]
1097 fn magic_constants_all_present() {
1098 let items = magic_constant_completions();
1099 let ls: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
1100 for name in &[
1101 "__FILE__",
1102 "__DIR__",
1103 "__LINE__",
1104 "__CLASS__",
1105 "__FUNCTION__",
1106 "__METHOD__",
1107 "__NAMESPACE__",
1108 "__TRAIT__",
1109 ] {
1110 assert!(ls.contains(name), "missing magic constant: {name}");
1111 }
1112 }
1113
1114 #[test]
1115 fn magic_constants_have_constant_kind() {
1116 for item in magic_constant_completions() {
1117 assert_eq!(
1118 item.kind,
1119 Some(CompletionItemKind::CONSTANT),
1120 "{} should have CONSTANT kind",
1121 item.label
1122 );
1123 }
1124 }
1125
1126 #[test]
1127 fn resolve_attribute_class_extracts_name() {
1128 let src = "<?php\n#[Route(\n";
1129 let pos = Position {
1131 line: 1,
1132 character: 8,
1133 };
1134 let result = resolve_attribute_class(src, pos);
1135 assert_eq!(result.as_deref(), Some("Route"));
1136 }
1137
1138 #[test]
1139 fn resolve_attribute_class_fqn_extracts_short_name() {
1140 let src = "<?php\n#[\\Symfony\\Component\\Routing\\Route(\n";
1141 let pos = Position {
1142 line: 1,
1143 character: 38,
1144 };
1145 let result = resolve_attribute_class(src, pos);
1146 assert_eq!(result.as_deref(), Some("Route"));
1147 }
1148
1149 #[test]
1150 fn resolve_attribute_class_returns_none_for_regular_call() {
1151 let src = "<?php\nsomeFunction(\n";
1152 let pos = Position {
1153 line: 1,
1154 character: 14,
1155 };
1156 let result = resolve_attribute_class(src, pos);
1157 assert!(result.is_none(), "should not match regular function call");
1158 }
1159}