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::{TypeMap, 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 type_map: Option<&'a dyn Fn() -> Arc<TypeMap>>,
242 pub session: Option<std::sync::Arc<mir_analyzer::AnalysisSession>>,
245}
246
247fn whole_doc_type_map(
249 ctx: &CompletionCtx<'_>,
250 doc: &ParsedDoc,
251 meta: Option<&PhpStormMeta>,
252) -> Arc<TypeMap> {
253 match ctx.type_map {
254 Some(get) => get(),
255 None => Arc::new(TypeMap::from_doc_with_meta(doc, meta)),
256 }
257}
258
259pub(crate) fn cursor_in_string_or_comment(source: &str, cursor_byte: usize) -> bool {
267 #[derive(PartialEq)]
268 enum S {
269 Normal,
270 Single,
271 Double,
272 Line,
273 Block,
274 }
275 let bytes = source.as_bytes();
276 let limit = bytes.len().min(cursor_byte);
277 let mut i = 0usize;
278 let mut state = S::Normal;
279 while i < limit {
280 match state {
281 S::Normal => match bytes[i] {
282 b'\'' => {
283 state = S::Single;
284 i += 1;
285 }
286 b'"' => {
287 state = S::Double;
288 i += 1;
289 }
290 b'/' if i + 1 < limit && bytes[i + 1] == b'/' => {
291 state = S::Line;
292 i += 2;
293 }
294 b'#' if !(i + 1 < limit && bytes[i + 1] == b'[') => {
296 state = S::Line;
297 i += 1;
298 }
299 b'/' if i + 1 < limit && bytes[i + 1] == b'*' => {
300 state = S::Block;
301 i += 2;
302 }
303 _ => {
304 i += 1;
305 }
306 },
307 S::Single => match bytes[i] {
308 b'\\' => {
309 i += 2;
310 }
311 b'\'' => {
312 state = S::Normal;
313 i += 1;
314 }
315 _ => {
316 i += 1;
317 }
318 },
319 S::Double => match bytes[i] {
320 b'\\' => {
321 i += 2;
322 }
323 b'"' => {
324 state = S::Normal;
325 i += 1;
326 }
327 _ => {
328 i += 1;
329 }
330 },
331 S::Line => {
332 if bytes[i] == b'\n' {
333 state = S::Normal;
334 }
335 i += 1;
336 }
337 S::Block => {
338 if bytes[i] == b'*' && i + 1 < limit && bytes[i + 1] == b'/' {
339 state = S::Normal;
340 i += 2;
341 } else {
342 i += 1;
343 }
344 }
345 }
346 }
347 state != S::Normal
348}
349
350pub fn filtered_completions_at(
353 doc: &ParsedDoc,
354 other_docs: &[Arc<ParsedDoc>],
355 trigger_character: Option<&str>,
356 ctx: &CompletionCtx<'_>,
357) -> Vec<CompletionItem> {
358 let source = ctx.source;
359 let position = ctx.position;
360
361 let doc_uri = ctx.doc_uri;
362
363 if let (Some(src), Some(pos)) = (source, position) {
367 let cursor_byte = doc.view().byte_of_position(pos) as usize;
368 if cursor_in_string_or_comment(src, cursor_byte) && include_path_prefix(src, pos).is_none()
369 {
370 return vec![];
371 }
372 }
373 let meta = ctx.meta;
374 let empty_imports = HashMap::new();
375 let imports = ctx.file_imports.unwrap_or(&empty_imports);
376
377 match trigger_character {
378 Some("$") => {
379 let mut items = superglobal_completions();
380 items.extend(
381 symbol_completions(doc)
382 .into_iter()
383 .filter(|i| i.kind == Some(CompletionItemKind::VARIABLE)),
384 );
385 items
386 }
387 Some(">") => {
388 if let (Some(src), Some(pos)) = (source, position) {
390 let type_map = whole_doc_type_map(ctx, doc, meta);
391 if let Some(class_names) =
392 resolve_receiver_class(src, doc, pos, ctx.analysis, &type_map)
393 {
394 let mut items = Vec::new();
396 let mut seen = std::collections::HashSet::new();
397 for class_name in class_names.split('|') {
398 let class_name = class_name.trim();
399 for item in all_instance_members(
400 class_name,
401 doc,
402 other_docs,
403 ctx.find_class_doc,
404 ctx.session.as_deref(),
405 ) {
406 if seen.insert(item.label.clone()) {
407 items.push(item);
408 }
409 }
410 }
411 if !items.is_empty() {
412 return items;
413 }
414 }
415 }
416 symbol_completions(doc)
418 .into_iter()
419 .filter(|i| i.kind == Some(CompletionItemKind::METHOD))
420 .collect()
421 }
422 Some(":") => {
423 if let (Some(src), Some(pos)) = (source, position)
425 && let Some(class_name) =
426 resolve_static_receiver(src, doc, other_docs, pos, imports)
427 {
428 let items = all_static_members(
429 &class_name,
430 doc,
431 other_docs,
432 ctx.find_class_doc,
433 ctx.session.as_deref(),
434 );
435 if !items.is_empty() {
436 return items;
437 }
438 }
439 vec![]
440 }
441 Some("[") => {
442 if let (Some(src), Some(pos)) = (source, position) {
444 let line = src.lines().nth(pos.line as usize).unwrap_or("");
445 let col = utf16_offset_to_byte(line, pos.character as usize);
446 let before = &line[..col];
447 if before.trim_end_matches('[').trim_end().ends_with('#') {
448 return attribute_completions(src, pos, doc, other_docs, imports);
449 }
450 }
451 vec![]
452 }
453 Some("(") => {
454 if let (Some(src), Some(pos)) = (source, position) {
456 let params = resolve_call_params(src, doc, other_docs, pos);
457 if !params.is_empty() {
458 return params
459 .into_iter()
460 .map(|p| CompletionItem {
461 label: format!("{p}:"),
462 kind: Some(CompletionItemKind::VARIABLE),
463 ..Default::default()
464 })
465 .collect();
466 }
467 if let Some(attr_class) = resolve_attribute_class(src, pos) {
469 let mut attr_params = params_of_method(doc, &attr_class, "__construct");
470 if attr_params.is_empty() {
471 for other in other_docs {
472 attr_params = params_of_method(other, &attr_class, "__construct");
473 if !attr_params.is_empty() {
474 break;
475 }
476 }
477 }
478 if !attr_params.is_empty() {
479 return attr_params
480 .into_iter()
481 .map(|p| CompletionItem {
482 label: format!("{p}:"),
483 kind: Some(CompletionItemKind::VARIABLE),
484 detail: Some(format!("#{attr_class} argument")),
485 ..Default::default()
486 })
487 .collect();
488 }
489 }
490 }
491 vec![]
492 }
493 _ => {
494 if let (Some(src), Some(pos)) = (source, position) {
497 let line = src.lines().nth(pos.line as usize).unwrap_or("");
498 let col = utf16_offset_to_byte(line, pos.character as usize);
499 let before = &line[..col];
500 let pre_colon = before.trim_end_matches(|c: char| c.is_alphanumeric() || c == '_');
501 if pre_colon.ends_with("::") {
502 let colon_end_char = pre_colon.encode_utf16().count() as u32;
503 let colon_pos = tower_lsp::lsp_types::Position {
504 line: pos.line,
505 character: colon_end_char,
506 };
507 if let Some(class_name) =
508 resolve_static_receiver(src, doc, other_docs, colon_pos, imports)
509 {
510 let items = all_static_members(
511 &class_name,
512 doc,
513 other_docs,
514 ctx.find_class_doc,
515 ctx.session.as_deref(),
516 );
517 if !items.is_empty() {
518 return items;
519 }
520 }
521 }
522 }
523
524 if let (Some(src), Some(pos)) = (source, position) {
528 let line = src.lines().nth(pos.line as usize).unwrap_or("");
529 let col = utf16_offset_to_byte(line, pos.character as usize);
530 let before = &line[..col];
531 let pre_arrow = before.trim_end_matches(|c: char| c.is_alphanumeric() || c == '_');
533 let has_arrow = pre_arrow.ends_with("->") || pre_arrow.ends_with("?->");
534 if has_arrow {
535 let arrow_end_char = pre_arrow.encode_utf16().count() as u32;
541 let arrow_pos = tower_lsp::lsp_types::Position {
542 line: pos.line,
543 character: arrow_end_char,
544 };
545 let type_map = whole_doc_type_map(ctx, doc, meta);
546 if let Some(cls) =
547 resolve_receiver_class(src, doc, arrow_pos, ctx.analysis, &type_map)
548 {
549 let mut items = Vec::new();
550 let mut seen = std::collections::HashSet::new();
551 for class_name in cls.split('|') {
552 for item in all_instance_members(
553 class_name.trim(),
554 doc,
555 other_docs,
556 ctx.find_class_doc,
557 ctx.session.as_deref(),
558 ) {
559 if seen.insert(item.label.clone()) {
560 items.push(item);
561 }
562 }
563 }
564 if !items.is_empty() {
565 let prefix = before.strip_prefix(pre_arrow).unwrap_or("").to_string();
567 if !prefix.is_empty() {
568 let fq = crate::text::FuzzyQuery::new(&prefix);
569 items.retain(|i| {
570 let match_against = if i.label.starts_with('$') {
571 i.label.strip_prefix('$').unwrap_or(&i.label)
572 } else {
573 &i.label
574 };
575 fq.camel_match(match_against)
576 });
577 for item in &mut items {
578 let match_against = if item.label.starts_with('$') {
579 item.label.strip_prefix('$').unwrap_or(&item.label)
580 } else {
581 &item.label
582 };
583 item.sort_text =
584 Some(crate::text::camel_sort_key(&prefix, match_against));
585 item.filter_text = Some(item.label.clone());
586 }
587 }
588 return items;
589 }
590 }
591 }
592 }
593
594 if let (Some(src), Some(pos)) = (source, position) {
596 let line = src.lines().nth(pos.line as usize).unwrap_or("");
597 let col = utf16_offset_to_byte(line, pos.character as usize);
598 let before = &line[..col];
599 let pre_ident =
600 before.trim_end_matches(|c: char| c.is_alphanumeric() || c == '_' || c == '\\');
601 if pre_ident.trim_end().ends_with("#[") || pre_ident.trim_end() == "#[" {
602 let items = attribute_completions(src, pos, doc, other_docs, imports);
603 if !items.is_empty() {
604 return items;
605 }
606 }
607 }
608
609 if let (Some(src), Some(pos)) = (source, position)
611 && let Some(use_prefix) = use_completion_prefix(src, pos)
612 {
613 let mut use_items: Vec<CompletionItem> = Vec::new();
614 for other in other_docs {
615 collect_fqns_with_prefix(
616 &other.program().stmts,
617 "",
618 &use_prefix,
619 &mut use_items,
620 );
621 }
622 collect_fqns_with_prefix(&doc.program().stmts, "", &use_prefix, &mut use_items);
624 if !use_items.is_empty() {
625 return use_items;
626 }
627 }
628
629 if let (Some(src), Some(pos), Some(uri)) = (source, position, doc_uri)
631 && let Some(prefix) = include_path_prefix(src, pos)
632 {
633 let items = include_path_completions(uri, &prefix);
636 return items;
637 }
638
639 let other_classes_cell: std::cell::OnceCell<
644 Vec<Vec<(String, CompletionItemKind, String)>>,
645 > = std::cell::OnceCell::new();
646 let other_classes = || {
647 other_classes_cell.get_or_init(|| {
648 other_docs
649 .iter()
650 .map(|other| {
651 let mut classes = Vec::new();
652 collect_classes_with_ns(&other.program().stmts, "", &mut classes);
653 classes
654 })
655 .collect()
656 })
657 };
658
659 if let (Some(src), Some(pos)) = (source, position)
661 && let Some(prefix) = typed_prefix(Some(src), Some(pos))
662 && prefix.contains('\\')
663 {
664 let is_use = use_completion_prefix(src, pos).is_some();
666 if !is_use {
667 let prefix_lc = prefix.trim_start_matches('\\').to_lowercase();
668 let mut ns_items: Vec<CompletionItem> = Vec::new();
669 for classes in other_classes() {
670 for (label, kind, fqn) in classes {
671 if fqn
672 .get(..prefix_lc.len())
673 .is_some_and(|s| s.eq_ignore_ascii_case(&prefix_lc))
674 {
675 ns_items.push(CompletionItem {
676 label: label.clone(),
677 kind: Some(*kind),
678 insert_text: Some(label.clone()),
679 detail: Some(fqn.clone()),
680 ..Default::default()
681 });
682 }
683 }
684 }
685 let mut classes = Vec::new();
686 collect_classes_with_ns(&doc.program().stmts, "", &mut classes);
687 for (label, kind, fqn) in classes {
688 if fqn
689 .get(..prefix_lc.len())
690 .is_some_and(|s| s.eq_ignore_ascii_case(&prefix_lc))
691 {
692 ns_items.push(CompletionItem {
693 label: label.clone(),
694 kind: Some(kind),
695 insert_text: Some(label),
696 detail: Some(fqn),
697 ..Default::default()
698 });
699 }
700 }
701 if !ns_items.is_empty() {
702 return ns_items;
703 }
704 }
705 }
706
707 if let (Some(src), Some(pos)) = (source, position)
709 && let Some(match_items) = match_arm_completions(
710 src,
711 doc,
712 other_docs,
713 pos,
714 &|| whole_doc_type_map(ctx, doc, meta),
715 ctx.analysis,
716 )
717 && !match_items.is_empty()
718 {
719 let mut all = match_items;
720 let mut normal_items = keyword_completions();
722 normal_items.extend(magic_constant_completions());
723 normal_items.extend(builtin_completions());
724 normal_items.extend(superglobal_completions());
725 normal_items.extend(symbol_completions(doc));
726 all.extend(normal_items);
727
728 let mut seen = std::collections::HashSet::new();
730 all.retain(|i| seen.insert(i.label.clone()));
731
732 return all;
733 }
734
735 let mut magic_items: Vec<CompletionItem> = Vec::new();
737 if let (Some(src), Some(pos)) = (source, position)
738 && enclosing_class_at(src, doc, pos).is_some()
739 {
740 magic_items.extend(magic_method_completions());
741 }
742
743 let prefix = typed_prefix(source, position).unwrap_or_default();
749 let ident_query = (!prefix.is_empty() && !prefix.contains('\\'))
750 .then(|| crate::text::FuzzyQuery::new(&prefix));
751 let keep_static =
752 |label: &str| ident_query.as_ref().is_none_or(|fq| fq.camel_match(label));
753
754 let mut items = keyword_completions_matching(&keep_static);
755 items.extend(magic_constant_completions_matching(&keep_static));
756 items.extend(builtin_completions_matching(&keep_static));
757 items.extend(superglobal_completions_matching(&keep_static));
758 let sym_items = if let (Some(_src), Some(pos)) = (source, position) {
760 symbol_completions_before(doc, pos.line)
761 } else {
762 symbol_completions(doc)
763 };
764 items.extend(sym_items);
765 items.extend(magic_items);
766
767 let cur_ns = current_file_namespace(&doc.program().stmts);
768
769 let use_pos = source.map(use_insert_position);
774
775 let push_class_item = |items: &mut Vec<CompletionItem>,
779 label: &str,
780 kind: CompletionItemKind,
781 fqn: &str| {
782 let additional_text_edits = if let Some(pos) = use_pos {
783 let in_same_ns = !cur_ns.is_empty() && fqn == format!("{}\\{}", cur_ns, label);
784 let is_global = !fqn.contains('\\');
785 let already = imports.contains_key(label);
786 if !in_same_ns && !is_global && !already {
787 Some(vec![TextEdit {
788 range: Range {
789 start: pos,
790 end: pos,
791 },
792 new_text: format!("use {};\n", fqn),
793 }])
794 } else {
795 None
796 }
797 } else {
798 None
799 };
800 items.push(CompletionItem {
801 label: label.to_string(),
802 kind: Some(kind),
803 detail: if fqn.contains('\\') {
804 Some(fqn.to_string())
805 } else {
806 None
807 },
808 additional_text_edits,
809 ..Default::default()
810 });
811 };
812
813 for (other, classes) in other_docs.iter().zip(other_classes()) {
814 for (label, kind, fqn) in classes {
816 push_class_item(&mut items, label, *kind, fqn);
817 }
818 let cross: Vec<CompletionItem> = symbol_completions(other)
820 .into_iter()
821 .filter(|i| {
822 !matches!(
823 i.kind,
824 Some(CompletionItemKind::CLASS)
825 | Some(CompletionItemKind::INTERFACE)
826 | Some(CompletionItemKind::ENUM)
827 ) && i.kind != Some(CompletionItemKind::VARIABLE)
828 })
829 .collect();
830 items.extend(cross);
831 }
832
833 if !prefix.is_empty()
838 && !prefix.contains('\\')
839 && let Some(search) = ctx.workspace_class_search
840 {
841 for (label, kind, fqn) in search(&prefix) {
842 push_class_item(&mut items, &label, kind, &fqn);
843 }
844 }
845
846 let mut seen = std::collections::HashSet::new();
847 items.retain(|i| seen.insert(i.label.clone()));
848
849 if prefix.contains('\\') {
850 let ns_prefix = prefix.trim_start_matches('\\').to_lowercase();
852 items.retain(|i| {
853 let fqn = i.detail.as_deref().unwrap_or(&i.label);
854 fqn.get(..ns_prefix.len())
855 .is_some_and(|s| s.eq_ignore_ascii_case(&ns_prefix))
856 });
857 } else if !prefix.is_empty() {
858 let fq = crate::text::FuzzyQuery::new(&prefix);
859 items.retain(|i| fq.camel_match(&i.label));
860 for item in &mut items {
861 item.sort_text = Some(camel_sort_key(&prefix, &item.label));
862 item.filter_text = Some(item.label.clone());
863 }
864 }
865 items
866 }
867 }
868}
869
870#[cfg(test)]
871mod tests {
872 use super::*;
873
874 fn labels(items: &[CompletionItem]) -> Vec<&str> {
875 items.iter().map(|i| i.label.as_str()).collect()
876 }
877
878 #[test]
879 fn keywords_list_is_non_empty() {
880 let kws = keyword_completions();
881 assert!(
882 kws.len() >= 20,
883 "expected at least 20 keywords, got {}",
884 kws.len()
885 );
886 }
887
888 #[test]
889 fn keywords_contain_common_php_keywords() {
890 let kws = keyword_completions();
891 let ls = labels(&kws);
892 for expected in &[
893 "function",
894 "class",
895 "return",
896 "foreach",
897 "match",
898 "namespace",
899 ] {
900 assert!(ls.contains(expected), "missing keyword: {expected}");
901 }
902 }
903
904 #[test]
905 fn type_keywords_present() {
906 let kws = keyword_completions();
907 let ls = labels(&kws);
908 for expected in &[
909 "bool", "float", "int", "iterable", "mixed", "never", "object", "string", "void",
910 "parent",
911 ] {
912 assert!(ls.contains(expected), "missing type keyword: {expected}");
913 }
914 }
915
916 #[test]
917 fn all_keyword_items_have_keyword_kind() {
918 for item in keyword_completions() {
919 assert_eq!(item.kind, Some(CompletionItemKind::KEYWORD));
920 }
921 }
922
923 #[test]
924 fn magic_constants_all_present() {
925 let items = magic_constant_completions();
926 let ls: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
927 for name in &[
928 "__FILE__",
929 "__DIR__",
930 "__LINE__",
931 "__CLASS__",
932 "__FUNCTION__",
933 "__METHOD__",
934 "__NAMESPACE__",
935 "__TRAIT__",
936 ] {
937 assert!(ls.contains(name), "missing magic constant: {name}");
938 }
939 }
940
941 #[test]
942 fn magic_constants_have_constant_kind() {
943 for item in magic_constant_completions() {
944 assert_eq!(
945 item.kind,
946 Some(CompletionItemKind::CONSTANT),
947 "{} should have CONSTANT kind",
948 item.label
949 );
950 }
951 }
952
953 #[test]
954 fn resolve_attribute_class_extracts_name() {
955 let src = "<?php\n#[Route(\n";
956 let pos = Position {
958 line: 1,
959 character: 8,
960 };
961 let result = resolve_attribute_class(src, pos);
962 assert_eq!(result.as_deref(), Some("Route"));
963 }
964
965 #[test]
966 fn resolve_attribute_class_fqn_extracts_short_name() {
967 let src = "<?php\n#[\\Symfony\\Component\\Routing\\Route(\n";
968 let pos = Position {
969 line: 1,
970 character: 38,
971 };
972 let result = resolve_attribute_class(src, pos);
973 assert_eq!(result.as_deref(), Some("Route"));
974 }
975
976 #[test]
977 fn resolve_attribute_class_returns_none_for_regular_call() {
978 let src = "<?php\nsomeFunction(\n";
979 let pos = Position {
980 line: 1,
981 character: 14,
982 };
983 let result = resolve_attribute_class(src, pos);
984 assert!(result.is_none(), "should not match regular function call");
985 }
986}