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