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