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