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<std::sync::Arc<mir_analyzer::AnalysisSession>>,
306 pub laravel: Option<&'a crate::laravel::LaravelIndex>,
311}
312
313pub(crate) fn cursor_in_string_or_comment(source: &str, cursor_byte: usize) -> bool {
321 #[derive(PartialEq)]
322 enum S {
323 Normal,
324 Single,
325 Double,
326 Line,
327 Block,
328 }
329 let bytes = source.as_bytes();
330 let limit = bytes.len().min(cursor_byte);
331 let mut i = 0usize;
332 let mut state = S::Normal;
333 while i < limit {
334 match state {
335 S::Normal => match bytes[i] {
336 b'\'' => {
337 state = S::Single;
338 i += 1;
339 }
340 b'"' => {
341 state = S::Double;
342 i += 1;
343 }
344 b'/' if i + 1 < limit && bytes[i + 1] == b'/' => {
345 state = S::Line;
346 i += 2;
347 }
348 b'#' if !(i + 1 < limit && bytes[i + 1] == b'[') => {
350 state = S::Line;
351 i += 1;
352 }
353 b'/' if i + 1 < limit && bytes[i + 1] == b'*' => {
354 state = S::Block;
355 i += 2;
356 }
357 _ => {
358 i += 1;
359 }
360 },
361 S::Single => match bytes[i] {
362 b'\\' => {
363 i += 2;
364 }
365 b'\'' => {
366 state = S::Normal;
367 i += 1;
368 }
369 _ => {
370 i += 1;
371 }
372 },
373 S::Double => match bytes[i] {
374 b'\\' => {
375 i += 2;
376 }
377 b'"' => {
378 state = S::Normal;
379 i += 1;
380 }
381 _ => {
382 i += 1;
383 }
384 },
385 S::Line => {
386 if bytes[i] == b'\n' {
387 state = S::Normal;
388 }
389 i += 1;
390 }
391 S::Block => {
392 if bytes[i] == b'*' && i + 1 < limit && bytes[i + 1] == b'/' {
393 state = S::Normal;
394 i += 2;
395 } else {
396 i += 1;
397 }
398 }
399 }
400 }
401 state != S::Normal
402}
403
404pub fn filtered_completions_at(
407 doc: &ParsedDoc,
408 other_docs: &[Arc<ParsedDoc>],
409 trigger_character: Option<&str>,
410 ctx: &CompletionCtx<'_>,
411) -> Vec<CompletionItem> {
412 let source = ctx.source;
413 let position = ctx.position;
414
415 let doc_uri = ctx.doc_uri;
416
417 let laravel_completions = source
422 .zip(position)
423 .and_then(|(src, pos)| crate::laravel::completions_for_string_key(src, pos, ctx.laravel));
424
425 let blade_completions = doc_uri
430 .zip(source)
431 .zip(position)
432 .and_then(|((uri, src), pos)| {
433 crate::laravel::blade::completions(uri, src, pos, ctx.laravel)
434 });
435
436 let request_field_completions = source.zip(position).and_then(|(src, pos)| {
440 if !ctx.laravel.is_some_and(|l| l.is_laravel) {
441 return None;
442 }
443 let (receiver, prefix) =
444 crate::laravel::request_fields::method_call_string_prefix(src, pos)?;
445 let fields =
446 crate::laravel::request_fields::harvest_fields(&doc.program().stmts, &receiver);
447 Some(
448 fields
449 .into_iter()
450 .filter(|f| f.starts_with(&prefix))
451 .map(|f| CompletionItem {
452 label: f.clone(),
453 kind: Some(CompletionItemKind::FIELD),
454 insert_text: Some(f),
455 ..Default::default()
456 })
457 .collect::<Vec<_>>(),
458 )
459 });
460
461 let validation_rule_completions = source.zip(position).and_then(|(src, pos)| {
466 if !ctx.laravel.is_some_and(|l| l.is_laravel) {
467 return None;
468 }
469 let prefix = crate::laravel::validation_rules::validation_rule_prefix(doc, src, pos)?;
470 Some(crate::laravel::validation_rules::validation_rule_completions(&prefix))
471 });
472
473 if let (Some(src), Some(pos)) = (source, position) {
478 let cursor_byte = doc.view().byte_of_position(pos) as usize;
479 if cursor_in_string_or_comment(src, cursor_byte)
480 && include_path_prefix(src, pos).is_none()
481 && laravel_completions.is_none()
482 && blade_completions.is_none()
483 && request_field_completions.is_none()
484 && validation_rule_completions.is_none()
485 {
486 return vec![];
487 }
488 }
489 let empty_imports = HashMap::new();
490 let imports = ctx.file_imports.unwrap_or(&empty_imports);
491
492 match trigger_character {
493 Some("$") => {
494 let mut items = superglobal_completions();
495 items.extend(
496 symbol_completions(doc)
497 .into_iter()
498 .filter(|i| i.kind == Some(CompletionItemKind::VARIABLE)),
499 );
500 items
501 }
502 Some(">") => {
503 if let (Some(src), Some(pos)) = (source, position)
505 && let Some(class_names) = resolve_receiver_class(src, doc, pos, ctx.analysis)
506 {
507 let mut items = Vec::new();
509 let mut seen = std::collections::HashSet::new();
510 for class_name in class_names.split('|') {
511 let class_name = class_name.trim();
512 for item in all_instance_members(
513 class_name,
514 doc,
515 other_docs,
516 ctx.find_class_doc,
517 ctx.session.as_deref(),
518 ) {
519 if seen.insert(item.label.clone()) {
520 items.push(item);
521 }
522 }
523 }
524 if !items.is_empty() {
525 return items;
526 }
527 }
528 symbol_completions(doc)
530 .into_iter()
531 .filter(|i| i.kind == Some(CompletionItemKind::METHOD))
532 .collect()
533 }
534 Some(":") => {
535 if let (Some(src), Some(pos)) = (source, position)
537 && let Some(class_name) =
538 resolve_static_receiver(src, doc, other_docs, pos, imports)
539 {
540 let items = all_static_members(
541 &class_name,
542 doc,
543 other_docs,
544 ctx.find_class_doc,
545 ctx.session.as_deref(),
546 );
547 if !items.is_empty() {
548 return items;
549 }
550 }
551 vec![]
552 }
553 Some("[") => {
554 if let (Some(src), Some(pos)) = (source, position) {
556 let line = src.lines().nth(pos.line as usize).unwrap_or("");
557 let col = utf16_offset_to_byte(line, pos.character as usize);
558 let before = &line[..col];
559 if before.trim_end_matches('[').trim_end().ends_with('#') {
560 return attribute_completions(src, pos, doc, other_docs, imports);
561 }
562 }
563 vec![]
564 }
565 Some("(") => {
566 if let (Some(src), Some(pos)) = (source, position) {
568 let params = resolve_call_params(src, doc, other_docs, pos, ctx);
569 if !params.is_empty() {
570 return params
571 .into_iter()
572 .map(|p| CompletionItem {
573 label: format!("{p}:"),
574 kind: Some(CompletionItemKind::VARIABLE),
575 ..Default::default()
576 })
577 .collect();
578 }
579 if let Some(attr_class) = resolve_attribute_class(src, pos) {
581 let mut attr_params = params_of_method(doc, &attr_class, "__construct");
582 if attr_params.is_empty() {
583 for other in other_docs {
584 attr_params = params_of_method(other, &attr_class, "__construct");
585 if !attr_params.is_empty() {
586 break;
587 }
588 }
589 }
590 if !attr_params.is_empty() {
591 return attr_params
592 .into_iter()
593 .map(|p| CompletionItem {
594 label: format!("{p}:"),
595 kind: Some(CompletionItemKind::VARIABLE),
596 detail: Some(format!("#{attr_class} argument")),
597 ..Default::default()
598 })
599 .collect();
600 }
601 }
602 }
603 vec![]
604 }
605 _ => {
606 if let (Some(src), Some(pos)) = (source, position) {
609 let line = src.lines().nth(pos.line as usize).unwrap_or("");
610 let col = utf16_offset_to_byte(line, pos.character as usize);
611 let before = &line[..col];
612 let pre_colon = before.trim_end_matches(|c: char| c.is_alphanumeric() || c == '_');
613 if pre_colon.ends_with("::") {
614 let colon_end_char = pre_colon.encode_utf16().count() as u32;
615 let colon_pos = tower_lsp_server::ls_types::Position {
616 line: pos.line,
617 character: colon_end_char,
618 };
619 if let Some(class_name) =
620 resolve_static_receiver(src, doc, other_docs, colon_pos, imports)
621 {
622 let items = all_static_members(
623 &class_name,
624 doc,
625 other_docs,
626 ctx.find_class_doc,
627 ctx.session.as_deref(),
628 );
629 if !items.is_empty() {
630 return items;
631 }
632 }
633 }
634 }
635
636 if let (Some(src), Some(pos)) = (source, position) {
640 let line = src.lines().nth(pos.line as usize).unwrap_or("");
641 let col = utf16_offset_to_byte(line, pos.character as usize);
642 let before = &line[..col];
643 let pre_arrow = before.trim_end_matches(|c: char| c.is_alphanumeric() || c == '_');
645 let has_arrow = pre_arrow.ends_with("->") || pre_arrow.ends_with("?->");
646 if has_arrow {
647 let arrow_end_char = pre_arrow.encode_utf16().count() as u32;
653 let arrow_pos = tower_lsp_server::ls_types::Position {
654 line: pos.line,
655 character: arrow_end_char,
656 };
657 if let Some(cls) = resolve_receiver_class(src, doc, arrow_pos, ctx.analysis) {
658 let mut items = Vec::new();
659 let mut seen = std::collections::HashSet::new();
660 for class_name in cls.split('|') {
661 for item in all_instance_members(
662 class_name.trim(),
663 doc,
664 other_docs,
665 ctx.find_class_doc,
666 ctx.session.as_deref(),
667 ) {
668 if seen.insert(item.label.clone()) {
669 items.push(item);
670 }
671 }
672 }
673 if !items.is_empty() {
674 let prefix = before.strip_prefix(pre_arrow).unwrap_or("").to_string();
676 if !prefix.is_empty() {
677 let fq = crate::text::FuzzyQuery::new(&prefix);
678 items.retain(|i| {
679 let match_against = if i.label.starts_with('$') {
680 i.label.strip_prefix('$').unwrap_or(&i.label)
681 } else {
682 &i.label
683 };
684 fq.camel_match(match_against)
685 });
686 for item in &mut items {
687 let match_against = if item.label.starts_with('$') {
688 item.label.strip_prefix('$').unwrap_or(&item.label)
689 } else {
690 &item.label
691 };
692 item.sort_text =
693 Some(crate::text::camel_sort_key(&prefix, match_against));
694 item.filter_text = Some(item.label.clone());
695 }
696 }
697 return items;
698 }
699 }
700 }
701 }
702
703 if let (Some(src), Some(pos)) = (source, position) {
705 let line = src.lines().nth(pos.line as usize).unwrap_or("");
706 let col = utf16_offset_to_byte(line, pos.character as usize);
707 let before = &line[..col];
708 let pre_ident =
709 before.trim_end_matches(|c: char| c.is_alphanumeric() || c == '_' || c == '\\');
710 if pre_ident.trim_end().ends_with("#[") || pre_ident.trim_end() == "#[" {
711 let items = attribute_completions(src, pos, doc, other_docs, imports);
712 if !items.is_empty() {
713 return items;
714 }
715 }
716 }
717
718 if let (Some(src), Some(pos)) = (source, position)
721 && let Some((use_kind, use_prefix)) = use_completion_prefix(src, pos)
722 {
723 let mut use_items: Vec<CompletionItem> = Vec::new();
724 for other in other_docs {
725 collect_fqns_with_prefix(
726 &other.program().stmts,
727 "",
728 &use_prefix,
729 use_kind,
730 &mut use_items,
731 );
732 }
733 collect_fqns_with_prefix(
735 &doc.program().stmts,
736 "",
737 &use_prefix,
738 use_kind,
739 &mut use_items,
740 );
741 if !use_items.is_empty() {
742 return use_items;
743 }
744 }
745
746 if let Some(items) = blade_completions {
752 return items;
753 }
754
755 if let (Some(src), Some(pos), Some(uri)) = (source, position, doc_uri)
757 && let Some(prefix) = include_path_prefix(src, pos)
758 {
759 let items = include_path_completions(uri, &prefix);
762 return items;
763 }
764
765 if let Some(items) = laravel_completions {
767 return items;
768 }
769 if let Some(items) = request_field_completions {
770 return items;
771 }
772 if let Some(items) = validation_rule_completions {
773 return items;
774 }
775
776 let other_classes_cell: std::cell::OnceCell<
781 Vec<Vec<(String, CompletionItemKind, String)>>,
782 > = std::cell::OnceCell::new();
783 let other_classes = || {
784 other_classes_cell.get_or_init(|| {
785 other_docs
786 .iter()
787 .map(|other| {
788 let mut classes = Vec::new();
789 collect_classes_with_ns(&other.program().stmts, "", &mut classes);
790 classes
791 })
792 .collect()
793 })
794 };
795
796 if let (Some(src), Some(pos)) = (source, position)
798 && let Some(prefix) = typed_prefix(Some(src), Some(pos))
799 && prefix.contains('\\')
800 {
801 let is_use = use_completion_prefix(src, pos).is_some();
803 if !is_use {
804 let prefix_lc = prefix.trim_start_matches('\\').to_lowercase();
805 let mut ns_items: Vec<CompletionItem> = Vec::new();
806 for classes in other_classes() {
807 for (label, kind, fqn) in classes {
808 if fqn
809 .get(..prefix_lc.len())
810 .is_some_and(|s| s.eq_ignore_ascii_case(&prefix_lc))
811 {
812 ns_items.push(CompletionItem {
813 label: label.clone(),
814 kind: Some(*kind),
815 insert_text: Some(label.clone()),
816 detail: Some(fqn.clone()),
817 ..Default::default()
818 });
819 }
820 }
821 }
822 let mut classes = Vec::new();
823 collect_classes_with_ns(&doc.program().stmts, "", &mut classes);
824 for (label, kind, fqn) in classes {
825 if fqn
826 .get(..prefix_lc.len())
827 .is_some_and(|s| s.eq_ignore_ascii_case(&prefix_lc))
828 {
829 ns_items.push(CompletionItem {
830 label: label.clone(),
831 kind: Some(kind),
832 insert_text: Some(label),
833 detail: Some(fqn),
834 ..Default::default()
835 });
836 }
837 }
838 if !ns_items.is_empty() {
839 return ns_items;
840 }
841 }
842 }
843
844 if let (Some(src), Some(pos)) = (source, position)
846 && let Some(match_items) = match_arm_completions(
847 src,
848 doc,
849 other_docs,
850 pos,
851 ctx.analysis,
852 ctx.find_class_doc,
853 )
854 && !match_items.is_empty()
855 {
856 let mut all = match_items;
857 let mut normal_items = keyword_completions();
859 normal_items.extend(magic_constant_completions());
860 normal_items.extend(builtin_completions());
861 normal_items.extend(superglobal_completions());
862 normal_items.extend(symbol_completions(doc));
863 all.extend(normal_items);
864
865 let mut seen = std::collections::HashSet::new();
867 all.retain(|i| seen.insert(i.label.clone()));
868
869 return all;
870 }
871
872 let mut magic_items: Vec<CompletionItem> = Vec::new();
874 if let (Some(src), Some(pos)) = (source, position)
875 && enclosing_class_at(src, doc, pos).is_some()
876 {
877 magic_items.extend(magic_method_completions());
878 }
879
880 let prefix = typed_prefix(source, position).unwrap_or_default();
886 let ident_query = (!prefix.is_empty() && !prefix.contains('\\'))
887 .then(|| crate::text::FuzzyQuery::new(&prefix));
888 let keep_static =
889 |label: &str| ident_query.as_ref().is_none_or(|fq| fq.camel_match(label));
890
891 let mut items = keyword_completions_matching(&keep_static);
892 items.extend(magic_constant_completions_matching(&keep_static));
893 items.extend(builtin_completions_matching(&keep_static));
894 items.extend(superglobal_completions_matching(&keep_static));
895 let sym_items = if let (Some(_src), Some(pos)) = (source, position) {
897 symbol_completions_before(doc, pos.line)
898 } else {
899 symbol_completions(doc)
900 };
901 items.extend(sym_items);
902 items.extend(magic_items);
903
904 let cur_ns = current_file_namespace(&doc.program().stmts);
905
906 let use_pos = source.map(use_insert_position);
911
912 let push_class_item = |items: &mut Vec<CompletionItem>,
916 label: &str,
917 kind: CompletionItemKind,
918 fqn: &str| {
919 let additional_text_edits = if let Some(pos) = use_pos {
920 let in_same_ns = !cur_ns.is_empty() && fqn == format!("{}\\{}", cur_ns, label);
921 let is_global = !fqn.contains('\\');
922 let already = imports.contains_key(label);
923 if !in_same_ns && !is_global && !already {
924 Some(vec![TextEdit {
925 range: Range {
926 start: pos,
927 end: pos,
928 },
929 new_text: format!("use {};\n", fqn),
930 }])
931 } else {
932 None
933 }
934 } else {
935 None
936 };
937 items.push(CompletionItem {
938 label: label.to_string(),
939 kind: Some(kind),
940 detail: if fqn.contains('\\') {
941 Some(fqn.to_string())
942 } else {
943 None
944 },
945 additional_text_edits,
946 ..Default::default()
947 });
948 };
949
950 for (other, classes) in other_docs.iter().zip(other_classes()) {
951 for (label, kind, fqn) in classes {
953 push_class_item(&mut items, label, *kind, fqn);
954 }
955 let cross: Vec<CompletionItem> = symbol_completions(other)
957 .into_iter()
958 .filter(|i| {
959 !matches!(
960 i.kind,
961 Some(CompletionItemKind::CLASS)
962 | Some(CompletionItemKind::INTERFACE)
963 | Some(CompletionItemKind::ENUM)
964 ) && i.kind != Some(CompletionItemKind::VARIABLE)
965 })
966 .collect();
967 items.extend(cross);
968 }
969
970 if !prefix.is_empty()
975 && !prefix.contains('\\')
976 && let Some(search) = ctx.workspace_class_search
977 {
978 for (label, kind, fqn) in search(&prefix) {
979 push_class_item(&mut items, &label, kind, &fqn);
980 }
981 }
982
983 let mut seen = std::collections::HashSet::new();
984 items.retain(|i| seen.insert(i.label.clone()));
985
986 if prefix.contains('\\') {
987 let ns_prefix = prefix.trim_start_matches('\\').to_lowercase();
989 items.retain(|i| {
990 let fqn = i.detail.as_deref().unwrap_or(&i.label);
991 fqn.get(..ns_prefix.len())
992 .is_some_and(|s| s.eq_ignore_ascii_case(&ns_prefix))
993 });
994 } else if !prefix.is_empty() {
995 let fq = crate::text::FuzzyQuery::new(&prefix);
996 items.retain(|i| fq.camel_match(&i.label));
997 for item in &mut items {
998 item.sort_text = Some(camel_sort_key(&prefix, &item.label));
999 item.filter_text = Some(item.label.clone());
1000 }
1001 }
1002 items
1003 }
1004 }
1005}
1006
1007#[cfg(test)]
1008mod tests {
1009 use super::*;
1010
1011 fn labels(items: &[CompletionItem]) -> Vec<&str> {
1012 items.iter().map(|i| i.label.as_str()).collect()
1013 }
1014
1015 #[test]
1016 fn keywords_list_is_non_empty() {
1017 let kws = keyword_completions();
1018 assert!(
1019 kws.len() >= 20,
1020 "expected at least 20 keywords, got {}",
1021 kws.len()
1022 );
1023 }
1024
1025 #[test]
1026 fn keywords_contain_common_php_keywords() {
1027 let kws = keyword_completions();
1028 let ls = labels(&kws);
1029 for expected in &[
1030 "function",
1031 "class",
1032 "return",
1033 "foreach",
1034 "match",
1035 "namespace",
1036 ] {
1037 assert!(ls.contains(expected), "missing keyword: {expected}");
1038 }
1039 }
1040
1041 #[test]
1042 fn type_keywords_present() {
1043 let kws = keyword_completions();
1044 let ls = labels(&kws);
1045 for expected in &[
1046 "bool", "float", "int", "iterable", "mixed", "never", "object", "string", "void",
1047 "parent",
1048 ] {
1049 assert!(ls.contains(expected), "missing type keyword: {expected}");
1050 }
1051 }
1052
1053 #[test]
1054 fn all_keyword_items_have_keyword_kind() {
1055 for item in keyword_completions() {
1056 assert_eq!(item.kind, Some(CompletionItemKind::KEYWORD));
1057 }
1058 }
1059
1060 #[test]
1061 fn magic_constants_all_present() {
1062 let items = magic_constant_completions();
1063 let ls: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
1064 for name in &[
1065 "__FILE__",
1066 "__DIR__",
1067 "__LINE__",
1068 "__CLASS__",
1069 "__FUNCTION__",
1070 "__METHOD__",
1071 "__NAMESPACE__",
1072 "__TRAIT__",
1073 ] {
1074 assert!(ls.contains(name), "missing magic constant: {name}");
1075 }
1076 }
1077
1078 #[test]
1079 fn magic_constants_have_constant_kind() {
1080 for item in magic_constant_completions() {
1081 assert_eq!(
1082 item.kind,
1083 Some(CompletionItemKind::CONSTANT),
1084 "{} should have CONSTANT kind",
1085 item.label
1086 );
1087 }
1088 }
1089
1090 #[test]
1091 fn resolve_attribute_class_extracts_name() {
1092 let src = "<?php\n#[Route(\n";
1093 let pos = Position {
1095 line: 1,
1096 character: 8,
1097 };
1098 let result = resolve_attribute_class(src, pos);
1099 assert_eq!(result.as_deref(), Some("Route"));
1100 }
1101
1102 #[test]
1103 fn resolve_attribute_class_fqn_extracts_short_name() {
1104 let src = "<?php\n#[\\Symfony\\Component\\Routing\\Route(\n";
1105 let pos = Position {
1106 line: 1,
1107 character: 38,
1108 };
1109 let result = resolve_attribute_class(src, pos);
1110 assert_eq!(result.as_deref(), Some("Route"));
1111 }
1112
1113 #[test]
1114 fn resolve_attribute_class_returns_none_for_regular_call() {
1115 let src = "<?php\nsomeFunction(\n";
1116 let pos = Position {
1117 line: 1,
1118 character: 14,
1119 };
1120 let result = resolve_attribute_class(src, pos);
1121 assert!(result.is_none(), "should not match regular function call");
1122 }
1123}