Skip to main content

php_lsp/completion/
mod.rs

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
46/// Build a `CompletionItem` for a callable (function or method).
47///
48/// If the function has parameters the item uses snippet format with `$1`
49/// inside the parentheses so the cursor lands there.  Zero-parameter
50/// callables insert `name()` as plain text.
51fn 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
70/// Build a named-argument `CompletionItem` for a callable when param names are
71/// known.  Produces a label like `create(name:, age:)` and a snippet like
72/// `create(name: $1, age: $2)`.  Returns `None` when the param list is empty
73/// (no advantage over the positional item in that case).
74fn 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
111/// Build the full signature string for a callable, e.g.
112/// `"function foo(string $bar, int $baz): bool"`.
113fn 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
125/// Build a `Documentation` value from a docblock found before `sym_name` in `doc`.
126fn 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
139/// If the `(` trigger occurs inside an attribute like `#[ClassName(`, extract
140/// the attribute class name so we can offer its `__construct` parameter names.
141fn 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    // Look backwards on the same line for `#[ClassName` or `#[\NS\ClassName`
146    let hash_pos = before.rfind("#[")?;
147    let after_bracket = before[hash_pos + 2..].trim_start();
148    // Strip leading backslashes (FQN), keep the short name
149    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
196/// Workspace-index-backed class lookup: maps a short class name to the
197/// `ParsedDoc` that defines it. Used by `all_instance_members` and
198/// `all_static_members` to avoid scanning all workspace docs linearly.
199pub type ClassDocLookup<'a> = &'a dyn Fn(&str) -> Option<Arc<ParsedDoc>>;
200
201/// Optional context for completion requests that enables richer results
202/// (e.g. auto-import edits, `->` scoping to a class).
203#[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    /// Optional O(1) class-document lookup backed by the workspace index.
211    /// When `Some`, `all_instance_members` and `all_static_members` use it
212    /// to find the defining doc directly instead of scanning `other_docs`
213    /// linearly (O(n files × inheritance depth) → O(depth)).
214    /// Pass `None` to fall back to the existing linear scan.
215    pub find_class_doc: Option<ClassDocLookup<'a>>,
216    /// Retained mir body analysis for the primary doc. Receiver-variable types
217    /// (`$obj->`, match subjects) are read from its `symbol_at`; `None` in unit
218    /// tests that don't supply it.
219    pub analysis: Option<&'a mir_analyzer::FileAnalysis>,
220    /// Optional cross-request cache for the whole-doc [`TypeMap`]. The backend
221    /// wires this to `DocumentStore::cached_type_map` so the receiver-type
222    /// paths (`->`, `::`, match arms) reuse one map per document revision
223    /// instead of walking the full AST on every completion request. `None`
224    /// (unit tests) builds the map fresh.
225    pub type_map: Option<&'a dyn Fn() -> Arc<TypeMap>>,
226    /// mir-analyzer session for querying phpstorm-stubs member info on
227    /// built-in PHP classes. `None` in unit tests that don't require stubs.
228    pub session: Option<std::sync::Arc<mir_analyzer::AnalysisSession>>,
229}
230
231/// Whole-doc [`TypeMap`] through the ctx cache when wired, else a fresh build.
232fn 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
243/// Returns `true` when `cursor_byte` falls inside a PHP string literal or
244/// comment. Scans the source from the beginning with a simple state machine;
245/// handles single/double-quoted strings (with backslash escapes), `// …` and
246/// `# …` line comments, and `/* … */` block comments. Heredoc/nowdoc are not
247/// tracked — they are too rare in interactive editing contexts to warrant the
248/// complexity, and missing them produces a false-negative (completions shown
249/// inside a heredoc), not a false-positive (completions suppressed outside one).
250pub(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                // `#[` is a PHP 8 attribute — not a comment.
279                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
334/// Completions filtered by trigger character, with optional context
335/// so that `->` completions can be scoped to the variable's class.
336pub 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    // Suppress all completions when the cursor is inside a string literal or
348    // comment — except for include/require path strings, where file-path
349    // completions are legitimate inside the string argument.
350    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            // Arrow: $obj->  or  $this->
373            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                    // Feature 5: support union types (Foo|Bar)
379                    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            // Fallback: all methods from current doc
401            symbol_completions(doc)
402                .into_iter()
403                .filter(|i| i.kind == Some(CompletionItemKind::METHOD))
404                .collect()
405        }
406        Some(":") => {
407            // Static access: ClassName:: / self:: / static:: / parent::
408            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            // PHP attribute: #[ — suggest only #[\Attribute]-annotated classes.
427            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            // Named argument: funcName(
439            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                // Attribute constructor: #[ClassName(
452                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            // Static access context: ClassName::member (invoked without trigger char).
479            // Strip any identifier chars being typed as the member prefix.
480            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            // Detect $obj->member context (invoked completion without trigger char).
509            // Returns only the receiver class's instance members so unrelated class
510            // methods don't pollute the list.
511            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                // Strip any identifier chars the user is typing as the member prefix.
516                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                    // Synthesise a cursor that sits right at the end of the arrow so
520                    // that `resolve_receiver_class` — which strips the trailing `->` /
521                    // `?->` itself — can locate the receiver.  This correctly handles
522                    // simple variables ($obj->), `(new Foo())->`, method chains
523                    // ($obj->getUser()->), and nullable operators ($obj?->).
524                    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                            // Apply fuzzy filtering based on the typed prefix.
550                            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            // Attribute context: #[ or #[PartialName — invoked without trigger char.
579            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            // Feature 4: detect `use ` context and suggest FQNs from other docs
594            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                // Also check current doc
607                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            // Feature 9: include/require path completions
614            if let (Some(src), Some(pos), Some(uri)) = (source, position, doc_uri)
615                && let Some(prefix) = include_path_prefix(src, pos)
616            {
617                // When in include/require context, return path completions (even if empty)
618                // instead of falling back to keywords/symbols
619                let items = include_path_completions(uri, &prefix);
620                return items;
621            }
622
623            // Classes (label, kind, FQN) per other doc, collected lazily once
624            // per request: the sub-namespace branch below falls through to the
625            // default cross-file loop when nothing matches, which previously
626            // re-collected the same lists from every doc's AST a second time.
627            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            // Feature 3: Sub-namespace \ completions outside use statement
644            if let (Some(src), Some(pos)) = (source, position)
645                && let Some(prefix) = typed_prefix(Some(src), Some(pos))
646                && prefix.contains('\\')
647            {
648                // Check we're NOT in a use statement
649                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            // Feature 7: match arm completions
692            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                // extend with normal items below, but return early here
705                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                // Deduplicate by label (first occurrence wins)
713                let mut seen = std::collections::HashSet::new();
714                all.retain(|i| seen.insert(i.label.clone()));
715
716                return all;
717            }
718
719            // Feature 5: Magic method completions in class body
720            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            // Feature 2: scope variable completions to before cursor line
732            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                // Class-like symbols: add `use` insertion when needed.
744                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                // Non-class symbols (functions, methods, constants) need no use statement.
778                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            // Extract the typed prefix for fuzzy camel/underscore filtering.
795            let prefix = typed_prefix(source, position).unwrap_or_default();
796            if prefix.contains('\\') {
797                // Namespace-qualified prefix: filter by FQN prefix match.
798                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        // Position right after the '(' on line 1
896        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}