1mod attribute;
2use attribute::attribute_completions;
3
4mod include_path;
5use include_path::{include_path_completions, include_path_prefix};
6
7mod keyword;
8pub use keyword::{
9 keyword_completions, keyword_completions_matching, magic_constant_completions,
10 magic_constant_completions_matching,
11};
12
13mod match_arm;
14use match_arm::match_arm_completions;
15
16mod member;
17use member::{
18 all_instance_members, all_static_members, magic_method_completions, resolve_receiver_class,
19 resolve_static_receiver,
20};
21
22mod namespace;
23use namespace::{
24 collect_classes_with_ns, collect_fqns_with_prefix, current_file_namespace, typed_prefix,
25 use_completion_prefix, use_insert_position,
26};
27
28mod symbols;
29pub use symbols::{
30 builtin_completions, builtin_completions_matching, superglobal_completions,
31 superglobal_completions_matching, symbol_completions, symbol_completions_before,
32};
33
34use std::sync::Arc;
35
36use tower_lsp::lsp_types::{
37 CompletionItem, CompletionItemKind, InsertTextFormat, Position, Range, TextEdit, Url,
38};
39
40use tower_lsp::lsp_types::{Documentation, MarkupContent, MarkupKind};
41
42use crate::document::ast::{ParsedDoc, format_type_hint};
43use crate::hover::format_params_str;
44use crate::lang::docblock::find_docblock;
45use crate::lang::phpstorm_meta::PhpStormMeta;
46use crate::text::{camel_sort_key, utf16_offset_to_byte};
47use crate::types::type_map::{enclosing_class_at, params_of_function, params_of_method};
48use std::collections::HashMap;
49
50fn callable_item(label: &str, kind: CompletionItemKind, has_params: bool) -> CompletionItem {
56 if has_params {
57 CompletionItem {
58 label: label.to_string(),
59 kind: Some(kind),
60 insert_text: Some(format!("{}($1)", label)),
61 insert_text_format: Some(InsertTextFormat::SNIPPET),
62 ..Default::default()
63 }
64 } else {
65 CompletionItem {
66 label: label.to_string(),
67 kind: Some(kind),
68 insert_text: Some(format!("{}()", label)),
69 ..Default::default()
70 }
71 }
72}
73
74fn named_arg_item(
79 label: &str,
80 kind: CompletionItemKind,
81 params: &[php_ast::Param<'_, '_>],
82) -> Option<CompletionItem> {
83 if params.is_empty() {
84 return None;
85 }
86 let named_label = format!(
87 "{}({})",
88 label,
89 params
90 .iter()
91 .map(|p| format!("{}:", &p.name.to_string()))
92 .collect::<Vec<_>>()
93 .join(", ")
94 );
95 let snippet = format!(
96 "{}({})",
97 label,
98 params
99 .iter()
100 .enumerate()
101 .map(|(i, p)| format!("{}: ${}", p.name, i + 1))
102 .collect::<Vec<_>>()
103 .join(", ")
104 );
105 Some(CompletionItem {
106 label: named_label,
107 kind: Some(kind),
108 insert_text: Some(snippet),
109 insert_text_format: Some(InsertTextFormat::SNIPPET),
110 detail: Some("named args".to_string()),
111 ..Default::default()
112 })
113}
114
115fn build_function_sig(
118 name: &str,
119 params: &[php_ast::Param<'_, '_>],
120 return_type: Option<&php_ast::TypeHint<'_, '_>>,
121) -> String {
122 let params_str = format_params_str(params);
123 let ret = return_type
124 .map(|r| format!(": {}", format_type_hint(r)))
125 .unwrap_or_default();
126 format!("function {}({}){}", name, params_str, ret)
127}
128
129fn docblock_docs(doc: &ParsedDoc, sym_name: &str) -> Option<Documentation> {
131 let db = find_docblock(&doc.program().stmts, sym_name)?;
132 let md = db.to_markdown();
133 if md.is_empty() {
134 None
135 } else {
136 Some(Documentation::MarkupContent(MarkupContent {
137 kind: MarkupKind::Markdown,
138 value: md,
139 }))
140 }
141}
142
143fn resolve_attribute_class(source: &str, position: Position) -> Option<String> {
146 let line = source.lines().nth(position.line as usize)?;
147 let col = utf16_offset_to_byte(line, position.character as usize);
148 let before = line[..col].trim_end_matches('(').trim_end();
149 let hash_pos = before.rfind("#[")?;
151 let after_bracket = before[hash_pos + 2..].trim_start();
152 let name: String = after_bracket
154 .trim_start_matches('\\')
155 .rsplit('\\')
156 .next()
157 .unwrap_or("")
158 .chars()
159 .take_while(|c| c.is_alphanumeric() || *c == '_')
160 .collect();
161 if name.is_empty() { None } else { Some(name) }
162}
163
164fn resolve_call_params(
165 source: &str,
166 doc: &ParsedDoc,
167 other_docs: &[Arc<ParsedDoc>],
168 position: Position,
169) -> Vec<String> {
170 let line = match source.lines().nth(position.line as usize) {
171 Some(l) => l,
172 None => return vec![],
173 };
174 let col = utf16_offset_to_byte(line, position.character as usize);
175 let before = &line[..col];
176 let before = before.strip_suffix('(').unwrap_or(before);
177 let func_name: String = before
178 .chars()
179 .rev()
180 .take_while(|&c| c.is_alphanumeric() || c == '_')
181 .collect::<String>()
182 .chars()
183 .rev()
184 .collect();
185 if func_name.is_empty() {
186 return vec![];
187 }
188 let mut params = params_of_function(doc, &func_name);
189 if params.is_empty() {
190 for other in other_docs {
191 params = params_of_function(other, &func_name);
192 if !params.is_empty() {
193 break;
194 }
195 }
196 }
197 params
198}
199
200pub type ClassDocLookup<'a> = &'a dyn Fn(&str) -> Option<Arc<ParsedDoc>>;
204
205pub type WorkspaceClassSearch<'a> = &'a dyn Fn(&str) -> Vec<(String, CompletionItemKind, String)>;
211
212#[derive(Default)]
215pub struct CompletionCtx<'a> {
216 pub source: Option<&'a str>,
217 pub position: Option<Position>,
218 pub meta: Option<&'a PhpStormMeta>,
219 pub doc_uri: Option<&'a Url>,
220 pub file_imports: Option<&'a HashMap<String, String>>,
221 pub find_class_doc: Option<ClassDocLookup<'a>>,
227 pub workspace_class_search: Option<WorkspaceClassSearch<'a>>,
232 pub analysis: Option<&'a mir_analyzer::FileAnalysis>,
236 pub session: Option<std::sync::Arc<mir_analyzer::AnalysisSession>>,
239 pub laravel: Option<&'a crate::laravel::LaravelIndex>,
244}
245
246pub(crate) fn cursor_in_string_or_comment(source: &str, cursor_byte: usize) -> bool {
254 #[derive(PartialEq)]
255 enum S {
256 Normal,
257 Single,
258 Double,
259 Line,
260 Block,
261 }
262 let bytes = source.as_bytes();
263 let limit = bytes.len().min(cursor_byte);
264 let mut i = 0usize;
265 let mut state = S::Normal;
266 while i < limit {
267 match state {
268 S::Normal => match bytes[i] {
269 b'\'' => {
270 state = S::Single;
271 i += 1;
272 }
273 b'"' => {
274 state = S::Double;
275 i += 1;
276 }
277 b'/' if i + 1 < limit && bytes[i + 1] == b'/' => {
278 state = S::Line;
279 i += 2;
280 }
281 b'#' if !(i + 1 < limit && bytes[i + 1] == b'[') => {
283 state = S::Line;
284 i += 1;
285 }
286 b'/' if i + 1 < limit && bytes[i + 1] == b'*' => {
287 state = S::Block;
288 i += 2;
289 }
290 _ => {
291 i += 1;
292 }
293 },
294 S::Single => match bytes[i] {
295 b'\\' => {
296 i += 2;
297 }
298 b'\'' => {
299 state = S::Normal;
300 i += 1;
301 }
302 _ => {
303 i += 1;
304 }
305 },
306 S::Double => match bytes[i] {
307 b'\\' => {
308 i += 2;
309 }
310 b'"' => {
311 state = S::Normal;
312 i += 1;
313 }
314 _ => {
315 i += 1;
316 }
317 },
318 S::Line => {
319 if bytes[i] == b'\n' {
320 state = S::Normal;
321 }
322 i += 1;
323 }
324 S::Block => {
325 if bytes[i] == b'*' && i + 1 < limit && bytes[i + 1] == b'/' {
326 state = S::Normal;
327 i += 2;
328 } else {
329 i += 1;
330 }
331 }
332 }
333 }
334 state != S::Normal
335}
336
337pub fn filtered_completions_at(
340 doc: &ParsedDoc,
341 other_docs: &[Arc<ParsedDoc>],
342 trigger_character: Option<&str>,
343 ctx: &CompletionCtx<'_>,
344) -> Vec<CompletionItem> {
345 let source = ctx.source;
346 let position = ctx.position;
347
348 let doc_uri = ctx.doc_uri;
349
350 let laravel_completions = source
355 .zip(position)
356 .and_then(|(src, pos)| crate::laravel::completions_for_string_key(src, pos, ctx.laravel));
357
358 if let (Some(src), Some(pos)) = (source, position) {
363 let cursor_byte = doc.view().byte_of_position(pos) as usize;
364 if cursor_in_string_or_comment(src, cursor_byte)
365 && include_path_prefix(src, pos).is_none()
366 && laravel_completions.is_none()
367 {
368 return vec![];
369 }
370 }
371 let empty_imports = HashMap::new();
372 let imports = ctx.file_imports.unwrap_or(&empty_imports);
373
374 match trigger_character {
375 Some("$") => {
376 let mut items = superglobal_completions();
377 items.extend(
378 symbol_completions(doc)
379 .into_iter()
380 .filter(|i| i.kind == Some(CompletionItemKind::VARIABLE)),
381 );
382 items
383 }
384 Some(">") => {
385 if let (Some(src), Some(pos)) = (source, position)
387 && let Some(class_names) = resolve_receiver_class(src, doc, pos, ctx.analysis)
388 {
389 let mut items = Vec::new();
391 let mut seen = std::collections::HashSet::new();
392 for class_name in class_names.split('|') {
393 let class_name = class_name.trim();
394 for item in all_instance_members(
395 class_name,
396 doc,
397 other_docs,
398 ctx.find_class_doc,
399 ctx.session.as_deref(),
400 ) {
401 if seen.insert(item.label.clone()) {
402 items.push(item);
403 }
404 }
405 }
406 if !items.is_empty() {
407 return items;
408 }
409 }
410 symbol_completions(doc)
412 .into_iter()
413 .filter(|i| i.kind == Some(CompletionItemKind::METHOD))
414 .collect()
415 }
416 Some(":") => {
417 if let (Some(src), Some(pos)) = (source, position)
419 && let Some(class_name) =
420 resolve_static_receiver(src, doc, other_docs, pos, imports)
421 {
422 let items = all_static_members(
423 &class_name,
424 doc,
425 other_docs,
426 ctx.find_class_doc,
427 ctx.session.as_deref(),
428 );
429 if !items.is_empty() {
430 return items;
431 }
432 }
433 vec![]
434 }
435 Some("[") => {
436 if let (Some(src), Some(pos)) = (source, position) {
438 let line = src.lines().nth(pos.line as usize).unwrap_or("");
439 let col = utf16_offset_to_byte(line, pos.character as usize);
440 let before = &line[..col];
441 if before.trim_end_matches('[').trim_end().ends_with('#') {
442 return attribute_completions(src, pos, doc, other_docs, imports);
443 }
444 }
445 vec![]
446 }
447 Some("(") => {
448 if let (Some(src), Some(pos)) = (source, position) {
450 let params = resolve_call_params(src, doc, other_docs, pos);
451 if !params.is_empty() {
452 return params
453 .into_iter()
454 .map(|p| CompletionItem {
455 label: format!("{p}:"),
456 kind: Some(CompletionItemKind::VARIABLE),
457 ..Default::default()
458 })
459 .collect();
460 }
461 if let Some(attr_class) = resolve_attribute_class(src, pos) {
463 let mut attr_params = params_of_method(doc, &attr_class, "__construct");
464 if attr_params.is_empty() {
465 for other in other_docs {
466 attr_params = params_of_method(other, &attr_class, "__construct");
467 if !attr_params.is_empty() {
468 break;
469 }
470 }
471 }
472 if !attr_params.is_empty() {
473 return attr_params
474 .into_iter()
475 .map(|p| CompletionItem {
476 label: format!("{p}:"),
477 kind: Some(CompletionItemKind::VARIABLE),
478 detail: Some(format!("#{attr_class} argument")),
479 ..Default::default()
480 })
481 .collect();
482 }
483 }
484 }
485 vec![]
486 }
487 _ => {
488 if let (Some(src), Some(pos)) = (source, position) {
491 let line = src.lines().nth(pos.line as usize).unwrap_or("");
492 let col = utf16_offset_to_byte(line, pos.character as usize);
493 let before = &line[..col];
494 let pre_colon = before.trim_end_matches(|c: char| c.is_alphanumeric() || c == '_');
495 if pre_colon.ends_with("::") {
496 let colon_end_char = pre_colon.encode_utf16().count() as u32;
497 let colon_pos = tower_lsp::lsp_types::Position {
498 line: pos.line,
499 character: colon_end_char,
500 };
501 if let Some(class_name) =
502 resolve_static_receiver(src, doc, other_docs, colon_pos, imports)
503 {
504 let items = all_static_members(
505 &class_name,
506 doc,
507 other_docs,
508 ctx.find_class_doc,
509 ctx.session.as_deref(),
510 );
511 if !items.is_empty() {
512 return items;
513 }
514 }
515 }
516 }
517
518 if let (Some(src), Some(pos)) = (source, position) {
522 let line = src.lines().nth(pos.line as usize).unwrap_or("");
523 let col = utf16_offset_to_byte(line, pos.character as usize);
524 let before = &line[..col];
525 let pre_arrow = before.trim_end_matches(|c: char| c.is_alphanumeric() || c == '_');
527 let has_arrow = pre_arrow.ends_with("->") || pre_arrow.ends_with("?->");
528 if has_arrow {
529 let arrow_end_char = pre_arrow.encode_utf16().count() as u32;
535 let arrow_pos = tower_lsp::lsp_types::Position {
536 line: pos.line,
537 character: arrow_end_char,
538 };
539 if let Some(cls) = resolve_receiver_class(src, doc, arrow_pos, ctx.analysis) {
540 let mut items = Vec::new();
541 let mut seen = std::collections::HashSet::new();
542 for class_name in cls.split('|') {
543 for item in all_instance_members(
544 class_name.trim(),
545 doc,
546 other_docs,
547 ctx.find_class_doc,
548 ctx.session.as_deref(),
549 ) {
550 if seen.insert(item.label.clone()) {
551 items.push(item);
552 }
553 }
554 }
555 if !items.is_empty() {
556 let prefix = before.strip_prefix(pre_arrow).unwrap_or("").to_string();
558 if !prefix.is_empty() {
559 let fq = crate::text::FuzzyQuery::new(&prefix);
560 items.retain(|i| {
561 let match_against = if i.label.starts_with('$') {
562 i.label.strip_prefix('$').unwrap_or(&i.label)
563 } else {
564 &i.label
565 };
566 fq.camel_match(match_against)
567 });
568 for item in &mut items {
569 let match_against = if item.label.starts_with('$') {
570 item.label.strip_prefix('$').unwrap_or(&item.label)
571 } else {
572 &item.label
573 };
574 item.sort_text =
575 Some(crate::text::camel_sort_key(&prefix, match_against));
576 item.filter_text = Some(item.label.clone());
577 }
578 }
579 return items;
580 }
581 }
582 }
583 }
584
585 if let (Some(src), Some(pos)) = (source, position) {
587 let line = src.lines().nth(pos.line as usize).unwrap_or("");
588 let col = utf16_offset_to_byte(line, pos.character as usize);
589 let before = &line[..col];
590 let pre_ident =
591 before.trim_end_matches(|c: char| c.is_alphanumeric() || c == '_' || c == '\\');
592 if pre_ident.trim_end().ends_with("#[") || pre_ident.trim_end() == "#[" {
593 let items = attribute_completions(src, pos, doc, other_docs, imports);
594 if !items.is_empty() {
595 return items;
596 }
597 }
598 }
599
600 if let (Some(src), Some(pos)) = (source, position)
602 && let Some(use_prefix) = use_completion_prefix(src, pos)
603 {
604 let mut use_items: Vec<CompletionItem> = Vec::new();
605 for other in other_docs {
606 collect_fqns_with_prefix(
607 &other.program().stmts,
608 "",
609 &use_prefix,
610 &mut use_items,
611 );
612 }
613 collect_fqns_with_prefix(&doc.program().stmts, "", &use_prefix, &mut use_items);
615 if !use_items.is_empty() {
616 return use_items;
617 }
618 }
619
620 if let (Some(src), Some(pos), Some(uri)) = (source, position, doc_uri)
622 && let Some(prefix) = include_path_prefix(src, pos)
623 {
624 let items = include_path_completions(uri, &prefix);
627 return items;
628 }
629
630 if let Some(items) = laravel_completions {
632 return items;
633 }
634
635 let other_classes_cell: std::cell::OnceCell<
640 Vec<Vec<(String, CompletionItemKind, String)>>,
641 > = std::cell::OnceCell::new();
642 let other_classes = || {
643 other_classes_cell.get_or_init(|| {
644 other_docs
645 .iter()
646 .map(|other| {
647 let mut classes = Vec::new();
648 collect_classes_with_ns(&other.program().stmts, "", &mut classes);
649 classes
650 })
651 .collect()
652 })
653 };
654
655 if let (Some(src), Some(pos)) = (source, position)
657 && let Some(prefix) = typed_prefix(Some(src), Some(pos))
658 && prefix.contains('\\')
659 {
660 let is_use = use_completion_prefix(src, pos).is_some();
662 if !is_use {
663 let prefix_lc = prefix.trim_start_matches('\\').to_lowercase();
664 let mut ns_items: Vec<CompletionItem> = Vec::new();
665 for classes in other_classes() {
666 for (label, kind, fqn) in classes {
667 if fqn
668 .get(..prefix_lc.len())
669 .is_some_and(|s| s.eq_ignore_ascii_case(&prefix_lc))
670 {
671 ns_items.push(CompletionItem {
672 label: label.clone(),
673 kind: Some(*kind),
674 insert_text: Some(label.clone()),
675 detail: Some(fqn.clone()),
676 ..Default::default()
677 });
678 }
679 }
680 }
681 let mut classes = Vec::new();
682 collect_classes_with_ns(&doc.program().stmts, "", &mut classes);
683 for (label, kind, fqn) in classes {
684 if fqn
685 .get(..prefix_lc.len())
686 .is_some_and(|s| s.eq_ignore_ascii_case(&prefix_lc))
687 {
688 ns_items.push(CompletionItem {
689 label: label.clone(),
690 kind: Some(kind),
691 insert_text: Some(label),
692 detail: Some(fqn),
693 ..Default::default()
694 });
695 }
696 }
697 if !ns_items.is_empty() {
698 return ns_items;
699 }
700 }
701 }
702
703 if let (Some(src), Some(pos)) = (source, position)
705 && let Some(match_items) = match_arm_completions(
706 src,
707 doc,
708 other_docs,
709 pos,
710 ctx.analysis,
711 ctx.find_class_doc,
712 )
713 && !match_items.is_empty()
714 {
715 let mut all = match_items;
716 let mut normal_items = keyword_completions();
718 normal_items.extend(magic_constant_completions());
719 normal_items.extend(builtin_completions());
720 normal_items.extend(superglobal_completions());
721 normal_items.extend(symbol_completions(doc));
722 all.extend(normal_items);
723
724 let mut seen = std::collections::HashSet::new();
726 all.retain(|i| seen.insert(i.label.clone()));
727
728 return all;
729 }
730
731 let mut magic_items: Vec<CompletionItem> = Vec::new();
733 if let (Some(src), Some(pos)) = (source, position)
734 && enclosing_class_at(src, doc, pos).is_some()
735 {
736 magic_items.extend(magic_method_completions());
737 }
738
739 let prefix = typed_prefix(source, position).unwrap_or_default();
745 let ident_query = (!prefix.is_empty() && !prefix.contains('\\'))
746 .then(|| crate::text::FuzzyQuery::new(&prefix));
747 let keep_static =
748 |label: &str| ident_query.as_ref().is_none_or(|fq| fq.camel_match(label));
749
750 let mut items = keyword_completions_matching(&keep_static);
751 items.extend(magic_constant_completions_matching(&keep_static));
752 items.extend(builtin_completions_matching(&keep_static));
753 items.extend(superglobal_completions_matching(&keep_static));
754 let sym_items = if let (Some(_src), Some(pos)) = (source, position) {
756 symbol_completions_before(doc, pos.line)
757 } else {
758 symbol_completions(doc)
759 };
760 items.extend(sym_items);
761 items.extend(magic_items);
762
763 let cur_ns = current_file_namespace(&doc.program().stmts);
764
765 let use_pos = source.map(use_insert_position);
770
771 let push_class_item = |items: &mut Vec<CompletionItem>,
775 label: &str,
776 kind: CompletionItemKind,
777 fqn: &str| {
778 let additional_text_edits = if let Some(pos) = use_pos {
779 let in_same_ns = !cur_ns.is_empty() && fqn == format!("{}\\{}", cur_ns, label);
780 let is_global = !fqn.contains('\\');
781 let already = imports.contains_key(label);
782 if !in_same_ns && !is_global && !already {
783 Some(vec![TextEdit {
784 range: Range {
785 start: pos,
786 end: pos,
787 },
788 new_text: format!("use {};\n", fqn),
789 }])
790 } else {
791 None
792 }
793 } else {
794 None
795 };
796 items.push(CompletionItem {
797 label: label.to_string(),
798 kind: Some(kind),
799 detail: if fqn.contains('\\') {
800 Some(fqn.to_string())
801 } else {
802 None
803 },
804 additional_text_edits,
805 ..Default::default()
806 });
807 };
808
809 for (other, classes) in other_docs.iter().zip(other_classes()) {
810 for (label, kind, fqn) in classes {
812 push_class_item(&mut items, label, *kind, fqn);
813 }
814 let cross: Vec<CompletionItem> = symbol_completions(other)
816 .into_iter()
817 .filter(|i| {
818 !matches!(
819 i.kind,
820 Some(CompletionItemKind::CLASS)
821 | Some(CompletionItemKind::INTERFACE)
822 | Some(CompletionItemKind::ENUM)
823 ) && i.kind != Some(CompletionItemKind::VARIABLE)
824 })
825 .collect();
826 items.extend(cross);
827 }
828
829 if !prefix.is_empty()
834 && !prefix.contains('\\')
835 && let Some(search) = ctx.workspace_class_search
836 {
837 for (label, kind, fqn) in search(&prefix) {
838 push_class_item(&mut items, &label, kind, &fqn);
839 }
840 }
841
842 let mut seen = std::collections::HashSet::new();
843 items.retain(|i| seen.insert(i.label.clone()));
844
845 if prefix.contains('\\') {
846 let ns_prefix = prefix.trim_start_matches('\\').to_lowercase();
848 items.retain(|i| {
849 let fqn = i.detail.as_deref().unwrap_or(&i.label);
850 fqn.get(..ns_prefix.len())
851 .is_some_and(|s| s.eq_ignore_ascii_case(&ns_prefix))
852 });
853 } else if !prefix.is_empty() {
854 let fq = crate::text::FuzzyQuery::new(&prefix);
855 items.retain(|i| fq.camel_match(&i.label));
856 for item in &mut items {
857 item.sort_text = Some(camel_sort_key(&prefix, &item.label));
858 item.filter_text = Some(item.label.clone());
859 }
860 }
861 items
862 }
863 }
864}
865
866#[cfg(test)]
867mod tests {
868 use super::*;
869
870 fn labels(items: &[CompletionItem]) -> Vec<&str> {
871 items.iter().map(|i| i.label.as_str()).collect()
872 }
873
874 #[test]
875 fn keywords_list_is_non_empty() {
876 let kws = keyword_completions();
877 assert!(
878 kws.len() >= 20,
879 "expected at least 20 keywords, got {}",
880 kws.len()
881 );
882 }
883
884 #[test]
885 fn keywords_contain_common_php_keywords() {
886 let kws = keyword_completions();
887 let ls = labels(&kws);
888 for expected in &[
889 "function",
890 "class",
891 "return",
892 "foreach",
893 "match",
894 "namespace",
895 ] {
896 assert!(ls.contains(expected), "missing keyword: {expected}");
897 }
898 }
899
900 #[test]
901 fn type_keywords_present() {
902 let kws = keyword_completions();
903 let ls = labels(&kws);
904 for expected in &[
905 "bool", "float", "int", "iterable", "mixed", "never", "object", "string", "void",
906 "parent",
907 ] {
908 assert!(ls.contains(expected), "missing type keyword: {expected}");
909 }
910 }
911
912 #[test]
913 fn all_keyword_items_have_keyword_kind() {
914 for item in keyword_completions() {
915 assert_eq!(item.kind, Some(CompletionItemKind::KEYWORD));
916 }
917 }
918
919 #[test]
920 fn magic_constants_all_present() {
921 let items = magic_constant_completions();
922 let ls: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
923 for name in &[
924 "__FILE__",
925 "__DIR__",
926 "__LINE__",
927 "__CLASS__",
928 "__FUNCTION__",
929 "__METHOD__",
930 "__NAMESPACE__",
931 "__TRAIT__",
932 ] {
933 assert!(ls.contains(name), "missing magic constant: {name}");
934 }
935 }
936
937 #[test]
938 fn magic_constants_have_constant_kind() {
939 for item in magic_constant_completions() {
940 assert_eq!(
941 item.kind,
942 Some(CompletionItemKind::CONSTANT),
943 "{} should have CONSTANT kind",
944 item.label
945 );
946 }
947 }
948
949 #[test]
950 fn resolve_attribute_class_extracts_name() {
951 let src = "<?php\n#[Route(\n";
952 let pos = Position {
954 line: 1,
955 character: 8,
956 };
957 let result = resolve_attribute_class(src, pos);
958 assert_eq!(result.as_deref(), Some("Route"));
959 }
960
961 #[test]
962 fn resolve_attribute_class_fqn_extracts_short_name() {
963 let src = "<?php\n#[\\Symfony\\Component\\Routing\\Route(\n";
964 let pos = Position {
965 line: 1,
966 character: 38,
967 };
968 let result = resolve_attribute_class(src, pos);
969 assert_eq!(result.as_deref(), Some("Route"));
970 }
971
972 #[test]
973 fn resolve_attribute_class_returns_none_for_regular_call() {
974 let src = "<?php\nsomeFunction(\n";
975 let pos = Position {
976 line: 1,
977 character: 14,
978 };
979 let result = resolve_attribute_class(src, pos);
980 assert!(result.is_none(), "should not match regular function call");
981 }
982}