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
201#[derive(Default)]
204pub struct CompletionCtx<'a> {
205 pub source: Option<&'a str>,
206 pub position: Option<Position>,
207 pub meta: Option<&'a PhpStormMeta>,
208 pub doc_uri: Option<&'a Url>,
209 pub file_imports: Option<&'a HashMap<String, String>>,
210 pub find_class_doc: Option<ClassDocLookup<'a>>,
216 pub analysis: Option<&'a mir_analyzer::FileAnalysis>,
220 pub type_map: Option<&'a dyn Fn() -> Arc<TypeMap>>,
226 pub session: Option<std::sync::Arc<mir_analyzer::AnalysisSession>>,
229}
230
231fn whole_doc_type_map(
233 ctx: &CompletionCtx<'_>,
234 doc: &ParsedDoc,
235 meta: Option<&PhpStormMeta>,
236) -> Arc<TypeMap> {
237 match ctx.type_map {
238 Some(get) => get(),
239 None => Arc::new(TypeMap::from_doc_with_meta(doc, meta)),
240 }
241}
242
243pub(crate) fn cursor_in_string_or_comment(source: &str, cursor_byte: usize) -> bool {
251 #[derive(PartialEq)]
252 enum S {
253 Normal,
254 Single,
255 Double,
256 Line,
257 Block,
258 }
259 let bytes = source.as_bytes();
260 let limit = bytes.len().min(cursor_byte);
261 let mut i = 0usize;
262 let mut state = S::Normal;
263 while i < limit {
264 match state {
265 S::Normal => match bytes[i] {
266 b'\'' => {
267 state = S::Single;
268 i += 1;
269 }
270 b'"' => {
271 state = S::Double;
272 i += 1;
273 }
274 b'/' if i + 1 < limit && bytes[i + 1] == b'/' => {
275 state = S::Line;
276 i += 2;
277 }
278 b'#' if !(i + 1 < limit && bytes[i + 1] == b'[') => {
280 state = S::Line;
281 i += 1;
282 }
283 b'/' if i + 1 < limit && bytes[i + 1] == b'*' => {
284 state = S::Block;
285 i += 2;
286 }
287 _ => {
288 i += 1;
289 }
290 },
291 S::Single => match bytes[i] {
292 b'\\' => {
293 i += 2;
294 }
295 b'\'' => {
296 state = S::Normal;
297 i += 1;
298 }
299 _ => {
300 i += 1;
301 }
302 },
303 S::Double => 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::Line => {
316 if bytes[i] == b'\n' {
317 state = S::Normal;
318 }
319 i += 1;
320 }
321 S::Block => {
322 if bytes[i] == b'*' && i + 1 < limit && bytes[i + 1] == b'/' {
323 state = S::Normal;
324 i += 2;
325 } else {
326 i += 1;
327 }
328 }
329 }
330 }
331 state != S::Normal
332}
333
334pub fn filtered_completions_at(
337 doc: &ParsedDoc,
338 other_docs: &[Arc<ParsedDoc>],
339 trigger_character: Option<&str>,
340 ctx: &CompletionCtx<'_>,
341) -> Vec<CompletionItem> {
342 let source = ctx.source;
343 let position = ctx.position;
344
345 let doc_uri = ctx.doc_uri;
346
347 if let (Some(src), Some(pos)) = (source, position) {
351 let cursor_byte = doc.view().byte_of_position(pos) as usize;
352 if cursor_in_string_or_comment(src, cursor_byte) && include_path_prefix(src, pos).is_none()
353 {
354 return vec![];
355 }
356 }
357 let meta = ctx.meta;
358 let empty_imports = HashMap::new();
359 let imports = ctx.file_imports.unwrap_or(&empty_imports);
360
361 match trigger_character {
362 Some("$") => {
363 let mut items = superglobal_completions();
364 items.extend(
365 symbol_completions(doc)
366 .into_iter()
367 .filter(|i| i.kind == Some(CompletionItemKind::VARIABLE)),
368 );
369 items
370 }
371 Some(">") => {
372 if let (Some(src), Some(pos)) = (source, position) {
374 let type_map = whole_doc_type_map(ctx, doc, meta);
375 if let Some(class_names) =
376 resolve_receiver_class(src, doc, pos, ctx.analysis, &type_map)
377 {
378 let mut items = Vec::new();
380 let mut seen = std::collections::HashSet::new();
381 for class_name in class_names.split('|') {
382 let class_name = class_name.trim();
383 for item in all_instance_members(
384 class_name,
385 doc,
386 other_docs,
387 ctx.find_class_doc,
388 ctx.session.as_deref(),
389 ) {
390 if seen.insert(item.label.clone()) {
391 items.push(item);
392 }
393 }
394 }
395 if !items.is_empty() {
396 return items;
397 }
398 }
399 }
400 symbol_completions(doc)
402 .into_iter()
403 .filter(|i| i.kind == Some(CompletionItemKind::METHOD))
404 .collect()
405 }
406 Some(":") => {
407 if let (Some(src), Some(pos)) = (source, position)
409 && let Some(class_name) =
410 resolve_static_receiver(src, doc, other_docs, pos, imports)
411 {
412 let items = all_static_members(
413 &class_name,
414 doc,
415 other_docs,
416 ctx.find_class_doc,
417 ctx.session.as_deref(),
418 );
419 if !items.is_empty() {
420 return items;
421 }
422 }
423 vec![]
424 }
425 Some("[") => {
426 if let (Some(src), Some(pos)) = (source, position) {
428 let line = src.lines().nth(pos.line as usize).unwrap_or("");
429 let col = utf16_offset_to_byte(line, pos.character as usize);
430 let before = &line[..col];
431 if before.trim_end_matches('[').trim_end().ends_with('#') {
432 return attribute_completions(src, pos, doc, other_docs, imports);
433 }
434 }
435 vec![]
436 }
437 Some("(") => {
438 if let (Some(src), Some(pos)) = (source, position) {
440 let params = resolve_call_params(src, doc, other_docs, pos);
441 if !params.is_empty() {
442 return params
443 .into_iter()
444 .map(|p| CompletionItem {
445 label: format!("{p}:"),
446 kind: Some(CompletionItemKind::VARIABLE),
447 ..Default::default()
448 })
449 .collect();
450 }
451 if let Some(attr_class) = resolve_attribute_class(src, pos) {
453 let mut attr_params = params_of_method(doc, &attr_class, "__construct");
454 if attr_params.is_empty() {
455 for other in other_docs {
456 attr_params = params_of_method(other, &attr_class, "__construct");
457 if !attr_params.is_empty() {
458 break;
459 }
460 }
461 }
462 if !attr_params.is_empty() {
463 return attr_params
464 .into_iter()
465 .map(|p| CompletionItem {
466 label: format!("{p}:"),
467 kind: Some(CompletionItemKind::VARIABLE),
468 detail: Some(format!("#{attr_class} argument")),
469 ..Default::default()
470 })
471 .collect();
472 }
473 }
474 }
475 vec![]
476 }
477 _ => {
478 if let (Some(src), Some(pos)) = (source, position) {
481 let line = src.lines().nth(pos.line as usize).unwrap_or("");
482 let col = utf16_offset_to_byte(line, pos.character as usize);
483 let before = &line[..col];
484 let pre_colon = before.trim_end_matches(|c: char| c.is_alphanumeric() || c == '_');
485 if pre_colon.ends_with("::") {
486 let colon_end_char = pre_colon.encode_utf16().count() as u32;
487 let colon_pos = tower_lsp::lsp_types::Position {
488 line: pos.line,
489 character: colon_end_char,
490 };
491 if let Some(class_name) =
492 resolve_static_receiver(src, doc, other_docs, colon_pos, imports)
493 {
494 let items = all_static_members(
495 &class_name,
496 doc,
497 other_docs,
498 ctx.find_class_doc,
499 ctx.session.as_deref(),
500 );
501 if !items.is_empty() {
502 return items;
503 }
504 }
505 }
506 }
507
508 if let (Some(src), Some(pos)) = (source, position) {
512 let line = src.lines().nth(pos.line as usize).unwrap_or("");
513 let col = utf16_offset_to_byte(line, pos.character as usize);
514 let before = &line[..col];
515 let pre_arrow = before.trim_end_matches(|c: char| c.is_alphanumeric() || c == '_');
517 let has_arrow = pre_arrow.ends_with("->") || pre_arrow.ends_with("?->");
518 if has_arrow {
519 let arrow_end_char = pre_arrow.encode_utf16().count() as u32;
525 let arrow_pos = tower_lsp::lsp_types::Position {
526 line: pos.line,
527 character: arrow_end_char,
528 };
529 let type_map = whole_doc_type_map(ctx, doc, meta);
530 if let Some(cls) =
531 resolve_receiver_class(src, doc, arrow_pos, ctx.analysis, &type_map)
532 {
533 let mut items = Vec::new();
534 let mut seen = std::collections::HashSet::new();
535 for class_name in cls.split('|') {
536 for item in all_instance_members(
537 class_name.trim(),
538 doc,
539 other_docs,
540 ctx.find_class_doc,
541 ctx.session.as_deref(),
542 ) {
543 if seen.insert(item.label.clone()) {
544 items.push(item);
545 }
546 }
547 }
548 if !items.is_empty() {
549 let prefix = before.strip_prefix(pre_arrow).unwrap_or("").to_string();
551 if !prefix.is_empty() {
552 let fq = crate::text::FuzzyQuery::new(&prefix);
553 items.retain(|i| {
554 let match_against = if i.label.starts_with('$') {
555 i.label.strip_prefix('$').unwrap_or(&i.label)
556 } else {
557 &i.label
558 };
559 fq.camel_match(match_against)
560 });
561 for item in &mut items {
562 let match_against = if item.label.starts_with('$') {
563 item.label.strip_prefix('$').unwrap_or(&item.label)
564 } else {
565 &item.label
566 };
567 item.sort_text =
568 Some(crate::text::camel_sort_key(&prefix, match_against));
569 item.filter_text = Some(item.label.clone());
570 }
571 }
572 return items;
573 }
574 }
575 }
576 }
577
578 if let (Some(src), Some(pos)) = (source, position) {
580 let line = src.lines().nth(pos.line as usize).unwrap_or("");
581 let col = utf16_offset_to_byte(line, pos.character as usize);
582 let before = &line[..col];
583 let pre_ident =
584 before.trim_end_matches(|c: char| c.is_alphanumeric() || c == '_' || c == '\\');
585 if pre_ident.trim_end().ends_with("#[") || pre_ident.trim_end() == "#[" {
586 let items = attribute_completions(src, pos, doc, other_docs, imports);
587 if !items.is_empty() {
588 return items;
589 }
590 }
591 }
592
593 if let (Some(src), Some(pos)) = (source, position)
595 && let Some(use_prefix) = use_completion_prefix(src, pos)
596 {
597 let mut use_items: Vec<CompletionItem> = Vec::new();
598 for other in other_docs {
599 collect_fqns_with_prefix(
600 &other.program().stmts,
601 "",
602 &use_prefix,
603 &mut use_items,
604 );
605 }
606 collect_fqns_with_prefix(&doc.program().stmts, "", &use_prefix, &mut use_items);
608 if !use_items.is_empty() {
609 return use_items;
610 }
611 }
612
613 if let (Some(src), Some(pos), Some(uri)) = (source, position, doc_uri)
615 && let Some(prefix) = include_path_prefix(src, pos)
616 {
617 let items = include_path_completions(uri, &prefix);
620 return items;
621 }
622
623 let other_classes_cell: std::cell::OnceCell<
628 Vec<Vec<(String, CompletionItemKind, String)>>,
629 > = std::cell::OnceCell::new();
630 let other_classes = || {
631 other_classes_cell.get_or_init(|| {
632 other_docs
633 .iter()
634 .map(|other| {
635 let mut classes = Vec::new();
636 collect_classes_with_ns(&other.program().stmts, "", &mut classes);
637 classes
638 })
639 .collect()
640 })
641 };
642
643 if let (Some(src), Some(pos)) = (source, position)
645 && let Some(prefix) = typed_prefix(Some(src), Some(pos))
646 && prefix.contains('\\')
647 {
648 let is_use = use_completion_prefix(src, pos).is_some();
650 if !is_use {
651 let prefix_lc = prefix.trim_start_matches('\\').to_lowercase();
652 let mut ns_items: Vec<CompletionItem> = Vec::new();
653 for classes in other_classes() {
654 for (label, kind, fqn) in classes {
655 if fqn
656 .get(..prefix_lc.len())
657 .is_some_and(|s| s.eq_ignore_ascii_case(&prefix_lc))
658 {
659 ns_items.push(CompletionItem {
660 label: label.clone(),
661 kind: Some(*kind),
662 insert_text: Some(label.clone()),
663 detail: Some(fqn.clone()),
664 ..Default::default()
665 });
666 }
667 }
668 }
669 let mut classes = Vec::new();
670 collect_classes_with_ns(&doc.program().stmts, "", &mut classes);
671 for (label, kind, fqn) in classes {
672 if fqn
673 .get(..prefix_lc.len())
674 .is_some_and(|s| s.eq_ignore_ascii_case(&prefix_lc))
675 {
676 ns_items.push(CompletionItem {
677 label: label.clone(),
678 kind: Some(kind),
679 insert_text: Some(label),
680 detail: Some(fqn),
681 ..Default::default()
682 });
683 }
684 }
685 if !ns_items.is_empty() {
686 return ns_items;
687 }
688 }
689 }
690
691 if let (Some(src), Some(pos)) = (source, position)
693 && let Some(match_items) = match_arm_completions(
694 src,
695 doc,
696 other_docs,
697 pos,
698 &|| whole_doc_type_map(ctx, doc, meta),
699 ctx.analysis,
700 )
701 && !match_items.is_empty()
702 {
703 let mut all = match_items;
704 let mut normal_items = keyword_completions();
706 normal_items.extend(magic_constant_completions());
707 normal_items.extend(builtin_completions());
708 normal_items.extend(superglobal_completions());
709 normal_items.extend(symbol_completions(doc));
710 all.extend(normal_items);
711
712 let mut seen = std::collections::HashSet::new();
714 all.retain(|i| seen.insert(i.label.clone()));
715
716 return all;
717 }
718
719 let mut magic_items: Vec<CompletionItem> = Vec::new();
721 if let (Some(src), Some(pos)) = (source, position)
722 && enclosing_class_at(src, doc, pos).is_some()
723 {
724 magic_items.extend(magic_method_completions());
725 }
726
727 let mut items = keyword_completions();
728 items.extend(magic_constant_completions());
729 items.extend(builtin_completions());
730 items.extend(superglobal_completions());
731 let sym_items = if let (Some(_src), Some(pos)) = (source, position) {
733 symbol_completions_before(doc, pos.line)
734 } else {
735 symbol_completions(doc)
736 };
737 items.extend(sym_items);
738 items.extend(magic_items);
739
740 let cur_ns = current_file_namespace(&doc.program().stmts);
741
742 for (other, classes) in other_docs.iter().zip(other_classes()) {
743 for (label, kind, fqn) in classes {
745 let additional_text_edits = if let Some(src) = source {
746 let in_same_ns =
747 !cur_ns.is_empty() && *fqn == format!("{}\\{}", cur_ns, label);
748 let is_global = !fqn.contains('\\');
749 let already = imports.contains_key(label);
750 if !in_same_ns && !is_global && !already {
751 let pos = use_insert_position(src);
752 Some(vec![TextEdit {
753 range: Range {
754 start: pos,
755 end: pos,
756 },
757 new_text: format!("use {};\n", fqn),
758 }])
759 } else {
760 None
761 }
762 } else {
763 None
764 };
765 items.push(CompletionItem {
766 label: label.clone(),
767 kind: Some(*kind),
768 detail: if fqn.contains('\\') {
769 Some(fqn.clone())
770 } else {
771 None
772 },
773 additional_text_edits,
774 ..Default::default()
775 });
776 }
777 let cross: Vec<CompletionItem> = symbol_completions(other)
779 .into_iter()
780 .filter(|i| {
781 !matches!(
782 i.kind,
783 Some(CompletionItemKind::CLASS)
784 | Some(CompletionItemKind::INTERFACE)
785 | Some(CompletionItemKind::ENUM)
786 ) && i.kind != Some(CompletionItemKind::VARIABLE)
787 })
788 .collect();
789 items.extend(cross);
790 }
791 let mut seen = std::collections::HashSet::new();
792 items.retain(|i| seen.insert(i.label.clone()));
793
794 let prefix = typed_prefix(source, position).unwrap_or_default();
796 if prefix.contains('\\') {
797 let ns_prefix = prefix.trim_start_matches('\\').to_lowercase();
799 items.retain(|i| {
800 let fqn = i.detail.as_deref().unwrap_or(&i.label);
801 fqn.get(..ns_prefix.len())
802 .is_some_and(|s| s.eq_ignore_ascii_case(&ns_prefix))
803 });
804 } else if !prefix.is_empty() {
805 let fq = crate::text::FuzzyQuery::new(&prefix);
806 items.retain(|i| fq.camel_match(&i.label));
807 for item in &mut items {
808 item.sort_text = Some(camel_sort_key(&prefix, &item.label));
809 item.filter_text = Some(item.label.clone());
810 }
811 }
812 items
813 }
814 }
815}
816
817#[cfg(test)]
818mod tests {
819 use super::*;
820
821 fn doc(source: &str) -> ParsedDoc {
822 ParsedDoc::parse(source.to_string())
823 }
824
825 fn labels(items: &[CompletionItem]) -> Vec<&str> {
826 items.iter().map(|i| i.label.as_str()).collect()
827 }
828
829 #[test]
830 fn keywords_list_is_non_empty() {
831 let kws = keyword_completions();
832 assert!(
833 kws.len() >= 20,
834 "expected at least 20 keywords, got {}",
835 kws.len()
836 );
837 }
838
839 #[test]
840 fn keywords_contain_common_php_keywords() {
841 let kws = keyword_completions();
842 let ls = labels(&kws);
843 for expected in &[
844 "function",
845 "class",
846 "return",
847 "foreach",
848 "match",
849 "namespace",
850 ] {
851 assert!(ls.contains(expected), "missing keyword: {expected}");
852 }
853 }
854
855 #[test]
856 fn all_keyword_items_have_keyword_kind() {
857 for item in keyword_completions() {
858 assert_eq!(item.kind, Some(CompletionItemKind::KEYWORD));
859 }
860 }
861
862 #[test]
863 fn magic_constants_all_present() {
864 let items = magic_constant_completions();
865 let ls: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
866 for name in &[
867 "__FILE__",
868 "__DIR__",
869 "__LINE__",
870 "__CLASS__",
871 "__FUNCTION__",
872 "__METHOD__",
873 "__NAMESPACE__",
874 "__TRAIT__",
875 ] {
876 assert!(ls.contains(name), "missing magic constant: {name}");
877 }
878 }
879
880 #[test]
881 fn magic_constants_have_constant_kind() {
882 for item in magic_constant_completions() {
883 assert_eq!(
884 item.kind,
885 Some(CompletionItemKind::CONSTANT),
886 "{} should have CONSTANT kind",
887 item.label
888 );
889 }
890 }
891
892 #[test]
893 fn resolve_attribute_class_extracts_name() {
894 let src = "<?php\n#[Route(\n";
895 let pos = Position {
897 line: 1,
898 character: 8,
899 };
900 let result = resolve_attribute_class(src, pos);
901 assert_eq!(result.as_deref(), Some("Route"));
902 }
903
904 #[test]
905 fn resolve_attribute_class_fqn_extracts_short_name() {
906 let src = "<?php\n#[\\Symfony\\Component\\Routing\\Route(\n";
907 let pos = Position {
908 line: 1,
909 character: 38,
910 };
911 let result = resolve_attribute_class(src, pos);
912 assert_eq!(result.as_deref(), Some("Route"));
913 }
914
915 #[test]
916 fn resolve_attribute_class_returns_none_for_regular_call() {
917 let src = "<?php\nsomeFunction(\n";
918 let pos = Position {
919 line: 1,
920 character: 14,
921 };
922 let result = resolve_attribute_class(src, pos);
923 assert!(result.is_none(), "should not match regular function call");
924 }
925}