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