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