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/// Workspace-index-backed class name search: given a typed prefix, returns
202/// `(short_name, kind, fqn)` for every workspace class whose short name
203/// starts with it — including classes defined in files that are not open
204/// in the editor (e.g. `vendor/`). Complements `other_docs`, which only
205/// covers currently open documents.
206pub type WorkspaceClassSearch<'a> = &'a dyn Fn(&str) -> Vec<(String, CompletionItemKind, String)>;
207
208/// Optional context for completion requests that enables richer results
209/// (e.g. auto-import edits, `->` scoping to a class).
210#[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    /// Optional O(1) class-document lookup backed by the workspace index.
218    /// When `Some`, `all_instance_members` and `all_static_members` use it
219    /// to find the defining doc directly instead of scanning `other_docs`
220    /// linearly (O(n files × inheritance depth) → O(depth)).
221    /// Pass `None` to fall back to the existing linear scan.
222    pub find_class_doc: Option<ClassDocLookup<'a>>,
223    /// Optional workspace-wide class name search backed by the workspace
224    /// index (see [`WorkspaceClassSearch`]). Used to suggest — and
225    /// auto-import — classes from files that aren't currently open, such as
226    /// vendor code. `None` in unit tests that don't supply it.
227    pub workspace_class_search: Option<WorkspaceClassSearch<'a>>,
228    /// Retained mir body analysis for the primary doc. Receiver-variable types
229    /// (`$obj->`, match subjects) are read from its `symbol_at`; `None` in unit
230    /// tests that don't supply it.
231    pub analysis: Option<&'a mir_analyzer::FileAnalysis>,
232    /// Optional cross-request cache for the whole-doc [`TypeMap`]. The backend
233    /// wires this to `DocumentStore::cached_type_map` so the receiver-type
234    /// paths (`->`, `::`, match arms) reuse one map per document revision
235    /// instead of walking the full AST on every completion request. `None`
236    /// (unit tests) builds the map fresh.
237    pub type_map: Option<&'a dyn Fn() -> Arc<TypeMap>>,
238    /// mir-analyzer session for querying phpstorm-stubs member info on
239    /// built-in PHP classes. `None` in unit tests that don't require stubs.
240    pub session: Option<std::sync::Arc<mir_analyzer::AnalysisSession>>,
241}
242
243/// Whole-doc [`TypeMap`] through the ctx cache when wired, else a fresh build.
244fn 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
255/// Returns `true` when `cursor_byte` falls inside a PHP string literal or
256/// comment. Scans the source from the beginning with a simple state machine;
257/// handles single/double-quoted strings (with backslash escapes), `// …` and
258/// `# …` line comments, and `/* … */` block comments. Heredoc/nowdoc are not
259/// tracked — they are too rare in interactive editing contexts to warrant the
260/// complexity, and missing them produces a false-negative (completions shown
261/// inside a heredoc), not a false-positive (completions suppressed outside one).
262pub(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                // `#[` is a PHP 8 attribute — not a comment.
291                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
346/// Completions filtered by trigger character, with optional context
347/// so that `->` completions can be scoped to the variable's class.
348pub 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    // Suppress all completions when the cursor is inside a string literal or
360    // comment — except for include/require path strings, where file-path
361    // completions are legitimate inside the string argument.
362    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            // Arrow: $obj->  or  $this->
385            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                    // Feature 5: support union types (Foo|Bar)
391                    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            // Fallback: all methods from current doc
413            symbol_completions(doc)
414                .into_iter()
415                .filter(|i| i.kind == Some(CompletionItemKind::METHOD))
416                .collect()
417        }
418        Some(":") => {
419            // Static access: ClassName:: / self:: / static:: / parent::
420            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            // PHP attribute: #[ — suggest only #[\Attribute]-annotated classes.
439            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            // Named argument: funcName(
451            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                // Attribute constructor: #[ClassName(
464                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            // Static access context: ClassName::member (invoked without trigger char).
491            // Strip any identifier chars being typed as the member prefix.
492            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            // Detect $obj->member context (invoked completion without trigger char).
521            // Returns only the receiver class's instance members so unrelated class
522            // methods don't pollute the list.
523            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                // Strip any identifier chars the user is typing as the member prefix.
528                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                    // Synthesise a cursor that sits right at the end of the arrow so
532                    // that `resolve_receiver_class` — which strips the trailing `->` /
533                    // `?->` itself — can locate the receiver.  This correctly handles
534                    // simple variables ($obj->), `(new Foo())->`, method chains
535                    // ($obj->getUser()->), and nullable operators ($obj?->).
536                    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                            // Apply fuzzy filtering based on the typed prefix.
562                            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            // Attribute context: #[ or #[PartialName — invoked without trigger char.
591            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            // Feature 4: detect `use ` context and suggest FQNs from other docs
606            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                // Also check current doc
619                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            // Feature 9: include/require path completions
626            if let (Some(src), Some(pos), Some(uri)) = (source, position, doc_uri)
627                && let Some(prefix) = include_path_prefix(src, pos)
628            {
629                // When in include/require context, return path completions (even if empty)
630                // instead of falling back to keywords/symbols
631                let items = include_path_completions(uri, &prefix);
632                return items;
633            }
634
635            // Classes (label, kind, FQN) per other doc, collected lazily once
636            // per request: the sub-namespace branch below falls through to the
637            // default cross-file loop when nothing matches, which previously
638            // re-collected the same lists from every doc's AST a second time.
639            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            // Feature 3: Sub-namespace \ completions outside use statement
656            if let (Some(src), Some(pos)) = (source, position)
657                && let Some(prefix) = typed_prefix(Some(src), Some(pos))
658                && prefix.contains('\\')
659            {
660                // Check we're NOT in a use statement
661                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            // Feature 7: match arm completions
704            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                // extend with normal items below, but return early here
717                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                // Deduplicate by label (first occurrence wins)
725                let mut seen = std::collections::HashSet::new();
726                all.retain(|i| seen.insert(i.label.clone()));
727
728                return all;
729            }
730
731            // Feature 5: Magic method completions in class body
732            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            // Feature 2: scope variable completions to before cursor line
744            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            // Extract the typed prefix early: also used to bound the
754            // workspace-wide class search below.
755            let prefix = typed_prefix(source, position).unwrap_or_default();
756
757            // Same for every class candidate in this request (depends only on
758            // `source`) — computed once instead of per candidate, since a single
759            // request may build up to dozens of class items (open-doc classes
760            // plus up to `WORKSPACE_CLASS_SEARCH_LIMIT` workspace-index matches).
761            let use_pos = source.map(use_insert_position);
762
763            // Builds a class completion item, adding a `use` insertion edit
764            // unless the class is already reachable (same namespace, global,
765            // or already imported).
766            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                // Class-like symbols: add `use` insertion when needed.
803                for (label, kind, fqn) in classes {
804                    push_class_item(&mut items, label, *kind, fqn);
805                }
806                // Non-class symbols (functions, methods, constants) need no use statement.
807                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            // Classes from files that aren't open in the editor (e.g.
822            // vendor/), found via the workspace index. Only fires once a
823            // prefix is typed, both to bound the search and because an
824            // empty-prefix workspace-wide dump would swamp the list.
825            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                // Namespace-qualified prefix: filter by FQN prefix match.
839                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        // Position right after the '(' on line 1
945        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}