1mod keyword;
2pub use keyword::{keyword_completions, magic_constant_completions};
3
4mod symbols;
5pub use symbols::{
6 builtin_completions, superglobal_completions, symbol_completions, symbol_completions_before,
7};
8
9mod member;
10use member::{
11 all_instance_members, all_static_members, line_byte_offset, magic_method_completions,
12 receiver_class_at, resolve_receiver_class, resolve_static_receiver,
13};
14
15mod namespace;
16use namespace::{
17 collect_attribute_classes, collect_classes_with_ns, collect_fqns_with_prefix,
18 current_file_namespace, infer_attribute_target, typed_prefix, use_completion_prefix,
19 use_insert_position,
20};
21
22use std::sync::Arc;
23
24use tower_lsp::lsp_types::{
25 CompletionItem, CompletionItemKind, InsertTextFormat, Position, Range, TextEdit, Url,
26};
27
28use tower_lsp::lsp_types::{Documentation, MarkupContent, MarkupKind};
29
30use crate::ast::{ParsedDoc, format_type_hint};
31use crate::docblock::find_docblock;
32use crate::hover::format_params_str;
33use crate::phpstorm_meta::PhpStormMeta;
34use crate::type_map::{
35 TypeMap, enclosing_class_at, members_of_class, params_of_function, params_of_method,
36};
37use crate::util::{camel_sort_key, utf16_offset_to_byte};
38use std::collections::HashMap;
39
40fn callable_item(label: &str, kind: CompletionItemKind, has_params: bool) -> CompletionItem {
46 if has_params {
47 CompletionItem {
48 label: label.to_string(),
49 kind: Some(kind),
50 insert_text: Some(format!("{}($1)", label)),
51 insert_text_format: Some(InsertTextFormat::SNIPPET),
52 ..Default::default()
53 }
54 } else {
55 CompletionItem {
56 label: label.to_string(),
57 kind: Some(kind),
58 insert_text: Some(format!("{}()", label)),
59 ..Default::default()
60 }
61 }
62}
63
64fn named_arg_item(
69 label: &str,
70 kind: CompletionItemKind,
71 params: &[php_ast::Param<'_, '_>],
72) -> Option<CompletionItem> {
73 if params.is_empty() {
74 return None;
75 }
76 let named_label = format!(
77 "{}({})",
78 label,
79 params
80 .iter()
81 .map(|p| format!("{}:", &p.name.to_string()))
82 .collect::<Vec<_>>()
83 .join(", ")
84 );
85 let snippet = format!(
86 "{}({})",
87 label,
88 params
89 .iter()
90 .enumerate()
91 .map(|(i, p)| format!("{}: ${}", p.name, i + 1))
92 .collect::<Vec<_>>()
93 .join(", ")
94 );
95 Some(CompletionItem {
96 label: named_label,
97 kind: Some(kind),
98 insert_text: Some(snippet),
99 insert_text_format: Some(InsertTextFormat::SNIPPET),
100 detail: Some("named args".to_string()),
101 ..Default::default()
102 })
103}
104
105fn build_function_sig(
108 name: &str,
109 params: &[php_ast::Param<'_, '_>],
110 return_type: Option<&php_ast::TypeHint<'_, '_>>,
111) -> String {
112 let params_str = format_params_str(params);
113 let ret = return_type
114 .map(|r| format!(": {}", format_type_hint(r)))
115 .unwrap_or_default();
116 format!("function {}({}){}", name, params_str, ret)
117}
118
119fn docblock_docs(doc: &ParsedDoc, sym_name: &str) -> Option<Documentation> {
121 let db = find_docblock(&doc.program().stmts, sym_name)?;
122 let md = db.to_markdown();
123 if md.is_empty() {
124 None
125 } else {
126 Some(Documentation::MarkupContent(MarkupContent {
127 kind: MarkupKind::Markdown,
128 value: md,
129 }))
130 }
131}
132
133fn resolve_attribute_class(source: &str, position: Position) -> Option<String> {
136 let line = source.lines().nth(position.line as usize)?;
137 let col = utf16_offset_to_byte(line, position.character as usize);
138 let before = line[..col].trim_end_matches('(').trim_end();
139 let hash_pos = before.rfind("#[")?;
141 let after_bracket = before[hash_pos + 2..].trim_start();
142 let name: String = after_bracket
144 .trim_start_matches('\\')
145 .rsplit('\\')
146 .next()
147 .unwrap_or("")
148 .chars()
149 .take_while(|c| c.is_alphanumeric() || *c == '_')
150 .collect();
151 if name.is_empty() { None } else { Some(name) }
152}
153
154fn resolve_call_params(
155 source: &str,
156 doc: &ParsedDoc,
157 other_docs: &[Arc<ParsedDoc>],
158 position: Position,
159) -> Vec<String> {
160 let line = match source.lines().nth(position.line as usize) {
161 Some(l) => l,
162 None => return vec![],
163 };
164 let col = utf16_offset_to_byte(line, position.character as usize);
165 let before = &line[..col];
166 let before = before.strip_suffix('(').unwrap_or(before);
167 let func_name: String = before
168 .chars()
169 .rev()
170 .take_while(|&c| c.is_alphanumeric() || c == '_')
171 .collect::<String>()
172 .chars()
173 .rev()
174 .collect();
175 if func_name.is_empty() {
176 return vec![];
177 }
178 let mut params = params_of_function(doc, &func_name);
179 if params.is_empty() {
180 for other in other_docs {
181 params = params_of_function(other, &func_name);
182 if !params.is_empty() {
183 break;
184 }
185 }
186 }
187 params
188}
189
190pub type ClassDocLookup<'a> = &'a dyn Fn(&str) -> Option<Arc<ParsedDoc>>;
194
195#[derive(Default)]
198pub struct CompletionCtx<'a> {
199 pub source: Option<&'a str>,
200 pub position: Option<Position>,
201 pub meta: Option<&'a PhpStormMeta>,
202 pub doc_uri: Option<&'a Url>,
203 pub file_imports: Option<&'a HashMap<String, String>>,
204 pub find_class_doc: Option<ClassDocLookup<'a>>,
210 pub analysis: Option<&'a mir_analyzer::FileAnalysis>,
214 pub type_map: Option<&'a dyn Fn() -> Arc<TypeMap>>,
220}
221
222fn whole_doc_type_map(
224 ctx: &CompletionCtx<'_>,
225 doc: &ParsedDoc,
226 meta: Option<&PhpStormMeta>,
227) -> Arc<TypeMap> {
228 match ctx.type_map {
229 Some(get) => get(),
230 None => Arc::new(TypeMap::from_doc_with_meta(doc, meta)),
231 }
232}
233
234pub(crate) fn cursor_in_string_or_comment(source: &str, cursor_byte: usize) -> bool {
242 #[derive(PartialEq)]
243 enum S {
244 Normal,
245 Single,
246 Double,
247 Line,
248 Block,
249 }
250 let bytes = source.as_bytes();
251 let limit = bytes.len().min(cursor_byte);
252 let mut i = 0usize;
253 let mut state = S::Normal;
254 while i < limit {
255 match state {
256 S::Normal => match bytes[i] {
257 b'\'' => {
258 state = S::Single;
259 i += 1;
260 }
261 b'"' => {
262 state = S::Double;
263 i += 1;
264 }
265 b'/' if i + 1 < limit && bytes[i + 1] == b'/' => {
266 state = S::Line;
267 i += 2;
268 }
269 b'#' if !(i + 1 < limit && bytes[i + 1] == b'[') => {
271 state = S::Line;
272 i += 1;
273 }
274 b'/' if i + 1 < limit && bytes[i + 1] == b'*' => {
275 state = S::Block;
276 i += 2;
277 }
278 _ => {
279 i += 1;
280 }
281 },
282 S::Single => match bytes[i] {
283 b'\\' => {
284 i += 2;
285 }
286 b'\'' => {
287 state = S::Normal;
288 i += 1;
289 }
290 _ => {
291 i += 1;
292 }
293 },
294 S::Double => match bytes[i] {
295 b'\\' => {
296 i += 2;
297 }
298 b'"' => {
299 state = S::Normal;
300 i += 1;
301 }
302 _ => {
303 i += 1;
304 }
305 },
306 S::Line => {
307 if bytes[i] == b'\n' {
308 state = S::Normal;
309 }
310 i += 1;
311 }
312 S::Block => {
313 if bytes[i] == b'*' && i + 1 < limit && bytes[i + 1] == b'/' {
314 state = S::Normal;
315 i += 2;
316 } else {
317 i += 1;
318 }
319 }
320 }
321 }
322 state != S::Normal
323}
324
325pub fn filtered_completions_at(
328 doc: &ParsedDoc,
329 other_docs: &[Arc<ParsedDoc>],
330 trigger_character: Option<&str>,
331 ctx: &CompletionCtx<'_>,
332) -> Vec<CompletionItem> {
333 let source = ctx.source;
334 let position = ctx.position;
335
336 let doc_uri = ctx.doc_uri;
337
338 if let (Some(src), Some(pos)) = (source, position) {
342 let cursor_byte = doc.view().byte_of_position(pos) as usize;
343 if cursor_in_string_or_comment(src, cursor_byte) && include_path_prefix(src, pos).is_none()
344 {
345 return vec![];
346 }
347 }
348 let meta = ctx.meta;
349 let empty_imports = HashMap::new();
350 let imports = ctx.file_imports.unwrap_or(&empty_imports);
351
352 match trigger_character {
353 Some("$") => {
354 let mut items = superglobal_completions();
355 items.extend(
356 symbol_completions(doc)
357 .into_iter()
358 .filter(|i| i.kind == Some(CompletionItemKind::VARIABLE)),
359 );
360 items
361 }
362 Some(">") => {
363 if let (Some(src), Some(pos)) = (source, position) {
365 let type_map = whole_doc_type_map(ctx, doc, meta);
366 if let Some(class_names) =
367 resolve_receiver_class(src, doc, pos, ctx.analysis, &type_map)
368 {
369 let mut items = Vec::new();
371 let mut seen = std::collections::HashSet::new();
372 for class_name in class_names.split('|') {
373 let class_name = class_name.trim();
374 for item in
375 all_instance_members(class_name, doc, other_docs, ctx.find_class_doc)
376 {
377 if seen.insert(item.label.clone()) {
378 items.push(item);
379 }
380 }
381 }
382 if !items.is_empty() {
383 return items;
384 }
385 }
386 }
387 symbol_completions(doc)
389 .into_iter()
390 .filter(|i| i.kind == Some(CompletionItemKind::METHOD))
391 .collect()
392 }
393 Some(":") => {
394 if let (Some(src), Some(pos)) = (source, position)
396 && let Some(class_name) = resolve_static_receiver(src, doc, other_docs, pos)
397 {
398 let items = all_static_members(&class_name, doc, other_docs, ctx.find_class_doc);
399 if !items.is_empty() {
400 return items;
401 }
402 }
403 vec![]
404 }
405 Some("[") => {
406 if let (Some(src), Some(pos)) = (source, position) {
408 let line = src.lines().nth(pos.line as usize).unwrap_or("");
409 let col = utf16_offset_to_byte(line, pos.character as usize);
410 let before = &line[..col];
411 if before.trim_end_matches('[').trim_end().ends_with('#') {
412 return attribute_completions(src, pos, doc, other_docs, imports);
413 }
414 }
415 vec![]
416 }
417 Some("(") => {
418 if let (Some(src), Some(pos)) = (source, position) {
420 let params = resolve_call_params(src, doc, other_docs, pos);
421 if !params.is_empty() {
422 return params
423 .into_iter()
424 .map(|p| CompletionItem {
425 label: format!("{p}:"),
426 kind: Some(CompletionItemKind::VARIABLE),
427 ..Default::default()
428 })
429 .collect();
430 }
431 if let Some(attr_class) = resolve_attribute_class(src, pos) {
433 let mut attr_params = params_of_method(doc, &attr_class, "__construct");
434 if attr_params.is_empty() {
435 for other in other_docs {
436 attr_params = params_of_method(other, &attr_class, "__construct");
437 if !attr_params.is_empty() {
438 break;
439 }
440 }
441 }
442 if !attr_params.is_empty() {
443 return attr_params
444 .into_iter()
445 .map(|p| CompletionItem {
446 label: format!("{p}:"),
447 kind: Some(CompletionItemKind::VARIABLE),
448 detail: Some(format!("#{attr_class} argument")),
449 ..Default::default()
450 })
451 .collect();
452 }
453 }
454 }
455 vec![]
456 }
457 _ => {
458 if let (Some(src), Some(pos)) = (source, position) {
462 let line = src.lines().nth(pos.line as usize).unwrap_or("");
463 let col = utf16_offset_to_byte(line, pos.character as usize);
464 let before = &line[..col];
465 let pre_arrow = before.trim_end_matches(|c: char| c.is_alphanumeric() || c == '_');
467 let has_arrow = pre_arrow.ends_with("->") || pre_arrow.ends_with("?->");
468 if has_arrow {
469 let arrow_stripped = pre_arrow
471 .strip_suffix("->")
472 .or_else(|| pre_arrow.strip_suffix("?->"))
473 .unwrap_or(pre_arrow);
474 let receiver: String = arrow_stripped
475 .chars()
476 .rev()
477 .take_while(|&c| c.is_alphanumeric() || c == '_' || c == '$')
478 .collect::<String>()
479 .chars()
480 .rev()
481 .collect();
482 let receiver = if receiver.starts_with('$') {
483 receiver
484 } else if !receiver.is_empty() {
485 format!("${receiver}")
486 } else {
487 String::new()
488 };
489 let var_offset = line_byte_offset(doc, pos.line, arrow_stripped.len());
492 let type_map = whole_doc_type_map(ctx, doc, meta);
493 let class_name = if receiver == "$this" {
494 enclosing_class_at(src, doc, pos)
495 .or_else(|| ctx.analysis.and_then(|a| receiver_class_at(a, var_offset)))
496 .or_else(|| type_map.get("$this").map(str::to_owned))
497 } else if !receiver.is_empty() {
498 ctx.analysis
499 .and_then(|a| receiver_class_at(a, var_offset))
500 .or_else(|| type_map.get(&receiver).map(str::to_owned))
501 } else {
502 None
503 };
504 if let Some(cls) = class_name {
505 let mut items = Vec::new();
506 let mut seen = std::collections::HashSet::new();
507 for class_name in cls.split('|') {
508 for item in all_instance_members(
509 class_name.trim(),
510 doc,
511 other_docs,
512 ctx.find_class_doc,
513 ) {
514 if seen.insert(item.label.clone()) {
515 items.push(item);
516 }
517 }
518 }
519 if !items.is_empty() {
520 let prefix = before.strip_prefix(pre_arrow).unwrap_or("").to_string();
522 if !prefix.is_empty() {
523 let fq = crate::util::FuzzyQuery::new(&prefix);
524 items.retain(|i| {
525 let match_against = if i.label.starts_with('$') {
529 i.label.strip_prefix('$').unwrap_or(&i.label)
530 } else {
531 &i.label
532 };
533 fq.camel_match(match_against)
534 });
535 for item in &mut items {
536 let match_against = if item.label.starts_with('$') {
537 item.label.strip_prefix('$').unwrap_or(&item.label)
538 } else {
539 &item.label
540 };
541 item.sort_text =
542 Some(crate::util::camel_sort_key(&prefix, match_against));
543 item.filter_text = Some(item.label.clone());
544 }
545 }
546 return items;
547 }
548 }
549 }
550 }
551
552 if let (Some(src), Some(pos)) = (source, position) {
554 let line = src.lines().nth(pos.line as usize).unwrap_or("");
555 let col = utf16_offset_to_byte(line, pos.character as usize);
556 let before = &line[..col];
557 let pre_ident =
558 before.trim_end_matches(|c: char| c.is_alphanumeric() || c == '_' || c == '\\');
559 if pre_ident.trim_end().ends_with("#[") || pre_ident.trim_end() == "#[" {
560 let items = attribute_completions(src, pos, doc, other_docs, imports);
561 if !items.is_empty() {
562 return items;
563 }
564 }
565 }
566
567 if let (Some(src), Some(pos)) = (source, position)
569 && let Some(use_prefix) = use_completion_prefix(src, pos)
570 {
571 let mut use_items: Vec<CompletionItem> = Vec::new();
572 for other in other_docs {
573 collect_fqns_with_prefix(
574 &other.program().stmts,
575 "",
576 &use_prefix,
577 &mut use_items,
578 );
579 }
580 collect_fqns_with_prefix(&doc.program().stmts, "", &use_prefix, &mut use_items);
582 if !use_items.is_empty() {
583 return use_items;
584 }
585 }
586
587 if let (Some(src), Some(pos), Some(uri)) = (source, position, doc_uri)
589 && let Some(prefix) = include_path_prefix(src, pos)
590 {
591 let items = include_path_completions(uri, &prefix);
594 return items;
595 }
596
597 let other_classes_cell: std::cell::OnceCell<
602 Vec<Vec<(String, CompletionItemKind, String)>>,
603 > = std::cell::OnceCell::new();
604 let other_classes = || {
605 other_classes_cell.get_or_init(|| {
606 other_docs
607 .iter()
608 .map(|other| {
609 let mut classes = Vec::new();
610 collect_classes_with_ns(&other.program().stmts, "", &mut classes);
611 classes
612 })
613 .collect()
614 })
615 };
616
617 if let (Some(src), Some(pos)) = (source, position)
619 && let Some(prefix) = typed_prefix(Some(src), Some(pos))
620 && prefix.contains('\\')
621 {
622 let is_use = use_completion_prefix(src, pos).is_some();
624 if !is_use {
625 let prefix_lc = prefix.trim_start_matches('\\').to_lowercase();
626 let mut ns_items: Vec<CompletionItem> = Vec::new();
627 for classes in other_classes() {
628 for (label, kind, fqn) in classes {
629 if fqn
630 .get(..prefix_lc.len())
631 .is_some_and(|s| s.eq_ignore_ascii_case(&prefix_lc))
632 {
633 ns_items.push(CompletionItem {
634 label: label.clone(),
635 kind: Some(*kind),
636 insert_text: Some(label.clone()),
637 detail: Some(fqn.clone()),
638 ..Default::default()
639 });
640 }
641 }
642 }
643 let mut classes = Vec::new();
644 collect_classes_with_ns(&doc.program().stmts, "", &mut classes);
645 for (label, kind, fqn) in classes {
646 if fqn
647 .get(..prefix_lc.len())
648 .is_some_and(|s| s.eq_ignore_ascii_case(&prefix_lc))
649 {
650 ns_items.push(CompletionItem {
651 label: label.clone(),
652 kind: Some(kind),
653 insert_text: Some(label),
654 detail: Some(fqn),
655 ..Default::default()
656 });
657 }
658 }
659 if !ns_items.is_empty() {
660 return ns_items;
661 }
662 }
663 }
664
665 if let (Some(src), Some(pos)) = (source, position)
667 && let Some(match_items) = match_arm_completions(
668 src,
669 doc,
670 other_docs,
671 pos,
672 &|| whole_doc_type_map(ctx, doc, meta),
673 ctx.analysis,
674 )
675 && !match_items.is_empty()
676 {
677 let mut all = match_items;
678 let mut normal_items = keyword_completions();
680 normal_items.extend(magic_constant_completions());
681 normal_items.extend(builtin_completions());
682 normal_items.extend(superglobal_completions());
683 normal_items.extend(symbol_completions(doc));
684 all.extend(normal_items);
685
686 let mut seen = std::collections::HashSet::new();
688 all.retain(|i| seen.insert(i.label.clone()));
689
690 return all;
691 }
692
693 let mut magic_items: Vec<CompletionItem> = Vec::new();
695 if let (Some(src), Some(pos)) = (source, position)
696 && enclosing_class_at(src, doc, pos).is_some()
697 {
698 magic_items.extend(magic_method_completions());
699 }
700
701 let mut items = keyword_completions();
702 items.extend(magic_constant_completions());
703 items.extend(builtin_completions());
704 items.extend(superglobal_completions());
705 let sym_items = if let (Some(_src), Some(pos)) = (source, position) {
707 symbol_completions_before(doc, pos.line)
708 } else {
709 symbol_completions(doc)
710 };
711 items.extend(sym_items);
712 items.extend(magic_items);
713
714 let cur_ns = current_file_namespace(&doc.program().stmts);
715
716 for (other, classes) in other_docs.iter().zip(other_classes()) {
717 for (label, kind, fqn) in classes {
719 let additional_text_edits = if let Some(src) = source {
720 let in_same_ns =
721 !cur_ns.is_empty() && *fqn == format!("{}\\{}", cur_ns, label);
722 let is_global = !fqn.contains('\\');
723 let already = imports.contains_key(label);
724 if !in_same_ns && !is_global && !already {
725 let pos = use_insert_position(src);
726 Some(vec![TextEdit {
727 range: Range {
728 start: pos,
729 end: pos,
730 },
731 new_text: format!("use {};\n", fqn),
732 }])
733 } else {
734 None
735 }
736 } else {
737 None
738 };
739 items.push(CompletionItem {
740 label: label.clone(),
741 kind: Some(*kind),
742 detail: if fqn.contains('\\') {
743 Some(fqn.clone())
744 } else {
745 None
746 },
747 additional_text_edits,
748 ..Default::default()
749 });
750 }
751 let cross: Vec<CompletionItem> = symbol_completions(other)
753 .into_iter()
754 .filter(|i| {
755 !matches!(
756 i.kind,
757 Some(CompletionItemKind::CLASS)
758 | Some(CompletionItemKind::INTERFACE)
759 | Some(CompletionItemKind::ENUM)
760 ) && i.kind != Some(CompletionItemKind::VARIABLE)
761 })
762 .collect();
763 items.extend(cross);
764 }
765 let mut seen = std::collections::HashSet::new();
766 items.retain(|i| seen.insert(i.label.clone()));
767
768 let prefix = typed_prefix(source, position).unwrap_or_default();
770 if prefix.contains('\\') {
771 let ns_prefix = prefix.trim_start_matches('\\').to_lowercase();
773 items.retain(|i| {
774 let fqn = i.detail.as_deref().unwrap_or(&i.label);
775 fqn.get(..ns_prefix.len())
776 .is_some_and(|s| s.eq_ignore_ascii_case(&ns_prefix))
777 });
778 } else if !prefix.is_empty() {
779 let fq = crate::util::FuzzyQuery::new(&prefix);
780 items.retain(|i| fq.camel_match(&i.label));
781 for item in &mut items {
782 item.sort_text = Some(camel_sort_key(&prefix, &item.label));
783 item.filter_text = Some(item.label.clone());
784 }
785 }
786 items
787 }
788 }
789}
790
791fn attribute_completions(
797 source: &str,
798 position: Position,
799 doc: &ParsedDoc,
800 other_docs: &[Arc<ParsedDoc>],
801 imports: &HashMap<String, String>,
802) -> Vec<CompletionItem> {
803 let context_target = infer_attribute_target(source, position);
804 let cur_ns = current_file_namespace(&doc.program().stmts);
805 let mut items: Vec<CompletionItem> = Vec::new();
806 let mut seen = std::collections::HashSet::new();
807
808 let mut cur_entries = Vec::new();
810 collect_attribute_classes(&doc.program().stmts, "", &mut cur_entries);
811 for entry in cur_entries {
812 if entry.target & context_target == 0 {
813 continue;
814 }
815 if seen.insert(entry.label.clone()) {
816 items.push(CompletionItem {
817 label: entry.label,
818 kind: Some(CompletionItemKind::CLASS),
819 ..Default::default()
820 });
821 }
822 }
823
824 for other in other_docs {
826 let mut entries = Vec::new();
827 collect_attribute_classes(&other.program().stmts, "", &mut entries);
828 for entry in entries {
829 if entry.target & context_target == 0 {
830 continue;
831 }
832 if !seen.insert(entry.label.clone()) {
833 continue;
834 }
835 let in_same_ns =
836 !cur_ns.is_empty() && entry.fqn == format!("{}\\{}", cur_ns, entry.label);
837 let is_global = !entry.fqn.contains('\\');
838 let already = imports.contains_key(&entry.label);
839 let additional_text_edits = if !in_same_ns && !is_global && !already {
840 let insert_pos = use_insert_position(source);
841 Some(vec![TextEdit {
842 range: Range {
843 start: insert_pos,
844 end: insert_pos,
845 },
846 new_text: format!("use {};\n", entry.fqn),
847 }])
848 } else {
849 None
850 };
851 items.push(CompletionItem {
852 label: entry.label,
853 kind: Some(CompletionItemKind::CLASS),
854 detail: if entry.fqn.contains('\\') {
855 Some(entry.fqn)
856 } else {
857 None
858 },
859 additional_text_edits,
860 ..Default::default()
861 });
862 }
863 }
864 items
865}
866
867#[allow(clippy::too_many_arguments)]
868fn match_arm_completions(
869 source: &str,
870 doc: &ParsedDoc,
871 other_docs: &[Arc<ParsedDoc>],
872 position: Position,
873 get_type_map: &dyn Fn() -> Arc<TypeMap>,
874 analysis: Option<&mir_analyzer::FileAnalysis>,
875) -> Option<Vec<CompletionItem>> {
876 let start_line = position.line as usize;
877 let end_line = start_line.saturating_sub(5);
878 let all_lines: Vec<&str> = source.lines().collect();
879 let type_map_cell: std::cell::OnceCell<Arc<TypeMap>> = std::cell::OnceCell::new();
880 for line_idx in (end_line..=start_line).rev() {
881 let line = all_lines.get(line_idx).copied()?;
882 if let Some(cap) = extract_match_subject(line) {
883 let class_name = if cap == "this" {
884 enclosing_class_at(source, doc, position)?
885 } else {
886 let subject_byte = line.find(&format!("${cap}"))?;
890 let var_offset = line_byte_offset(doc, line_idx as u32, subject_byte + 1);
891 analysis
892 .and_then(|a| receiver_class_at(a, var_offset))
893 .or_else(|| {
894 let type_map = type_map_cell.get_or_init(get_type_map);
895 type_map.get(&format!("${cap}")).map(str::to_owned)
896 })?
897 };
898 let all_docs: Vec<&ParsedDoc> = std::iter::once(doc)
899 .chain(other_docs.iter().map(|d| d.as_ref()))
900 .collect();
901 for d in &all_docs {
902 let members = members_of_class(d, &class_name);
903 if !members.constants.is_empty() {
904 return Some(
905 members
906 .constants
907 .iter()
908 .map(|c| CompletionItem {
909 label: format!("{class_name}::{c}"),
910 kind: Some(CompletionItemKind::CONSTANT),
911 ..Default::default()
912 })
913 .collect(),
914 );
915 }
916 }
917 }
918 }
919 None
920}
921
922fn include_path_prefix(source: &str, position: Position) -> Option<String> {
926 let line = source.lines().nth(position.line as usize)?;
927 if !line.contains("include") && !line.contains("require") {
929 return None;
930 }
931 let col = utf16_offset_to_byte(line, position.character as usize);
933 let before = &line[..col];
934 let quote_pos = before.rfind(['\'', '"'])?;
935 let typed = &before[quote_pos + 1..];
936 if typed.starts_with('/') || typed.contains("://") {
939 return None;
940 }
941 Some(typed.to_string())
942}
943
944fn include_path_completions(doc_uri: &Url, prefix: &str) -> Vec<CompletionItem> {
951 use std::path::Path;
952
953 let doc_path = match doc_uri.to_file_path() {
954 Ok(p) => p,
955 Err(_) => return vec![],
956 };
957 let doc_dir = match doc_path.parent() {
958 Some(d) => d.to_path_buf(),
959 None => return vec![],
960 };
961
962 let (dir_prefix, typed_file) = if prefix.ends_with('/') || prefix.ends_with('\\') {
964 (prefix.to_string(), String::new())
965 } else {
966 let p = Path::new(prefix);
967 let parent = p
968 .parent()
969 .map(|p| {
970 let s = p.to_string_lossy();
971 if s.is_empty() {
972 String::new()
973 } else {
974 format!("{}/", s)
975 }
976 })
977 .unwrap_or_default();
978 let file = p
979 .file_name()
980 .map(|f| f.to_string_lossy().into_owned())
981 .unwrap_or_default();
982 (parent, file)
983 };
984
985 let dir_to_list = doc_dir.join(&dir_prefix);
986
987 let entries = match std::fs::read_dir(&dir_to_list) {
988 Ok(e) => e,
989 Err(_) => return vec![],
990 };
991
992 let mut items = Vec::new();
993 for entry in entries.flatten() {
994 let name = entry.file_name().to_string_lossy().into_owned();
995 if name.starts_with('.') && !typed_file.starts_with('.') {
997 continue;
998 }
999 let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
1000 let is_php = name.ends_with(".php") || name.ends_with(".inc") || name.ends_with(".phtml");
1001 if !is_dir && !is_php {
1002 continue;
1003 }
1004 let entry_name = if is_dir {
1005 format!("{}/", name)
1006 } else {
1007 name.clone()
1008 };
1009 let insert_text = format!("{}{}", dir_prefix, entry_name);
1012 items.push(CompletionItem {
1013 label: name,
1014 kind: Some(if is_dir {
1015 CompletionItemKind::FOLDER
1016 } else {
1017 CompletionItemKind::FILE
1018 }),
1019 insert_text: Some(insert_text),
1020 ..Default::default()
1021 });
1022 }
1023 items.sort_by(|a, b| {
1024 let a_dir = a.kind == Some(CompletionItemKind::FOLDER);
1026 let b_dir = b.kind == Some(CompletionItemKind::FOLDER);
1027 b_dir.cmp(&a_dir).then(a.label.cmp(&b.label))
1028 });
1029 items
1030}
1031
1032fn extract_match_subject(line: &str) -> Option<String> {
1033 let trimmed = line.trim();
1034 let after = trimmed.strip_prefix("match")?.trim_start();
1035 let after = after.strip_prefix('(')?;
1036 let inner: String = after.chars().take_while(|&c| c != ')').collect();
1037 let var = inner.trim().trim_start_matches('$');
1038 if var.is_empty() {
1039 None
1040 } else {
1041 Some(var.to_string())
1042 }
1043}
1044
1045#[cfg(test)]
1046mod tests {
1047 use super::*;
1048
1049 fn doc(source: &str) -> ParsedDoc {
1050 ParsedDoc::parse(source.to_string())
1051 }
1052
1053 fn labels(items: &[CompletionItem]) -> Vec<&str> {
1054 items.iter().map(|i| i.label.as_str()).collect()
1055 }
1056
1057 #[test]
1058 fn keywords_list_is_non_empty() {
1059 let kws = keyword_completions();
1060 assert!(
1061 kws.len() >= 20,
1062 "expected at least 20 keywords, got {}",
1063 kws.len()
1064 );
1065 }
1066
1067 #[test]
1068 fn keywords_contain_common_php_keywords() {
1069 let kws = keyword_completions();
1070 let ls = labels(&kws);
1071 for expected in &[
1072 "function",
1073 "class",
1074 "return",
1075 "foreach",
1076 "match",
1077 "namespace",
1078 ] {
1079 assert!(ls.contains(expected), "missing keyword: {expected}");
1080 }
1081 }
1082
1083 #[test]
1084 fn all_keyword_items_have_keyword_kind() {
1085 for item in keyword_completions() {
1086 assert_eq!(item.kind, Some(CompletionItemKind::KEYWORD));
1087 }
1088 }
1089
1090 #[test]
1091 fn magic_constants_all_present() {
1092 let items = magic_constant_completions();
1093 let ls: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
1094 for name in &[
1095 "__FILE__",
1096 "__DIR__",
1097 "__LINE__",
1098 "__CLASS__",
1099 "__FUNCTION__",
1100 "__METHOD__",
1101 "__NAMESPACE__",
1102 "__TRAIT__",
1103 ] {
1104 assert!(ls.contains(name), "missing magic constant: {name}");
1105 }
1106 }
1107
1108 #[test]
1109 fn magic_constants_have_constant_kind() {
1110 for item in magic_constant_completions() {
1111 assert_eq!(
1112 item.kind,
1113 Some(CompletionItemKind::CONSTANT),
1114 "{} should have CONSTANT kind",
1115 item.label
1116 );
1117 }
1118 }
1119
1120 #[test]
1121 fn resolve_attribute_class_extracts_name() {
1122 let src = "<?php\n#[Route(\n";
1123 let pos = Position {
1125 line: 1,
1126 character: 8,
1127 };
1128 let result = resolve_attribute_class(src, pos);
1129 assert_eq!(result.as_deref(), Some("Route"));
1130 }
1131
1132 #[test]
1133 fn resolve_attribute_class_fqn_extracts_short_name() {
1134 let src = "<?php\n#[\\Symfony\\Component\\Routing\\Route(\n";
1135 let pos = Position {
1136 line: 1,
1137 character: 38,
1138 };
1139 let result = resolve_attribute_class(src, pos);
1140 assert_eq!(result.as_deref(), Some("Route"));
1141 }
1142
1143 #[test]
1144 fn resolve_attribute_class_returns_none_for_regular_call() {
1145 let src = "<?php\nsomeFunction(\n";
1146 let pos = Position {
1147 line: 1,
1148 character: 14,
1149 };
1150 let result = resolve_attribute_class(src, pos);
1151 assert!(result.is_none(), "should not match regular function call");
1152 }
1153
1154 #[test]
1155 fn extracts_top_level_function_name() {
1156 let d = doc("<?php\nfunction greet() {}");
1157 let items = symbol_completions(&d);
1158 assert!(labels(&items).contains(&"greet"));
1159 let greet = items.iter().find(|i| i.label == "greet").unwrap();
1160 assert_eq!(greet.kind, Some(CompletionItemKind::FUNCTION));
1161 }
1162
1163 #[test]
1164 fn extracts_top_level_class_name() {
1165 let d = doc("<?php\nclass MyService {}");
1166 let items = symbol_completions(&d);
1167 assert!(labels(&items).contains(&"MyService"));
1168 let cls = items.iter().find(|i| i.label == "MyService").unwrap();
1169 assert_eq!(cls.kind, Some(CompletionItemKind::CLASS));
1170 }
1171
1172 #[test]
1173 fn extracts_class_method_names() {
1174 let d = doc("<?php\nclass Calc { public function add() {} public function sub() {} }");
1175 let items = symbol_completions(&d);
1176 let ls = labels(&items);
1177 assert!(ls.contains(&"add"), "missing 'add'");
1178 assert!(ls.contains(&"sub"), "missing 'sub'");
1179 for item in items
1180 .iter()
1181 .filter(|i| i.label == "add" || i.label == "sub")
1182 {
1183 assert_eq!(item.kind, Some(CompletionItemKind::METHOD));
1184 }
1185 }
1186
1187 #[test]
1188 fn extracts_function_parameters_as_variables() {
1189 let d = doc("<?php\nfunction process($input, $count) {}");
1190 let items = symbol_completions(&d);
1191 let ls = labels(&items);
1192 assert!(ls.contains(&"$input"), "missing '$input'");
1193 assert!(ls.contains(&"$count"), "missing '$count'");
1194 }
1195
1196 #[test]
1197 fn extracts_symbols_inside_namespace() {
1198 let d = doc("<?php\nnamespace App {\nfunction render() {}\nclass View {}\n}");
1199 let items = symbol_completions(&d);
1200 let ls = labels(&items);
1201 assert!(ls.contains(&"render"), "missing 'render'");
1202 assert!(ls.contains(&"View"), "missing 'View'");
1203 }
1204
1205 #[test]
1206 fn extracts_interface_name() {
1207 let d = doc("<?php\ninterface Serializable {}");
1208 let items = symbol_completions(&d);
1209 let item = items.iter().find(|i| i.label == "Serializable");
1210 assert!(item.is_some(), "missing 'Serializable'");
1211 assert_eq!(item.unwrap().kind, Some(CompletionItemKind::INTERFACE));
1212 }
1213
1214 #[test]
1215 fn variable_assignment_produces_variable_item() {
1216 let d = doc("<?php\n$name = 'Alice';");
1217 let items = symbol_completions(&d);
1218 assert!(labels(&items).contains(&"$name"), "missing '$name'");
1219 }
1220
1221 #[test]
1222 fn class_property_appears_in_completions() {
1223 let d = doc("<?php\nclass User { public string $name; private int $age; }");
1224 let items = symbol_completions(&d);
1225 let ls = labels(&items);
1226 assert!(ls.contains(&"$name"), "missing '$name'");
1227 assert!(ls.contains(&"$age"), "missing '$age'");
1228 for item in items
1229 .iter()
1230 .filter(|i| i.label == "$name" || i.label == "$age")
1231 {
1232 assert_eq!(item.kind, Some(CompletionItemKind::PROPERTY));
1233 }
1234 }
1235
1236 #[test]
1237 fn class_constant_appears_in_completions() {
1238 let d = doc("<?php\nclass Status { const ACTIVE = 1; const INACTIVE = 0; }");
1239 let items = symbol_completions(&d);
1240 let ls = labels(&items);
1241 assert!(ls.contains(&"ACTIVE"), "missing 'ACTIVE'");
1242 assert!(ls.contains(&"INACTIVE"), "missing 'INACTIVE'");
1243 }
1244
1245 #[test]
1246 fn dollar_trigger_returns_only_variables() {
1247 let d = doc("<?php\nfunction greet($name) {}\nclass Foo {}\n$bar = 1;");
1248 let items = filtered_completions_at(&d, &[], Some("$"), &CompletionCtx::default());
1249 assert!(!items.is_empty(), "should have variable items");
1250 for item in &items {
1251 assert_eq!(item.kind, Some(CompletionItemKind::VARIABLE));
1252 }
1253 let ls = labels(&items);
1254 assert!(!ls.contains(&"greet"), "should not contain function");
1255 assert!(!ls.contains(&"Foo"), "should not contain class");
1256 }
1257
1258 #[test]
1259 fn arrow_trigger_returns_only_methods() {
1260 let d = doc("<?php\nclass Calc { public function add() {} public function sub() {} }");
1261 let items = filtered_completions_at(&d, &[], Some(">"), &CompletionCtx::default());
1262 assert!(!items.is_empty(), "should have method items");
1263 for item in &items {
1264 assert_eq!(item.kind, Some(CompletionItemKind::METHOD));
1265 }
1266 }
1267
1268 #[test]
1269 fn none_trigger_returns_keywords_functions_classes() {
1270 let d = doc("<?php\nfunction greet() {}\nclass MyApp {}");
1271 let items = filtered_completions_at(&d, &[], None, &CompletionCtx::default());
1272 let ls = labels(&items);
1273 assert!(
1274 ls.contains(&"function"),
1275 "should contain keyword 'function'"
1276 );
1277 assert!(ls.contains(&"greet"), "should contain function 'greet'");
1278 assert!(ls.contains(&"MyApp"), "should contain class 'MyApp'");
1279 }
1280
1281 #[test]
1282 fn builtins_appear_in_default_completions() {
1283 let d = doc("<?php");
1284 let items = filtered_completions_at(&d, &[], None, &CompletionCtx::default());
1285 let ls = labels(&items);
1286 assert!(ls.contains(&"strlen"), "missing strlen");
1287 assert!(ls.contains(&"array_map"), "missing array_map");
1288 assert!(ls.contains(&"json_encode"), "missing json_encode");
1289 }
1290
1291 #[test]
1292 fn colon_trigger_returns_static_members() {
1293 let src = "<?php\nclass Cfg { public static function load(): void {} public static int $debug = 0; const VERSION = '1'; }\nCfg::";
1294 let d = doc(src);
1295 let pos = Position {
1296 line: 2,
1297 character: 5,
1298 };
1299 let items = filtered_completions_at(
1300 &d,
1301 &[],
1302 Some(":"),
1303 &CompletionCtx {
1304 source: Some(src),
1305 position: Some(pos),
1306 ..Default::default()
1307 },
1308 );
1309 let ls = labels(&items);
1310 assert!(ls.contains(&"load"), "missing static method");
1311 assert!(ls.contains(&"VERSION"), "missing constant");
1312 }
1313
1314 #[test]
1315 fn inherited_methods_appear_in_arrow_completion() {
1316 let src = "<?php\nclass Base { public function baseMethod() {} }\nclass Child extends Base { public function childMethod() {} }\n$c = new Child();\n$c->";
1317 let d = doc(src);
1318 let pos = Position {
1319 line: 4,
1320 character: 4,
1321 };
1322 let items = filtered_completions_at(
1323 &d,
1324 &[],
1325 Some(">"),
1326 &CompletionCtx {
1327 source: Some(src),
1328 position: Some(pos),
1329 ..Default::default()
1330 },
1331 );
1332 let ls = labels(&items);
1333 assert!(ls.contains(&"baseMethod"), "missing inherited baseMethod");
1334 assert!(ls.contains(&"childMethod"), "missing childMethod");
1335 }
1336
1337 #[test]
1338 fn param_named_arg_completion() {
1339 let src = "<?php\nfunction connect(string $host, int $port): void {}\nconnect(";
1340 let d = doc(src);
1341 let pos = Position {
1342 line: 2,
1343 character: 8,
1344 };
1345 let items = filtered_completions_at(
1346 &d,
1347 &[],
1348 Some("("),
1349 &CompletionCtx {
1350 source: Some(src),
1351 position: Some(pos),
1352 ..Default::default()
1353 },
1354 );
1355 let ls = labels(&items);
1356 assert!(ls.contains(&"host:"), "missing host:");
1357 assert!(ls.contains(&"port:"), "missing port:");
1358 }
1359
1360 #[test]
1361 fn cross_file_symbols_appear_in_default_completions() {
1362 let d = doc("<?php\nfunction localFn() {}");
1363 let other = Arc::new(ParsedDoc::parse(
1364 "<?php\nclass RemoteService {}\nfunction remoteHelper() {}".to_string(),
1365 ));
1366 let items = filtered_completions_at(&d, &[other], None, &CompletionCtx::default());
1367 let ls = labels(&items);
1368 assert!(ls.contains(&"localFn"), "missing local function");
1369 assert!(ls.contains(&"RemoteService"), "missing cross-file class");
1370 assert!(ls.contains(&"remoteHelper"), "missing cross-file function");
1371 }
1372
1373 #[test]
1374 fn cross_file_variables_not_included_in_default_completions() {
1375 let d = doc("<?php\n$localVar = 1;");
1376 let other = Arc::new(ParsedDoc::parse("<?php\n$remoteVar = 2;".to_string()));
1377 let items = filtered_completions_at(&d, &[other], None, &CompletionCtx::default());
1378 let ls = labels(&items);
1379 assert!(
1380 !ls.contains(&"$remoteVar"),
1381 "cross-file variable should not appear"
1382 );
1383 }
1384
1385 #[test]
1386 fn cross_file_class_gets_use_insertion() {
1387 let current_src = "<?php\nnamespace App;\n\n$x = new ";
1388 let d = doc(current_src);
1389 let other = Arc::new(ParsedDoc::parse(
1390 "<?php\nnamespace Lib;\nclass Mailer {}".to_string(),
1391 ));
1392 let pos = Position {
1393 line: 3,
1394 character: 9,
1395 };
1396 let items = filtered_completions_at(
1397 &d,
1398 &[other],
1399 None,
1400 &CompletionCtx {
1401 source: Some(current_src),
1402 position: Some(pos),
1403 ..Default::default()
1404 },
1405 );
1406 let mailer = items.iter().find(|i| i.label == "Mailer");
1407 assert!(mailer.is_some(), "Mailer should appear in completions");
1408 let edits = mailer.unwrap().additional_text_edits.as_ref();
1409 assert!(edits.is_some(), "Mailer should have additionalTextEdits");
1410 let edit_text = &edits.unwrap()[0].new_text;
1411 assert!(
1412 edit_text.contains("use Lib\\Mailer;"),
1413 "edit should insert 'use Lib\\Mailer;', got: {edit_text}"
1414 );
1415 }
1416
1417 #[test]
1418 fn same_namespace_class_gets_no_use_insertion() {
1419 let current_src = "<?php\nnamespace Lib;\n$x = new ";
1420 let d = doc(current_src);
1421 let other = Arc::new(ParsedDoc::parse(
1422 "<?php\nnamespace Lib;\nclass Mailer {}".to_string(),
1423 ));
1424 let pos = Position {
1425 line: 2,
1426 character: 9,
1427 };
1428 let items = filtered_completions_at(
1429 &d,
1430 &[other],
1431 None,
1432 &CompletionCtx {
1433 source: Some(current_src),
1434 position: Some(pos),
1435 ..Default::default()
1436 },
1437 );
1438 let mailer = items.iter().find(|i| i.label == "Mailer");
1439 assert!(mailer.is_some(), "Mailer should appear in completions");
1440 assert!(
1441 mailer.unwrap().additional_text_edits.is_none(),
1442 "same-namespace class should not get a use edit"
1443 );
1444 }
1445
1446 #[test]
1447 fn function_with_params_gets_snippet() {
1448 let d = doc("<?php\nfunction process($input) {}");
1449 let items = symbol_completions(&d);
1450 let item = items.iter().find(|i| i.label == "process").unwrap();
1451 assert_eq!(item.insert_text_format, Some(InsertTextFormat::SNIPPET));
1452 assert_eq!(item.insert_text.as_deref(), Some("process($1)"));
1453 }
1454
1455 #[test]
1456 fn function_without_params_gets_plain_call() {
1457 let d = doc("<?php\nfunction doThing() {}");
1458 let items = symbol_completions(&d);
1459 let item = items.iter().find(|i| i.label == "doThing").unwrap();
1460 assert_eq!(item.insert_text.as_deref(), Some("doThing()"));
1462 assert_ne!(item.insert_text_format, Some(InsertTextFormat::SNIPPET));
1463 }
1464
1465 #[test]
1466 fn builtin_functions_get_snippet() {
1467 let items = builtin_completions();
1468 let strlen = items.iter().find(|i| i.label == "strlen").unwrap();
1469 assert_eq!(strlen.insert_text_format, Some(InsertTextFormat::SNIPPET));
1470 assert_eq!(strlen.insert_text.as_deref(), Some("strlen($1)"));
1471 }
1472
1473 #[test]
1474 fn enum_arrow_completion_includes_name_property() {
1475 let src = "<?php\nenum Suit { case Hearts; }\n$s = new Suit();\n$s->";
1476 let d = doc(src);
1477 let pos = Position {
1478 line: 3,
1479 character: 4,
1480 };
1481 let items = filtered_completions_at(
1482 &d,
1483 &[],
1484 Some(">"),
1485 &CompletionCtx {
1486 source: Some(src),
1487 position: Some(pos),
1488 ..Default::default()
1489 },
1490 );
1491 assert!(
1492 items.iter().any(|i| i.label == "name"),
1493 "enum should have ->name"
1494 );
1495 }
1496
1497 #[test]
1498 fn backed_enum_arrow_completion_includes_value_property() {
1499 let src =
1500 "<?php\nenum Status: string { case Active = 'active'; }\n$s = new Status();\n$s->";
1501 let d = doc(src);
1502 let pos = Position {
1503 line: 3,
1504 character: 4,
1505 };
1506 let items = filtered_completions_at(
1507 &d,
1508 &[],
1509 Some(">"),
1510 &CompletionCtx {
1511 source: Some(src),
1512 position: Some(pos),
1513 ..Default::default()
1514 },
1515 );
1516 assert!(
1517 items.iter().any(|i| i.label == "name"),
1518 "backed enum should have ->name"
1519 );
1520 assert!(
1521 items.iter().any(|i| i.label == "value"),
1522 "backed enum should have ->value"
1523 );
1524 }
1525
1526 #[test]
1527 fn pure_enum_arrow_completion_has_no_value_property() {
1528 let src = "<?php\nenum Suit { case Hearts; }\n$s = new Suit();\n$s->";
1529 let d = doc(src);
1530 let pos = Position {
1531 line: 3,
1532 character: 4,
1533 };
1534 let items = filtered_completions_at(
1535 &d,
1536 &[],
1537 Some(">"),
1538 &CompletionCtx {
1539 source: Some(src),
1540 position: Some(pos),
1541 ..Default::default()
1542 },
1543 );
1544 assert!(
1545 !items.iter().any(|i| i.label == "value"),
1546 "pure enum should not have ->value"
1547 );
1548 }
1549
1550 #[test]
1551 fn superglobals_appear_on_dollar_trigger() {
1552 let d = doc("<?php\n");
1553 let items = filtered_completions_at(&d, &[], Some("$"), &CompletionCtx::default());
1554 let ls = labels(&items);
1555 assert!(ls.contains(&"$_SERVER"), "missing $_SERVER");
1556 assert!(ls.contains(&"$_GET"), "missing $_GET");
1557 assert!(ls.contains(&"$_POST"), "missing $_POST");
1558 assert!(ls.contains(&"$_SESSION"), "missing $_SESSION");
1559 assert!(ls.contains(&"$GLOBALS"), "missing $GLOBALS");
1560 }
1561
1562 #[test]
1563 fn superglobals_appear_in_default_completions() {
1564 let d = doc("<?php\n");
1565 let items = filtered_completions_at(&d, &[], None, &CompletionCtx::default());
1566 let ls = labels(&items);
1567 assert!(
1568 ls.contains(&"$_SERVER"),
1569 "missing $_SERVER in default completions"
1570 );
1571 }
1572
1573 #[test]
1574 fn instanceof_narrowing_provides_arrow_completions() {
1575 let src =
1577 "<?php\nclass Foo { public function doFoo() {} }\nif ($x instanceof Foo) {\n $x->";
1578 let d = doc(src);
1579 let pos = Position {
1580 line: 3,
1581 character: 8,
1582 };
1583 let items = filtered_completions_at(
1584 &d,
1585 &[],
1586 Some(">"),
1587 &CompletionCtx {
1588 source: Some(src),
1589 position: Some(pos),
1590 ..Default::default()
1591 },
1592 );
1593 let ls = labels(&items);
1594 assert!(
1595 ls.contains(&"doFoo"),
1596 "instanceof narrowing should make Foo methods available"
1597 );
1598 }
1599
1600 #[test]
1601 fn constructor_chain_arrow_completion() {
1602 let src = "<?php\nclass Builder { public function build() {} public function reset() {} }\n(new Builder())->";
1603 let d = doc(src);
1604 let pos = Position {
1605 line: 2,
1606 character: 16,
1607 };
1608 let items = filtered_completions_at(
1609 &d,
1610 &[],
1611 Some(">"),
1612 &CompletionCtx {
1613 source: Some(src),
1614 position: Some(pos),
1615 ..Default::default()
1616 },
1617 );
1618 let ls = labels(&items);
1619 assert!(
1620 ls.contains(&"build"),
1621 "constructor chain should complete Builder methods"
1622 );
1623 assert!(
1624 ls.contains(&"reset"),
1625 "constructor chain should complete Builder methods"
1626 );
1627 }
1628
1629 #[test]
1631 fn use_statement_suggests_fqns() {
1632 let d = doc("<?php\nuse ");
1633 let other = Arc::new(ParsedDoc::parse(
1634 "<?php\nnamespace App\\Services;\nclass Mailer {}".to_string(),
1635 ));
1636 let pos = Position {
1637 line: 1,
1638 character: 4,
1639 };
1640 let items = filtered_completions_at(
1641 &d,
1642 &[other],
1643 None,
1644 &CompletionCtx {
1645 source: Some("<?php\nuse "),
1646 position: Some(pos),
1647 ..Default::default()
1648 },
1649 );
1650 assert!(
1651 items.iter().any(|i| i.label.contains("Mailer")),
1652 "use completion should suggest Mailer"
1653 );
1654 }
1655
1656 #[test]
1658 fn union_type_param_completes_both_classes() {
1659 let src = "<?php\nclass Foo { public function fooMethod() {} }\nclass Bar { public function barMethod() {} }\n/**\n * @param Foo|Bar $x\n */\nfunction handle($x) {\n $x->";
1660 let d = doc(src);
1661 let pos = Position {
1662 line: 7,
1663 character: 8,
1664 };
1665 let items = filtered_completions_at(
1666 &d,
1667 &[],
1668 Some(">"),
1669 &CompletionCtx {
1670 source: Some(src),
1671 position: Some(pos),
1672 ..Default::default()
1673 },
1674 );
1675 let ls = labels(&items);
1676 assert!(
1677 ls.contains(&"fooMethod"),
1678 "should complete Foo methods from union"
1679 );
1680 assert!(
1681 ls.contains(&"barMethod"),
1682 "should complete Bar methods from union"
1683 );
1684 }
1685
1686 #[test]
1688 fn attribute_bracket_suggests_classes() {
1689 let src = "<?php\n#[\\Attribute]\nclass Route {}\n#[\\Attribute]\nclass Middleware {}\nclass Plain {}\n#[";
1690 let d = doc(src);
1691 let pos = Position {
1692 line: 6,
1693 character: 2,
1694 };
1695 let items = filtered_completions_at(
1696 &d,
1697 &[],
1698 Some("["),
1699 &CompletionCtx {
1700 source: Some(src),
1701 position: Some(pos),
1702 ..Default::default()
1703 },
1704 );
1705 let ls = labels(&items);
1706 assert!(ls.contains(&"Route"), "should suggest Route as attribute");
1707 assert!(
1708 ls.contains(&"Middleware"),
1709 "should suggest Middleware as attribute"
1710 );
1711 assert!(
1712 !ls.contains(&"Plain"),
1713 "plain class without #[Attribute] must not appear"
1714 );
1715 }
1716
1717 #[test]
1718 fn attribute_bracket_cross_ns_gets_use_insertion() {
1719 let current_src = "<?php\nnamespace App\\Controllers;\n\n#[";
1720 let d = doc(current_src);
1721 let other = Arc::new(ParsedDoc::parse(
1722 "<?php\nnamespace App\\Attributes;\n#[\\Attribute]\nclass Route {}".to_string(),
1723 ));
1724 let pos = Position {
1725 line: 3,
1726 character: 2,
1727 };
1728 let items = filtered_completions_at(
1729 &d,
1730 &[other],
1731 Some("["),
1732 &CompletionCtx {
1733 source: Some(current_src),
1734 position: Some(pos),
1735 ..Default::default()
1736 },
1737 );
1738 let route = items.iter().find(|i| i.label == "Route");
1739 assert!(
1740 route.is_some(),
1741 "Route should appear in attribute completions"
1742 );
1743 let edits = route.unwrap().additional_text_edits.as_ref();
1744 assert!(
1745 edits.is_some(),
1746 "Route attribute should have additionalTextEdits for auto-import"
1747 );
1748 let edit_text = &edits.unwrap()[0].new_text;
1749 assert!(
1750 edit_text.contains("use App\\Attributes\\Route;"),
1751 "edit should insert 'use App\\Attributes\\Route;', got: {edit_text}"
1752 );
1753 }
1754
1755 #[test]
1756 fn attribute_bracket_same_ns_no_use_insertion() {
1757 let current_src = "<?php\nnamespace App\\Attributes;\n\n#[";
1758 let d = doc(current_src);
1759 let other = Arc::new(ParsedDoc::parse(
1760 "<?php\nnamespace App\\Attributes;\n#[\\Attribute]\nclass Route {}".to_string(),
1761 ));
1762 let pos = Position {
1763 line: 3,
1764 character: 2,
1765 };
1766 let items = filtered_completions_at(
1767 &d,
1768 &[other],
1769 Some("["),
1770 &CompletionCtx {
1771 source: Some(current_src),
1772 position: Some(pos),
1773 ..Default::default()
1774 },
1775 );
1776 let route = items.iter().find(|i| i.label == "Route");
1777 assert!(
1778 route.is_some(),
1779 "Route should appear in attribute completions"
1780 );
1781 assert!(
1782 route.unwrap().additional_text_edits.is_none(),
1783 "same-namespace attribute class should not get a use edit"
1784 );
1785 }
1786
1787 #[test]
1789 fn match_arm_suggests_enum_cases() {
1790 let src = "<?php\nenum Status { case Active; case Inactive; case Pending; }\n$s = new Status();\nmatch ($s) {\n ";
1791 let d = doc(src);
1792 let pos = Position {
1793 line: 4,
1794 character: 4,
1795 };
1796 let items = filtered_completions_at(
1797 &d,
1798 &[],
1799 None,
1800 &CompletionCtx {
1801 source: Some(src),
1802 position: Some(pos),
1803 ..Default::default()
1804 },
1805 );
1806 let ls = labels(&items);
1807 assert!(
1808 ls.iter().any(|l| l.contains("Active")),
1809 "match should suggest Status::Active"
1810 );
1811 }
1812
1813 #[test]
1815 fn readonly_property_has_detail_tag() {
1816 let src = "<?php\nclass Config { public readonly string $name; }\n$c = new Config();\n$c->";
1817 let d = doc(src);
1818 let pos = Position {
1819 line: 3,
1820 character: 4,
1821 };
1822 let items = filtered_completions_at(
1823 &d,
1824 &[],
1825 Some(">"),
1826 &CompletionCtx {
1827 source: Some(src),
1828 position: Some(pos),
1829 ..Default::default()
1830 },
1831 );
1832 let name_item = items.iter().find(|i| i.label == "$name");
1833 assert!(name_item.is_some(), "should have $name in completions");
1834 assert_eq!(
1835 name_item.unwrap().detail.as_deref(),
1836 Some("readonly"),
1837 "$name should be tagged readonly"
1838 );
1839 }
1840
1841 #[test]
1843 fn variables_after_cursor_not_suggested() {
1844 let src = "<?php\n$early = new Foo();\n// cursor here\n$late = new Bar();";
1845 let d = doc(src);
1846 let pos = Position {
1847 line: 2,
1848 character: 0,
1849 };
1850 let items = filtered_completions_at(
1851 &d,
1852 &[],
1853 None,
1854 &CompletionCtx {
1855 source: Some(src),
1856 position: Some(pos),
1857 ..Default::default()
1858 },
1859 );
1860 let ls = labels(&items);
1861 assert!(ls.contains(&"$early"), "$early should be suggested");
1862 assert!(
1863 !ls.contains(&"$late"),
1864 "$late declared after cursor should not be suggested"
1865 );
1866 }
1867
1868 #[test]
1870 fn backslash_prefix_suggests_matching_classes() {
1871 let d = doc("<?php\n$x = new App\\");
1872 let other = Arc::new(ParsedDoc::parse(
1873 "<?php\nnamespace App\\Services;\nclass Mailer {}\nclass Logger {}".to_string(),
1874 ));
1875 let pos = Position {
1876 line: 1,
1877 character: 18,
1878 };
1879 let items = filtered_completions_at(
1880 &d,
1881 &[other],
1882 None,
1883 &CompletionCtx {
1884 source: Some("<?php\n$x = new App\\"),
1885 position: Some(pos),
1886 ..Default::default()
1887 },
1888 );
1889 let ls = labels(&items);
1890 assert!(
1891 ls.contains(&"Mailer"),
1892 "should suggest Mailer under App\\Services"
1893 );
1894 }
1895
1896 #[test]
1898 fn nullsafe_arrow_triggers_member_completions() {
1899 let src = "<?php\nclass Service { public function run() {} public string $status; }\n$s = new Service();\n$s?->";
1900 let d = doc(src);
1901 let pos = Position {
1902 line: 3,
1903 character: 5,
1904 };
1905 let items = filtered_completions_at(
1906 &d,
1907 &[],
1908 Some(">"),
1909 &CompletionCtx {
1910 source: Some(src),
1911 position: Some(pos),
1912 ..Default::default()
1913 },
1914 );
1915 let ls = labels(&items);
1916 assert!(ls.contains(&"run"), "?-> should complete Service::run()");
1917 assert!(
1918 ls.iter().any(|l| l.contains("status")),
1919 "?-> should complete Service::$status"
1920 );
1921 }
1922
1923 #[test]
1925 fn magic_methods_suggested_in_class_body() {
1926 let src = "<?php\nclass Foo {\n __\n}";
1927 let d = doc(src);
1928 let pos = Position {
1929 line: 2,
1930 character: 6,
1931 };
1932 let items = filtered_completions_at(
1933 &d,
1934 &[],
1935 None,
1936 &CompletionCtx {
1937 source: Some(src),
1938 position: Some(pos),
1939 ..Default::default()
1940 },
1941 );
1942 let ls = labels(&items);
1943 assert!(ls.contains(&"__construct"), "should suggest __construct");
1944 assert!(ls.contains(&"__toString"), "should suggest __toString");
1945 }
1946
1947 #[test]
1948 fn arrow_trigger_does_not_complete_on_unknown_receiver() {
1949 let src = "<?php\n$unknown->";
1953 let d = doc(src);
1954 let pos = Position {
1955 line: 1,
1956 character: 10,
1957 };
1958 let items = filtered_completions_at(
1959 &d,
1960 &[],
1961 Some(">"),
1962 &CompletionCtx {
1963 source: Some(src),
1964 position: Some(pos),
1965 ..Default::default()
1966 },
1967 );
1968 assert!(
1970 items.is_empty(),
1971 "unknown receiver should yield no completions, got: {:?}",
1972 labels(&items)
1973 );
1974 }
1975
1976 #[test]
1977 fn static_trigger_shows_only_static_members() {
1978 let src = concat!(
1980 "<?php\n",
1981 "class MyClass {\n",
1982 " public static function staticMethod(): void {}\n",
1983 " public function instanceMethod(): void {}\n",
1984 " public static int $staticProp = 0;\n",
1985 " const MY_CONST = 42;\n",
1986 "}\n",
1987 "MyClass::",
1988 );
1989 let d = doc(src);
1990 let pos = Position {
1991 line: 7,
1992 character: 9,
1993 };
1994 let items = filtered_completions_at(
1995 &d,
1996 &[],
1997 Some(":"),
1998 &CompletionCtx {
1999 source: Some(src),
2000 position: Some(pos),
2001 ..Default::default()
2002 },
2003 );
2004 let ls = labels(&items);
2005 assert!(ls.contains(&"staticMethod"), "should include static method");
2006 assert!(ls.contains(&"MY_CONST"), "should include constant");
2007 assert!(
2008 !ls.contains(&"instanceMethod"),
2009 "should NOT include instance method in static completion, got: {:?}",
2010 ls
2011 );
2012 }
2013
2014 use expect_test::expect;
2017
2018 #[test]
2019 fn snapshot_keyword_completions_present() {
2020 let items = keyword_completions();
2022 let mut ls: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
2023 ls.sort_unstable();
2024 let first_ten = ls[..10.min(ls.len())].join("\n");
2027 expect![[r#"
2028 abstract
2029 and
2030 array
2031 as
2032 break
2033 callable
2034 case
2035 catch
2036 class
2037 clone"#]]
2038 .assert_eq(&first_ten);
2039 }
2040
2041 #[test]
2042 fn snapshot_symbol_completions_for_simple_class() {
2043 let d = doc(
2044 "<?php\nclass Counter { public function increment(): void {} public function reset(): void {} }",
2045 );
2046 let items = symbol_completions(&d);
2047 let mut ls: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
2048 ls.sort_unstable();
2049 expect![[r#"
2050 Counter
2051 increment
2052 reset"#]]
2053 .assert_eq(&ls.join("\n"));
2054 }
2055
2056 #[test]
2057 fn snapshot_symbol_completions_for_function_with_params() {
2058 let d = doc("<?php\nfunction connect(string $host, int $port): void {}");
2059 let items = symbol_completions(&d);
2060 let mut ls: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
2061 ls.sort_unstable();
2062 expect![[r#"
2063 $host
2064 $port
2065 connect
2066 connect(host:, port:)"#]]
2067 .assert_eq(&ls.join("\n"));
2068 }
2069
2070 #[test]
2071 fn snapshot_arrow_completions_for_typed_var() {
2072 let src = "<?php\nclass Greeter { public function sayHello(): void {} public function sayBye(): void {} }\n$g = new Greeter();\n$g->";
2073 let d = doc(src);
2074 let pos = Position {
2075 line: 3,
2076 character: 4,
2077 };
2078 let items = filtered_completions_at(
2079 &d,
2080 &[],
2081 Some(">"),
2082 &CompletionCtx {
2083 source: Some(src),
2084 position: Some(pos),
2085 ..Default::default()
2086 },
2087 );
2088 let mut ls: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
2089 ls.sort_unstable();
2090 expect![[r#"
2091 sayBye
2092 sayHello"#]]
2093 .assert_eq(&ls.join("\n"));
2094 }
2095
2096 #[test]
2099 fn array_destructuring_short_syntax_produces_variables() {
2100 let d = doc("<?php\n[$first, $second] = getSomething();");
2102 let items = symbol_completions(&d);
2103 let ls = labels(&items);
2104 assert!(
2105 ls.contains(&"$first"),
2106 "$first from array destructuring should be in completions"
2107 );
2108 assert!(
2109 ls.contains(&"$second"),
2110 "$second from array destructuring should be in completions"
2111 );
2112 }
2113
2114 #[test]
2115 fn array_destructuring_variables_have_variable_kind() {
2116 let d = doc("<?php\n[$x, $y, $z] = getData();");
2117 let items = symbol_completions(&d);
2118 for name in &["$x", "$y", "$z"] {
2119 let item = items.iter().find(|i| i.label.as_str() == *name);
2120 assert!(item.is_some(), "{name} should be in completions");
2121 assert_eq!(
2122 item.unwrap().kind,
2123 Some(CompletionItemKind::VARIABLE),
2124 "{name} should have VARIABLE kind"
2125 );
2126 }
2127 }
2128
2129 #[test]
2130 fn array_destructuring_respects_cursor_line_scope() {
2131 let src = "<?php\n// cursor here\n[$early] = getA();\n[$late] = getB();";
2133 let d = doc(src);
2134 let pos = Position {
2136 line: 1,
2137 character: 0,
2138 };
2139 let items = filtered_completions_at(
2140 &d,
2141 &[],
2142 None,
2143 &CompletionCtx {
2144 source: Some(src),
2145 position: Some(pos),
2146 ..Default::default()
2147 },
2148 );
2149 let ls = labels(&items);
2150 assert!(
2151 !ls.contains(&"$early"),
2152 "$early declared after cursor should not appear"
2153 );
2154 assert!(
2155 !ls.contains(&"$late"),
2156 "$late declared after cursor should not appear"
2157 );
2158 }
2159
2160 #[test]
2163 fn include_path_prefix_returns_none_for_non_include_line() {
2164 let src = "<?php\n$x = 'some string';";
2165 let pos = Position {
2166 line: 1,
2167 character: 14,
2168 };
2169 assert!(
2170 include_path_prefix(src, pos).is_none(),
2171 "should not trigger on non-include line"
2172 );
2173 }
2174
2175 #[test]
2176 fn include_path_prefix_returns_none_for_absolute_path() {
2177 let src = "<?php\nrequire '/absolute/path/file.php';";
2178 let pos = Position {
2179 line: 1,
2180 character: 30,
2181 };
2182 assert!(
2183 include_path_prefix(src, pos).is_none(),
2184 "should not trigger for absolute paths"
2185 );
2186 }
2187
2188 #[test]
2189 fn include_path_prefix_returns_none_for_stream_wrapper() {
2190 let src = "<?php\nrequire 'phar://archive.phar/file.php';";
2191 let pos = Position {
2192 line: 1,
2193 character: 35,
2194 };
2195 assert!(
2196 include_path_prefix(src, pos).is_none(),
2197 "should not trigger for stream wrappers"
2198 );
2199 }
2200
2201 #[test]
2202 fn include_path_prefix_returns_relative_dot_slash() {
2203 let src = "<?php\nrequire './lib/Helper";
2204 let pos = Position {
2205 line: 1,
2206 character: 23,
2207 };
2208 let result = include_path_prefix(src, pos);
2209 assert_eq!(
2210 result.as_deref(),
2211 Some("./lib/Helper"),
2212 "should return the typed relative path prefix"
2213 );
2214 }
2215
2216 #[test]
2217 fn include_path_prefix_returns_double_dot_prefix() {
2218 let src = "<?php\ninclude '../utils/";
2219 let pos = Position {
2220 line: 1,
2221 character: 22,
2222 };
2223 let result = include_path_prefix(src, pos);
2224 assert_eq!(
2225 result.as_deref(),
2226 Some("../utils/"),
2227 "should return ../utils/ prefix"
2228 );
2229 }
2230
2231 #[test]
2232 fn include_path_prefix_returns_empty_for_bare_quote() {
2233 let src = "<?php\nrequire '";
2234 let pos = Position {
2235 line: 1,
2236 character: 10,
2237 };
2238 let result = include_path_prefix(src, pos);
2239 assert_eq!(
2240 result.as_deref(),
2241 Some(""),
2242 "bare quote should return empty prefix (list current dir)"
2243 );
2244 }
2245
2246 #[test]
2247 fn include_path_completions_lists_relative_directory() {
2248 use std::fs;
2249
2250 let tmp = tempfile::tempdir().expect("tmpdir");
2251 let subdir = tmp.path().join("lib");
2252 fs::create_dir_all(&subdir).expect("create lib dir");
2253 fs::write(subdir.join("Helper.php"), "<?php").expect("write Helper.php");
2254 fs::write(subdir.join("Utils.php"), "<?php").expect("write Utils.php");
2255 fs::write(subdir.join("README.md"), "# readme").expect("write README.md");
2257
2258 let doc_path = tmp.path().join("index.php");
2259 let doc_uri = Url::from_file_path(&doc_path).expect("doc uri");
2260
2261 let items = include_path_completions(&doc_uri, "./lib/");
2263 let ls: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
2264 assert!(ls.contains(&"Helper.php"), "should list Helper.php");
2265 assert!(ls.contains(&"Utils.php"), "should list Utils.php");
2266 assert!(
2267 !ls.contains(&"README.md"),
2268 "non-PHP files should be excluded"
2269 );
2270 }
2271
2272 #[test]
2273 fn include_path_completions_insert_text_includes_directory_prefix() {
2274 use std::fs;
2275
2276 let tmp = tempfile::tempdir().expect("tmpdir");
2277 let subdir = tmp.path().join("src");
2278 fs::create_dir_all(&subdir).expect("create src dir");
2279 fs::write(subdir.join("Boot.php"), "<?php").expect("write Boot.php");
2280
2281 let doc_path = tmp.path().join("main.php");
2282 let doc_uri = Url::from_file_path(&doc_path).expect("doc uri");
2283
2284 let items = include_path_completions(&doc_uri, "./src/");
2285 let boot = items.iter().find(|i| i.label == "Boot.php");
2286 assert!(boot.is_some(), "Boot.php should be in completions");
2287 assert_eq!(
2288 boot.unwrap().insert_text.as_deref(),
2289 Some("./src/Boot.php"),
2290 "insert_text should include the directory prefix"
2291 );
2292 }
2293
2294 #[test]
2295 fn include_path_completions_is_empty_for_non_existent_directory() {
2296 let tmp = tempfile::tempdir().expect("tmpdir");
2297 let doc_path = tmp.path().join("index.php");
2298 let doc_uri = Url::from_file_path(&doc_path).expect("doc uri");
2299
2300 let items = include_path_completions(&doc_uri, "./nonexistent/");
2301 assert!(
2302 items.is_empty(),
2303 "should return empty list for non-existent directory"
2304 );
2305 }
2306
2307 #[test]
2308 fn include_path_completions_dir_entries_have_folder_kind() {
2309 use std::fs;
2310
2311 let tmp = tempfile::tempdir().expect("tmpdir");
2312 let subdir = tmp.path().join("modules");
2313 fs::create_dir_all(&subdir).expect("create modules dir");
2314
2315 let doc_path = tmp.path().join("index.php");
2316 let doc_uri = Url::from_file_path(&doc_path).expect("doc uri");
2317
2318 let items = include_path_completions(&doc_uri, "");
2319 let modules = items.iter().find(|i| i.label == "modules");
2320 assert!(modules.is_some(), "modules dir should be in completions");
2321 assert_eq!(
2322 modules.unwrap().kind,
2323 Some(CompletionItemKind::FOLDER),
2324 "directory should have FOLDER kind"
2325 );
2326 assert_eq!(
2327 modules.unwrap().insert_text.as_deref(),
2328 Some("modules/"),
2329 "directory insert_text should end with /"
2330 );
2331 }
2332}