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::{keyword_completions, magic_constant_completions};
9
10mod match_arm;
11use match_arm::match_arm_completions;
12
13mod member;
14use member::{
15 all_instance_members, all_static_members, magic_method_completions, resolve_receiver_class,
16 resolve_static_receiver,
17};
18
19mod namespace;
20use namespace::{
21 collect_classes_with_ns, collect_fqns_with_prefix, current_file_namespace, typed_prefix,
22 use_completion_prefix, use_insert_position,
23};
24
25mod symbols;
26pub use symbols::{
27 builtin_completions, superglobal_completions, symbol_completions, symbol_completions_before,
28};
29
30use std::sync::Arc;
31
32use tower_lsp::lsp_types::{
33 CompletionItem, CompletionItemKind, InsertTextFormat, Position, Range, TextEdit, Url,
34};
35
36use tower_lsp::lsp_types::{Documentation, MarkupContent, MarkupKind};
37
38use crate::document::ast::{ParsedDoc, format_type_hint};
39use crate::hover::format_params_str;
40use crate::lang::docblock::find_docblock;
41use crate::lang::phpstorm_meta::PhpStormMeta;
42use crate::text::{camel_sort_key, utf16_offset_to_byte};
43use crate::types::type_map::{TypeMap, enclosing_class_at, params_of_function, params_of_method};
44use std::collections::HashMap;
45
46fn callable_item(label: &str, kind: CompletionItemKind, has_params: bool) -> CompletionItem {
52 if has_params {
53 CompletionItem {
54 label: label.to_string(),
55 kind: Some(kind),
56 insert_text: Some(format!("{}($1)", label)),
57 insert_text_format: Some(InsertTextFormat::SNIPPET),
58 ..Default::default()
59 }
60 } else {
61 CompletionItem {
62 label: label.to_string(),
63 kind: Some(kind),
64 insert_text: Some(format!("{}()", label)),
65 ..Default::default()
66 }
67 }
68}
69
70fn named_arg_item(
75 label: &str,
76 kind: CompletionItemKind,
77 params: &[php_ast::Param<'_, '_>],
78) -> Option<CompletionItem> {
79 if params.is_empty() {
80 return None;
81 }
82 let named_label = format!(
83 "{}({})",
84 label,
85 params
86 .iter()
87 .map(|p| format!("{}:", &p.name.to_string()))
88 .collect::<Vec<_>>()
89 .join(", ")
90 );
91 let snippet = format!(
92 "{}({})",
93 label,
94 params
95 .iter()
96 .enumerate()
97 .map(|(i, p)| format!("{}: ${}", p.name, i + 1))
98 .collect::<Vec<_>>()
99 .join(", ")
100 );
101 Some(CompletionItem {
102 label: named_label,
103 kind: Some(kind),
104 insert_text: Some(snippet),
105 insert_text_format: Some(InsertTextFormat::SNIPPET),
106 detail: Some("named args".to_string()),
107 ..Default::default()
108 })
109}
110
111fn build_function_sig(
114 name: &str,
115 params: &[php_ast::Param<'_, '_>],
116 return_type: Option<&php_ast::TypeHint<'_, '_>>,
117) -> String {
118 let params_str = format_params_str(params);
119 let ret = return_type
120 .map(|r| format!(": {}", format_type_hint(r)))
121 .unwrap_or_default();
122 format!("function {}({}){}", name, params_str, ret)
123}
124
125fn docblock_docs(doc: &ParsedDoc, sym_name: &str) -> Option<Documentation> {
127 let db = find_docblock(&doc.program().stmts, sym_name)?;
128 let md = db.to_markdown();
129 if md.is_empty() {
130 None
131 } else {
132 Some(Documentation::MarkupContent(MarkupContent {
133 kind: MarkupKind::Markdown,
134 value: md,
135 }))
136 }
137}
138
139fn resolve_attribute_class(source: &str, position: Position) -> Option<String> {
142 let line = source.lines().nth(position.line as usize)?;
143 let col = utf16_offset_to_byte(line, position.character as usize);
144 let before = line[..col].trim_end_matches('(').trim_end();
145 let hash_pos = before.rfind("#[")?;
147 let after_bracket = before[hash_pos + 2..].trim_start();
148 let name: String = after_bracket
150 .trim_start_matches('\\')
151 .rsplit('\\')
152 .next()
153 .unwrap_or("")
154 .chars()
155 .take_while(|c| c.is_alphanumeric() || *c == '_')
156 .collect();
157 if name.is_empty() { None } else { Some(name) }
158}
159
160fn resolve_call_params(
161 source: &str,
162 doc: &ParsedDoc,
163 other_docs: &[Arc<ParsedDoc>],
164 position: Position,
165) -> Vec<String> {
166 let line = match source.lines().nth(position.line as usize) {
167 Some(l) => l,
168 None => return vec![],
169 };
170 let col = utf16_offset_to_byte(line, position.character as usize);
171 let before = &line[..col];
172 let before = before.strip_suffix('(').unwrap_or(before);
173 let func_name: String = before
174 .chars()
175 .rev()
176 .take_while(|&c| c.is_alphanumeric() || c == '_')
177 .collect::<String>()
178 .chars()
179 .rev()
180 .collect();
181 if func_name.is_empty() {
182 return vec![];
183 }
184 let mut params = params_of_function(doc, &func_name);
185 if params.is_empty() {
186 for other in other_docs {
187 params = params_of_function(other, &func_name);
188 if !params.is_empty() {
189 break;
190 }
191 }
192 }
193 params
194}
195
196pub type ClassDocLookup<'a> = &'a dyn Fn(&str) -> Option<Arc<ParsedDoc>>;
200
201pub type WorkspaceClassSearch<'a> = &'a dyn Fn(&str) -> Vec<(String, CompletionItemKind, String)>;
207
208#[derive(Default)]
211pub struct CompletionCtx<'a> {
212 pub source: Option<&'a str>,
213 pub position: Option<Position>,
214 pub meta: Option<&'a PhpStormMeta>,
215 pub doc_uri: Option<&'a Url>,
216 pub file_imports: Option<&'a HashMap<String, String>>,
217 pub find_class_doc: Option<ClassDocLookup<'a>>,
223 pub workspace_class_search: Option<WorkspaceClassSearch<'a>>,
228 pub analysis: Option<&'a mir_analyzer::FileAnalysis>,
232 pub type_map: Option<&'a dyn Fn() -> Arc<TypeMap>>,
238 pub session: Option<std::sync::Arc<mir_analyzer::AnalysisSession>>,
241}
242
243fn whole_doc_type_map(
245 ctx: &CompletionCtx<'_>,
246 doc: &ParsedDoc,
247 meta: Option<&PhpStormMeta>,
248) -> Arc<TypeMap> {
249 match ctx.type_map {
250 Some(get) => get(),
251 None => Arc::new(TypeMap::from_doc_with_meta(doc, meta)),
252 }
253}
254
255pub(crate) fn cursor_in_string_or_comment(source: &str, cursor_byte: usize) -> bool {
263 #[derive(PartialEq)]
264 enum S {
265 Normal,
266 Single,
267 Double,
268 Line,
269 Block,
270 }
271 let bytes = source.as_bytes();
272 let limit = bytes.len().min(cursor_byte);
273 let mut i = 0usize;
274 let mut state = S::Normal;
275 while i < limit {
276 match state {
277 S::Normal => match bytes[i] {
278 b'\'' => {
279 state = S::Single;
280 i += 1;
281 }
282 b'"' => {
283 state = S::Double;
284 i += 1;
285 }
286 b'/' if i + 1 < limit && bytes[i + 1] == b'/' => {
287 state = S::Line;
288 i += 2;
289 }
290 b'#' if !(i + 1 < limit && bytes[i + 1] == b'[') => {
292 state = S::Line;
293 i += 1;
294 }
295 b'/' if i + 1 < limit && bytes[i + 1] == b'*' => {
296 state = S::Block;
297 i += 2;
298 }
299 _ => {
300 i += 1;
301 }
302 },
303 S::Single => match bytes[i] {
304 b'\\' => {
305 i += 2;
306 }
307 b'\'' => {
308 state = S::Normal;
309 i += 1;
310 }
311 _ => {
312 i += 1;
313 }
314 },
315 S::Double => match bytes[i] {
316 b'\\' => {
317 i += 2;
318 }
319 b'"' => {
320 state = S::Normal;
321 i += 1;
322 }
323 _ => {
324 i += 1;
325 }
326 },
327 S::Line => {
328 if bytes[i] == b'\n' {
329 state = S::Normal;
330 }
331 i += 1;
332 }
333 S::Block => {
334 if bytes[i] == b'*' && i + 1 < limit && bytes[i + 1] == b'/' {
335 state = S::Normal;
336 i += 2;
337 } else {
338 i += 1;
339 }
340 }
341 }
342 }
343 state != S::Normal
344}
345
346pub fn filtered_completions_at(
349 doc: &ParsedDoc,
350 other_docs: &[Arc<ParsedDoc>],
351 trigger_character: Option<&str>,
352 ctx: &CompletionCtx<'_>,
353) -> Vec<CompletionItem> {
354 let source = ctx.source;
355 let position = ctx.position;
356
357 let doc_uri = ctx.doc_uri;
358
359 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) && include_path_prefix(src, pos).is_none()
365 {
366 return vec![];
367 }
368 }
369 let meta = ctx.meta;
370 let empty_imports = HashMap::new();
371 let imports = ctx.file_imports.unwrap_or(&empty_imports);
372
373 match trigger_character {
374 Some("$") => {
375 let mut items = superglobal_completions();
376 items.extend(
377 symbol_completions(doc)
378 .into_iter()
379 .filter(|i| i.kind == Some(CompletionItemKind::VARIABLE)),
380 );
381 items
382 }
383 Some(">") => {
384 if let (Some(src), Some(pos)) = (source, position) {
386 let type_map = whole_doc_type_map(ctx, doc, meta);
387 if let Some(class_names) =
388 resolve_receiver_class(src, doc, pos, ctx.analysis, &type_map)
389 {
390 let mut items = Vec::new();
392 let mut seen = std::collections::HashSet::new();
393 for class_name in class_names.split('|') {
394 let class_name = class_name.trim();
395 for item in all_instance_members(
396 class_name,
397 doc,
398 other_docs,
399 ctx.find_class_doc,
400 ctx.session.as_deref(),
401 ) {
402 if seen.insert(item.label.clone()) {
403 items.push(item);
404 }
405 }
406 }
407 if !items.is_empty() {
408 return items;
409 }
410 }
411 }
412 symbol_completions(doc)
414 .into_iter()
415 .filter(|i| i.kind == Some(CompletionItemKind::METHOD))
416 .collect()
417 }
418 Some(":") => {
419 if let (Some(src), Some(pos)) = (source, position)
421 && let Some(class_name) =
422 resolve_static_receiver(src, doc, other_docs, pos, imports)
423 {
424 let items = all_static_members(
425 &class_name,
426 doc,
427 other_docs,
428 ctx.find_class_doc,
429 ctx.session.as_deref(),
430 );
431 if !items.is_empty() {
432 return items;
433 }
434 }
435 vec![]
436 }
437 Some("[") => {
438 if let (Some(src), Some(pos)) = (source, position) {
440 let line = src.lines().nth(pos.line as usize).unwrap_or("");
441 let col = utf16_offset_to_byte(line, pos.character as usize);
442 let before = &line[..col];
443 if before.trim_end_matches('[').trim_end().ends_with('#') {
444 return attribute_completions(src, pos, doc, other_docs, imports);
445 }
446 }
447 vec![]
448 }
449 Some("(") => {
450 if let (Some(src), Some(pos)) = (source, position) {
452 let params = resolve_call_params(src, doc, other_docs, pos);
453 if !params.is_empty() {
454 return params
455 .into_iter()
456 .map(|p| CompletionItem {
457 label: format!("{p}:"),
458 kind: Some(CompletionItemKind::VARIABLE),
459 ..Default::default()
460 })
461 .collect();
462 }
463 if let Some(attr_class) = resolve_attribute_class(src, pos) {
465 let mut attr_params = params_of_method(doc, &attr_class, "__construct");
466 if attr_params.is_empty() {
467 for other in other_docs {
468 attr_params = params_of_method(other, &attr_class, "__construct");
469 if !attr_params.is_empty() {
470 break;
471 }
472 }
473 }
474 if !attr_params.is_empty() {
475 return attr_params
476 .into_iter()
477 .map(|p| CompletionItem {
478 label: format!("{p}:"),
479 kind: Some(CompletionItemKind::VARIABLE),
480 detail: Some(format!("#{attr_class} argument")),
481 ..Default::default()
482 })
483 .collect();
484 }
485 }
486 }
487 vec![]
488 }
489 _ => {
490 if let (Some(src), Some(pos)) = (source, position) {
493 let line = src.lines().nth(pos.line as usize).unwrap_or("");
494 let col = utf16_offset_to_byte(line, pos.character as usize);
495 let before = &line[..col];
496 let pre_colon = before.trim_end_matches(|c: char| c.is_alphanumeric() || c == '_');
497 if pre_colon.ends_with("::") {
498 let colon_end_char = pre_colon.encode_utf16().count() as u32;
499 let colon_pos = tower_lsp::lsp_types::Position {
500 line: pos.line,
501 character: colon_end_char,
502 };
503 if let Some(class_name) =
504 resolve_static_receiver(src, doc, other_docs, colon_pos, imports)
505 {
506 let items = all_static_members(
507 &class_name,
508 doc,
509 other_docs,
510 ctx.find_class_doc,
511 ctx.session.as_deref(),
512 );
513 if !items.is_empty() {
514 return items;
515 }
516 }
517 }
518 }
519
520 if let (Some(src), Some(pos)) = (source, position) {
524 let line = src.lines().nth(pos.line as usize).unwrap_or("");
525 let col = utf16_offset_to_byte(line, pos.character as usize);
526 let before = &line[..col];
527 let pre_arrow = before.trim_end_matches(|c: char| c.is_alphanumeric() || c == '_');
529 let has_arrow = pre_arrow.ends_with("->") || pre_arrow.ends_with("?->");
530 if has_arrow {
531 let arrow_end_char = pre_arrow.encode_utf16().count() as u32;
537 let arrow_pos = tower_lsp::lsp_types::Position {
538 line: pos.line,
539 character: arrow_end_char,
540 };
541 let type_map = whole_doc_type_map(ctx, doc, meta);
542 if let Some(cls) =
543 resolve_receiver_class(src, doc, arrow_pos, ctx.analysis, &type_map)
544 {
545 let mut items = Vec::new();
546 let mut seen = std::collections::HashSet::new();
547 for class_name in cls.split('|') {
548 for item in all_instance_members(
549 class_name.trim(),
550 doc,
551 other_docs,
552 ctx.find_class_doc,
553 ctx.session.as_deref(),
554 ) {
555 if seen.insert(item.label.clone()) {
556 items.push(item);
557 }
558 }
559 }
560 if !items.is_empty() {
561 let prefix = before.strip_prefix(pre_arrow).unwrap_or("").to_string();
563 if !prefix.is_empty() {
564 let fq = crate::text::FuzzyQuery::new(&prefix);
565 items.retain(|i| {
566 let match_against = if i.label.starts_with('$') {
567 i.label.strip_prefix('$').unwrap_or(&i.label)
568 } else {
569 &i.label
570 };
571 fq.camel_match(match_against)
572 });
573 for item in &mut items {
574 let match_against = if item.label.starts_with('$') {
575 item.label.strip_prefix('$').unwrap_or(&item.label)
576 } else {
577 &item.label
578 };
579 item.sort_text =
580 Some(crate::text::camel_sort_key(&prefix, match_against));
581 item.filter_text = Some(item.label.clone());
582 }
583 }
584 return items;
585 }
586 }
587 }
588 }
589
590 if let (Some(src), Some(pos)) = (source, position) {
592 let line = src.lines().nth(pos.line as usize).unwrap_or("");
593 let col = utf16_offset_to_byte(line, pos.character as usize);
594 let before = &line[..col];
595 let pre_ident =
596 before.trim_end_matches(|c: char| c.is_alphanumeric() || c == '_' || c == '\\');
597 if pre_ident.trim_end().ends_with("#[") || pre_ident.trim_end() == "#[" {
598 let items = attribute_completions(src, pos, doc, other_docs, imports);
599 if !items.is_empty() {
600 return items;
601 }
602 }
603 }
604
605 if let (Some(src), Some(pos)) = (source, position)
607 && let Some(use_prefix) = use_completion_prefix(src, pos)
608 {
609 let mut use_items: Vec<CompletionItem> = Vec::new();
610 for other in other_docs {
611 collect_fqns_with_prefix(
612 &other.program().stmts,
613 "",
614 &use_prefix,
615 &mut use_items,
616 );
617 }
618 collect_fqns_with_prefix(&doc.program().stmts, "", &use_prefix, &mut use_items);
620 if !use_items.is_empty() {
621 return use_items;
622 }
623 }
624
625 if let (Some(src), Some(pos), Some(uri)) = (source, position, doc_uri)
627 && let Some(prefix) = include_path_prefix(src, pos)
628 {
629 let items = include_path_completions(uri, &prefix);
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 &|| whole_doc_type_map(ctx, doc, meta),
711 ctx.analysis,
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 mut items = keyword_completions();
740 items.extend(magic_constant_completions());
741 items.extend(builtin_completions());
742 items.extend(superglobal_completions());
743 let sym_items = if let (Some(_src), Some(pos)) = (source, position) {
745 symbol_completions_before(doc, pos.line)
746 } else {
747 symbol_completions(doc)
748 };
749 items.extend(sym_items);
750 items.extend(magic_items);
751
752 let cur_ns = current_file_namespace(&doc.program().stmts);
753 let prefix = typed_prefix(source, position).unwrap_or_default();
756
757 let use_pos = source.map(use_insert_position);
762
763 let push_class_item = |items: &mut Vec<CompletionItem>,
767 label: &str,
768 kind: CompletionItemKind,
769 fqn: &str| {
770 let additional_text_edits = if let Some(pos) = use_pos {
771 let in_same_ns = !cur_ns.is_empty() && fqn == format!("{}\\{}", cur_ns, label);
772 let is_global = !fqn.contains('\\');
773 let already = imports.contains_key(label);
774 if !in_same_ns && !is_global && !already {
775 Some(vec![TextEdit {
776 range: Range {
777 start: pos,
778 end: pos,
779 },
780 new_text: format!("use {};\n", fqn),
781 }])
782 } else {
783 None
784 }
785 } else {
786 None
787 };
788 items.push(CompletionItem {
789 label: label.to_string(),
790 kind: Some(kind),
791 detail: if fqn.contains('\\') {
792 Some(fqn.to_string())
793 } else {
794 None
795 },
796 additional_text_edits,
797 ..Default::default()
798 });
799 };
800
801 for (other, classes) in other_docs.iter().zip(other_classes()) {
802 for (label, kind, fqn) in classes {
804 push_class_item(&mut items, label, *kind, fqn);
805 }
806 let cross: Vec<CompletionItem> = symbol_completions(other)
808 .into_iter()
809 .filter(|i| {
810 !matches!(
811 i.kind,
812 Some(CompletionItemKind::CLASS)
813 | Some(CompletionItemKind::INTERFACE)
814 | Some(CompletionItemKind::ENUM)
815 ) && i.kind != Some(CompletionItemKind::VARIABLE)
816 })
817 .collect();
818 items.extend(cross);
819 }
820
821 if !prefix.is_empty()
826 && !prefix.contains('\\')
827 && let Some(search) = ctx.workspace_class_search
828 {
829 for (label, kind, fqn) in search(&prefix) {
830 push_class_item(&mut items, &label, kind, &fqn);
831 }
832 }
833
834 let mut seen = std::collections::HashSet::new();
835 items.retain(|i| seen.insert(i.label.clone()));
836
837 if prefix.contains('\\') {
838 let ns_prefix = prefix.trim_start_matches('\\').to_lowercase();
840 items.retain(|i| {
841 let fqn = i.detail.as_deref().unwrap_or(&i.label);
842 fqn.get(..ns_prefix.len())
843 .is_some_and(|s| s.eq_ignore_ascii_case(&ns_prefix))
844 });
845 } else if !prefix.is_empty() {
846 let fq = crate::text::FuzzyQuery::new(&prefix);
847 items.retain(|i| fq.camel_match(&i.label));
848 for item in &mut items {
849 item.sort_text = Some(camel_sort_key(&prefix, &item.label));
850 item.filter_text = Some(item.label.clone());
851 }
852 }
853 items
854 }
855 }
856}
857
858#[cfg(test)]
859mod tests {
860 use super::*;
861
862 fn labels(items: &[CompletionItem]) -> Vec<&str> {
863 items.iter().map(|i| i.label.as_str()).collect()
864 }
865
866 #[test]
867 fn keywords_list_is_non_empty() {
868 let kws = keyword_completions();
869 assert!(
870 kws.len() >= 20,
871 "expected at least 20 keywords, got {}",
872 kws.len()
873 );
874 }
875
876 #[test]
877 fn keywords_contain_common_php_keywords() {
878 let kws = keyword_completions();
879 let ls = labels(&kws);
880 for expected in &[
881 "function",
882 "class",
883 "return",
884 "foreach",
885 "match",
886 "namespace",
887 ] {
888 assert!(ls.contains(expected), "missing keyword: {expected}");
889 }
890 }
891
892 #[test]
893 fn type_keywords_present() {
894 let kws = keyword_completions();
895 let ls = labels(&kws);
896 for expected in &[
897 "bool", "float", "int", "iterable", "mixed", "never", "object", "string", "void",
898 "parent",
899 ] {
900 assert!(ls.contains(expected), "missing type keyword: {expected}");
901 }
902 }
903
904 #[test]
905 fn all_keyword_items_have_keyword_kind() {
906 for item in keyword_completions() {
907 assert_eq!(item.kind, Some(CompletionItemKind::KEYWORD));
908 }
909 }
910
911 #[test]
912 fn magic_constants_all_present() {
913 let items = magic_constant_completions();
914 let ls: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
915 for name in &[
916 "__FILE__",
917 "__DIR__",
918 "__LINE__",
919 "__CLASS__",
920 "__FUNCTION__",
921 "__METHOD__",
922 "__NAMESPACE__",
923 "__TRAIT__",
924 ] {
925 assert!(ls.contains(name), "missing magic constant: {name}");
926 }
927 }
928
929 #[test]
930 fn magic_constants_have_constant_kind() {
931 for item in magic_constant_completions() {
932 assert_eq!(
933 item.kind,
934 Some(CompletionItemKind::CONSTANT),
935 "{} should have CONSTANT kind",
936 item.label
937 );
938 }
939 }
940
941 #[test]
942 fn resolve_attribute_class_extracts_name() {
943 let src = "<?php\n#[Route(\n";
944 let pos = Position {
946 line: 1,
947 character: 8,
948 };
949 let result = resolve_attribute_class(src, pos);
950 assert_eq!(result.as_deref(), Some("Route"));
951 }
952
953 #[test]
954 fn resolve_attribute_class_fqn_extracts_short_name() {
955 let src = "<?php\n#[\\Symfony\\Component\\Routing\\Route(\n";
956 let pos = Position {
957 line: 1,
958 character: 38,
959 };
960 let result = resolve_attribute_class(src, pos);
961 assert_eq!(result.as_deref(), Some("Route"));
962 }
963
964 #[test]
965 fn resolve_attribute_class_returns_none_for_regular_call() {
966 let src = "<?php\nsomeFunction(\n";
967 let pos = Position {
968 line: 1,
969 character: 14,
970 };
971 let result = resolve_attribute_class(src, pos);
972 assert!(result.is_none(), "should not match regular function call");
973 }
974}