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::{
9    keyword_completions, keyword_completions_matching, magic_constant_completions,
10    magic_constant_completions_matching,
11};
12
13mod match_arm;
14use match_arm::match_arm_completions;
15
16mod member;
17use member::{
18    all_instance_members, all_static_members, magic_method_completions, resolve_receiver_class,
19    resolve_static_receiver,
20};
21
22mod namespace;
23use namespace::{
24    collect_classes_with_ns, collect_fqns_with_prefix, current_file_namespace, typed_prefix,
25    use_completion_prefix, use_insert_position,
26};
27
28mod symbols;
29pub use symbols::{
30    builtin_completions, builtin_completions_matching, superglobal_completions,
31    superglobal_completions_matching, symbol_completions, symbol_completions_before,
32};
33
34use std::sync::Arc;
35
36use tower_lsp::lsp_types::{
37    CompletionItem, CompletionItemKind, InsertTextFormat, Position, Range, TextEdit, Url,
38};
39
40use tower_lsp::lsp_types::{Documentation, MarkupContent, MarkupKind};
41
42use crate::document::ast::{ParsedDoc, format_type_hint};
43use crate::hover::format_params_str;
44use crate::lang::docblock::find_docblock;
45use crate::lang::phpstorm_meta::PhpStormMeta;
46use crate::text::{camel_sort_key, utf16_offset_to_byte};
47use crate::types::type_map::{TypeMap, enclosing_class_at, params_of_function, params_of_method};
48use std::collections::HashMap;
49
50/// Build a `CompletionItem` for a callable (function or method).
51///
52/// If the function has parameters the item uses snippet format with `$1`
53/// inside the parentheses so the cursor lands there.  Zero-parameter
54/// callables insert `name()` as plain text.
55fn callable_item(label: &str, kind: CompletionItemKind, has_params: bool) -> CompletionItem {
56    if has_params {
57        CompletionItem {
58            label: label.to_string(),
59            kind: Some(kind),
60            insert_text: Some(format!("{}($1)", label)),
61            insert_text_format: Some(InsertTextFormat::SNIPPET),
62            ..Default::default()
63        }
64    } else {
65        CompletionItem {
66            label: label.to_string(),
67            kind: Some(kind),
68            insert_text: Some(format!("{}()", label)),
69            ..Default::default()
70        }
71    }
72}
73
74/// Build a named-argument `CompletionItem` for a callable when param names are
75/// known.  Produces a label like `create(name:, age:)` and a snippet like
76/// `create(name: $1, age: $2)`.  Returns `None` when the param list is empty
77/// (no advantage over the positional item in that case).
78fn named_arg_item(
79    label: &str,
80    kind: CompletionItemKind,
81    params: &[php_ast::Param<'_, '_>],
82) -> Option<CompletionItem> {
83    if params.is_empty() {
84        return None;
85    }
86    let named_label = format!(
87        "{}({})",
88        label,
89        params
90            .iter()
91            .map(|p| format!("{}:", &p.name.to_string()))
92            .collect::<Vec<_>>()
93            .join(", ")
94    );
95    let snippet = format!(
96        "{}({})",
97        label,
98        params
99            .iter()
100            .enumerate()
101            .map(|(i, p)| format!("{}: ${}", p.name, i + 1))
102            .collect::<Vec<_>>()
103            .join(", ")
104    );
105    Some(CompletionItem {
106        label: named_label,
107        kind: Some(kind),
108        insert_text: Some(snippet),
109        insert_text_format: Some(InsertTextFormat::SNIPPET),
110        detail: Some("named args".to_string()),
111        ..Default::default()
112    })
113}
114
115/// Build the full signature string for a callable, e.g.
116/// `"function foo(string $bar, int $baz): bool"`.
117fn build_function_sig(
118    name: &str,
119    params: &[php_ast::Param<'_, '_>],
120    return_type: Option<&php_ast::TypeHint<'_, '_>>,
121) -> String {
122    let params_str = format_params_str(params);
123    let ret = return_type
124        .map(|r| format!(": {}", format_type_hint(r)))
125        .unwrap_or_default();
126    format!("function {}({}){}", name, params_str, ret)
127}
128
129/// Build a `Documentation` value from a docblock found before `sym_name` in `doc`.
130fn docblock_docs(doc: &ParsedDoc, sym_name: &str) -> Option<Documentation> {
131    let db = find_docblock(&doc.program().stmts, sym_name)?;
132    let md = db.to_markdown();
133    if md.is_empty() {
134        None
135    } else {
136        Some(Documentation::MarkupContent(MarkupContent {
137            kind: MarkupKind::Markdown,
138            value: md,
139        }))
140    }
141}
142
143/// If the `(` trigger occurs inside an attribute like `#[ClassName(`, extract
144/// the attribute class name so we can offer its `__construct` parameter names.
145fn resolve_attribute_class(source: &str, position: Position) -> Option<String> {
146    let line = source.lines().nth(position.line as usize)?;
147    let col = utf16_offset_to_byte(line, position.character as usize);
148    let before = line[..col].trim_end_matches('(').trim_end();
149    // Look backwards on the same line for `#[ClassName` or `#[\NS\ClassName`
150    let hash_pos = before.rfind("#[")?;
151    let after_bracket = before[hash_pos + 2..].trim_start();
152    // Strip leading backslashes (FQN), keep the short name
153    let name: String = after_bracket
154        .trim_start_matches('\\')
155        .rsplit('\\')
156        .next()
157        .unwrap_or("")
158        .chars()
159        .take_while(|c| c.is_alphanumeric() || *c == '_')
160        .collect();
161    if name.is_empty() { None } else { Some(name) }
162}
163
164fn resolve_call_params(
165    source: &str,
166    doc: &ParsedDoc,
167    other_docs: &[Arc<ParsedDoc>],
168    position: Position,
169) -> Vec<String> {
170    let line = match source.lines().nth(position.line as usize) {
171        Some(l) => l,
172        None => return vec![],
173    };
174    let col = utf16_offset_to_byte(line, position.character as usize);
175    let before = &line[..col];
176    let before = before.strip_suffix('(').unwrap_or(before);
177    let func_name: String = before
178        .chars()
179        .rev()
180        .take_while(|&c| c.is_alphanumeric() || c == '_')
181        .collect::<String>()
182        .chars()
183        .rev()
184        .collect();
185    if func_name.is_empty() {
186        return vec![];
187    }
188    let mut params = params_of_function(doc, &func_name);
189    if params.is_empty() {
190        for other in other_docs {
191            params = params_of_function(other, &func_name);
192            if !params.is_empty() {
193                break;
194            }
195        }
196    }
197    params
198}
199
200/// Workspace-index-backed class lookup: maps a short class name to the
201/// `ParsedDoc` that defines it. Used by `all_instance_members` and
202/// `all_static_members` to avoid scanning all workspace docs linearly.
203pub type ClassDocLookup<'a> = &'a dyn Fn(&str) -> Option<Arc<ParsedDoc>>;
204
205/// Workspace-index-backed class name search: given a typed prefix, returns
206/// `(short_name, kind, fqn)` for every workspace class whose short name
207/// starts with it — including classes defined in files that are not open
208/// in the editor (e.g. `vendor/`). Complements `other_docs`, which only
209/// covers currently open documents.
210pub type WorkspaceClassSearch<'a> = &'a dyn Fn(&str) -> Vec<(String, CompletionItemKind, String)>;
211
212/// Optional context for completion requests that enables richer results
213/// (e.g. auto-import edits, `->` scoping to a class).
214#[derive(Default)]
215pub struct CompletionCtx<'a> {
216    pub source: Option<&'a str>,
217    pub position: Option<Position>,
218    pub meta: Option<&'a PhpStormMeta>,
219    pub doc_uri: Option<&'a Url>,
220    pub file_imports: Option<&'a HashMap<String, String>>,
221    /// Optional O(1) class-document lookup backed by the workspace index.
222    /// When `Some`, `all_instance_members` and `all_static_members` use it
223    /// to find the defining doc directly instead of scanning `other_docs`
224    /// linearly (O(n files × inheritance depth) → O(depth)).
225    /// Pass `None` to fall back to the existing linear scan.
226    pub find_class_doc: Option<ClassDocLookup<'a>>,
227    /// Optional workspace-wide class name search backed by the workspace
228    /// index (see [`WorkspaceClassSearch`]). Used to suggest — and
229    /// auto-import — classes from files that aren't currently open, such as
230    /// vendor code. `None` in unit tests that don't supply it.
231    pub workspace_class_search: Option<WorkspaceClassSearch<'a>>,
232    /// Retained mir body analysis for the primary doc. Receiver-variable types
233    /// (`$obj->`, match subjects) are read from its `symbol_at`; `None` in unit
234    /// tests that don't supply it.
235    pub analysis: Option<&'a mir_analyzer::FileAnalysis>,
236    /// Optional cross-request cache for the whole-doc [`TypeMap`]. The backend
237    /// wires this to `DocumentStore::cached_type_map` so the receiver-type
238    /// paths (`->`, `::`, match arms) reuse one map per document revision
239    /// instead of walking the full AST on every completion request. `None`
240    /// (unit tests) builds the map fresh.
241    pub type_map: Option<&'a dyn Fn() -> Arc<TypeMap>>,
242    /// mir-analyzer session for querying phpstorm-stubs member info on
243    /// built-in PHP classes. `None` in unit tests that don't require stubs.
244    pub session: Option<std::sync::Arc<mir_analyzer::AnalysisSession>>,
245}
246
247/// Whole-doc [`TypeMap`] through the ctx cache when wired, else a fresh build.
248fn whole_doc_type_map(
249    ctx: &CompletionCtx<'_>,
250    doc: &ParsedDoc,
251    meta: Option<&PhpStormMeta>,
252) -> Arc<TypeMap> {
253    match ctx.type_map {
254        Some(get) => get(),
255        None => Arc::new(TypeMap::from_doc_with_meta(doc, meta)),
256    }
257}
258
259/// Returns `true` when `cursor_byte` falls inside a PHP string literal or
260/// comment. Scans the source from the beginning with a simple state machine;
261/// handles single/double-quoted strings (with backslash escapes), `// …` and
262/// `# …` line comments, and `/* … */` block comments. Heredoc/nowdoc are not
263/// tracked — they are too rare in interactive editing contexts to warrant the
264/// complexity, and missing them produces a false-negative (completions shown
265/// inside a heredoc), not a false-positive (completions suppressed outside one).
266pub(crate) fn cursor_in_string_or_comment(source: &str, cursor_byte: usize) -> bool {
267    #[derive(PartialEq)]
268    enum S {
269        Normal,
270        Single,
271        Double,
272        Line,
273        Block,
274    }
275    let bytes = source.as_bytes();
276    let limit = bytes.len().min(cursor_byte);
277    let mut i = 0usize;
278    let mut state = S::Normal;
279    while i < limit {
280        match state {
281            S::Normal => match bytes[i] {
282                b'\'' => {
283                    state = S::Single;
284                    i += 1;
285                }
286                b'"' => {
287                    state = S::Double;
288                    i += 1;
289                }
290                b'/' if i + 1 < limit && bytes[i + 1] == b'/' => {
291                    state = S::Line;
292                    i += 2;
293                }
294                // `#[` is a PHP 8 attribute — not a comment.
295                b'#' if !(i + 1 < limit && bytes[i + 1] == b'[') => {
296                    state = S::Line;
297                    i += 1;
298                }
299                b'/' if i + 1 < limit && bytes[i + 1] == b'*' => {
300                    state = S::Block;
301                    i += 2;
302                }
303                _ => {
304                    i += 1;
305                }
306            },
307            S::Single => match bytes[i] {
308                b'\\' => {
309                    i += 2;
310                }
311                b'\'' => {
312                    state = S::Normal;
313                    i += 1;
314                }
315                _ => {
316                    i += 1;
317                }
318            },
319            S::Double => match bytes[i] {
320                b'\\' => {
321                    i += 2;
322                }
323                b'"' => {
324                    state = S::Normal;
325                    i += 1;
326                }
327                _ => {
328                    i += 1;
329                }
330            },
331            S::Line => {
332                if bytes[i] == b'\n' {
333                    state = S::Normal;
334                }
335                i += 1;
336            }
337            S::Block => {
338                if bytes[i] == b'*' && i + 1 < limit && bytes[i + 1] == b'/' {
339                    state = S::Normal;
340                    i += 2;
341                } else {
342                    i += 1;
343                }
344            }
345        }
346    }
347    state != S::Normal
348}
349
350/// Completions filtered by trigger character, with optional context
351/// so that `->` completions can be scoped to the variable's class.
352pub fn filtered_completions_at(
353    doc: &ParsedDoc,
354    other_docs: &[Arc<ParsedDoc>],
355    trigger_character: Option<&str>,
356    ctx: &CompletionCtx<'_>,
357) -> Vec<CompletionItem> {
358    let source = ctx.source;
359    let position = ctx.position;
360
361    let doc_uri = ctx.doc_uri;
362
363    // Suppress all completions when the cursor is inside a string literal or
364    // comment — except for include/require path strings, where file-path
365    // completions are legitimate inside the string argument.
366    if let (Some(src), Some(pos)) = (source, position) {
367        let cursor_byte = doc.view().byte_of_position(pos) as usize;
368        if cursor_in_string_or_comment(src, cursor_byte) && include_path_prefix(src, pos).is_none()
369        {
370            return vec![];
371        }
372    }
373    let meta = ctx.meta;
374    let empty_imports = HashMap::new();
375    let imports = ctx.file_imports.unwrap_or(&empty_imports);
376
377    match trigger_character {
378        Some("$") => {
379            let mut items = superglobal_completions();
380            items.extend(
381                symbol_completions(doc)
382                    .into_iter()
383                    .filter(|i| i.kind == Some(CompletionItemKind::VARIABLE)),
384            );
385            items
386        }
387        Some(">") => {
388            // Arrow: $obj->  or  $this->
389            if let (Some(src), Some(pos)) = (source, position) {
390                let type_map = whole_doc_type_map(ctx, doc, meta);
391                if let Some(class_names) =
392                    resolve_receiver_class(src, doc, pos, ctx.analysis, &type_map)
393                {
394                    // Feature 5: support union types (Foo|Bar)
395                    let mut items = Vec::new();
396                    let mut seen = std::collections::HashSet::new();
397                    for class_name in class_names.split('|') {
398                        let class_name = class_name.trim();
399                        for item in all_instance_members(
400                            class_name,
401                            doc,
402                            other_docs,
403                            ctx.find_class_doc,
404                            ctx.session.as_deref(),
405                        ) {
406                            if seen.insert(item.label.clone()) {
407                                items.push(item);
408                            }
409                        }
410                    }
411                    if !items.is_empty() {
412                        return items;
413                    }
414                }
415            }
416            // Fallback: all methods from current doc
417            symbol_completions(doc)
418                .into_iter()
419                .filter(|i| i.kind == Some(CompletionItemKind::METHOD))
420                .collect()
421        }
422        Some(":") => {
423            // Static access: ClassName:: / self:: / static:: / parent::
424            if let (Some(src), Some(pos)) = (source, position)
425                && let Some(class_name) =
426                    resolve_static_receiver(src, doc, other_docs, pos, imports)
427            {
428                let items = all_static_members(
429                    &class_name,
430                    doc,
431                    other_docs,
432                    ctx.find_class_doc,
433                    ctx.session.as_deref(),
434                );
435                if !items.is_empty() {
436                    return items;
437                }
438            }
439            vec![]
440        }
441        Some("[") => {
442            // PHP attribute: #[ — suggest only #[\Attribute]-annotated classes.
443            if let (Some(src), Some(pos)) = (source, position) {
444                let line = src.lines().nth(pos.line as usize).unwrap_or("");
445                let col = utf16_offset_to_byte(line, pos.character as usize);
446                let before = &line[..col];
447                if before.trim_end_matches('[').trim_end().ends_with('#') {
448                    return attribute_completions(src, pos, doc, other_docs, imports);
449                }
450            }
451            vec![]
452        }
453        Some("(") => {
454            // Named argument: funcName(
455            if let (Some(src), Some(pos)) = (source, position) {
456                let params = resolve_call_params(src, doc, other_docs, pos);
457                if !params.is_empty() {
458                    return params
459                        .into_iter()
460                        .map(|p| CompletionItem {
461                            label: format!("{p}:"),
462                            kind: Some(CompletionItemKind::VARIABLE),
463                            ..Default::default()
464                        })
465                        .collect();
466                }
467                // Attribute constructor: #[ClassName(
468                if let Some(attr_class) = resolve_attribute_class(src, pos) {
469                    let mut attr_params = params_of_method(doc, &attr_class, "__construct");
470                    if attr_params.is_empty() {
471                        for other in other_docs {
472                            attr_params = params_of_method(other, &attr_class, "__construct");
473                            if !attr_params.is_empty() {
474                                break;
475                            }
476                        }
477                    }
478                    if !attr_params.is_empty() {
479                        return attr_params
480                            .into_iter()
481                            .map(|p| CompletionItem {
482                                label: format!("{p}:"),
483                                kind: Some(CompletionItemKind::VARIABLE),
484                                detail: Some(format!("#{attr_class} argument")),
485                                ..Default::default()
486                            })
487                            .collect();
488                    }
489                }
490            }
491            vec![]
492        }
493        _ => {
494            // Static access context: ClassName::member (invoked without trigger char).
495            // Strip any identifier chars being typed as the member prefix.
496            if let (Some(src), Some(pos)) = (source, position) {
497                let line = src.lines().nth(pos.line as usize).unwrap_or("");
498                let col = utf16_offset_to_byte(line, pos.character as usize);
499                let before = &line[..col];
500                let pre_colon = before.trim_end_matches(|c: char| c.is_alphanumeric() || c == '_');
501                if pre_colon.ends_with("::") {
502                    let colon_end_char = pre_colon.encode_utf16().count() as u32;
503                    let colon_pos = tower_lsp::lsp_types::Position {
504                        line: pos.line,
505                        character: colon_end_char,
506                    };
507                    if let Some(class_name) =
508                        resolve_static_receiver(src, doc, other_docs, colon_pos, imports)
509                    {
510                        let items = all_static_members(
511                            &class_name,
512                            doc,
513                            other_docs,
514                            ctx.find_class_doc,
515                            ctx.session.as_deref(),
516                        );
517                        if !items.is_empty() {
518                            return items;
519                        }
520                    }
521                }
522            }
523
524            // Detect $obj->member context (invoked completion without trigger char).
525            // Returns only the receiver class's instance members so unrelated class
526            // methods don't pollute the list.
527            if let (Some(src), Some(pos)) = (source, position) {
528                let line = src.lines().nth(pos.line as usize).unwrap_or("");
529                let col = utf16_offset_to_byte(line, pos.character as usize);
530                let before = &line[..col];
531                // Strip any identifier chars the user is typing as the member prefix.
532                let pre_arrow = before.trim_end_matches(|c: char| c.is_alphanumeric() || c == '_');
533                let has_arrow = pre_arrow.ends_with("->") || pre_arrow.ends_with("?->");
534                if has_arrow {
535                    // Synthesise a cursor that sits right at the end of the arrow so
536                    // that `resolve_receiver_class` — which strips the trailing `->` /
537                    // `?->` itself — can locate the receiver.  This correctly handles
538                    // simple variables ($obj->), `(new Foo())->`, method chains
539                    // ($obj->getUser()->), and nullable operators ($obj?->).
540                    let arrow_end_char = pre_arrow.encode_utf16().count() as u32;
541                    let arrow_pos = tower_lsp::lsp_types::Position {
542                        line: pos.line,
543                        character: arrow_end_char,
544                    };
545                    let type_map = whole_doc_type_map(ctx, doc, meta);
546                    if let Some(cls) =
547                        resolve_receiver_class(src, doc, arrow_pos, ctx.analysis, &type_map)
548                    {
549                        let mut items = Vec::new();
550                        let mut seen = std::collections::HashSet::new();
551                        for class_name in cls.split('|') {
552                            for item in all_instance_members(
553                                class_name.trim(),
554                                doc,
555                                other_docs,
556                                ctx.find_class_doc,
557                                ctx.session.as_deref(),
558                            ) {
559                                if seen.insert(item.label.clone()) {
560                                    items.push(item);
561                                }
562                            }
563                        }
564                        if !items.is_empty() {
565                            // Apply fuzzy filtering based on the typed prefix.
566                            let prefix = before.strip_prefix(pre_arrow).unwrap_or("").to_string();
567                            if !prefix.is_empty() {
568                                let fq = crate::text::FuzzyQuery::new(&prefix);
569                                items.retain(|i| {
570                                    let match_against = if i.label.starts_with('$') {
571                                        i.label.strip_prefix('$').unwrap_or(&i.label)
572                                    } else {
573                                        &i.label
574                                    };
575                                    fq.camel_match(match_against)
576                                });
577                                for item in &mut items {
578                                    let match_against = if item.label.starts_with('$') {
579                                        item.label.strip_prefix('$').unwrap_or(&item.label)
580                                    } else {
581                                        &item.label
582                                    };
583                                    item.sort_text =
584                                        Some(crate::text::camel_sort_key(&prefix, match_against));
585                                    item.filter_text = Some(item.label.clone());
586                                }
587                            }
588                            return items;
589                        }
590                    }
591                }
592            }
593
594            // Attribute context: #[ or #[PartialName — invoked without trigger char.
595            if let (Some(src), Some(pos)) = (source, position) {
596                let line = src.lines().nth(pos.line as usize).unwrap_or("");
597                let col = utf16_offset_to_byte(line, pos.character as usize);
598                let before = &line[..col];
599                let pre_ident =
600                    before.trim_end_matches(|c: char| c.is_alphanumeric() || c == '_' || c == '\\');
601                if pre_ident.trim_end().ends_with("#[") || pre_ident.trim_end() == "#[" {
602                    let items = attribute_completions(src, pos, doc, other_docs, imports);
603                    if !items.is_empty() {
604                        return items;
605                    }
606                }
607            }
608
609            // Feature 4: detect `use ` context and suggest FQNs from other docs
610            if let (Some(src), Some(pos)) = (source, position)
611                && let Some(use_prefix) = use_completion_prefix(src, pos)
612            {
613                let mut use_items: Vec<CompletionItem> = Vec::new();
614                for other in other_docs {
615                    collect_fqns_with_prefix(
616                        &other.program().stmts,
617                        "",
618                        &use_prefix,
619                        &mut use_items,
620                    );
621                }
622                // Also check current doc
623                collect_fqns_with_prefix(&doc.program().stmts, "", &use_prefix, &mut use_items);
624                if !use_items.is_empty() {
625                    return use_items;
626                }
627            }
628
629            // Feature 9: include/require path completions
630            if let (Some(src), Some(pos), Some(uri)) = (source, position, doc_uri)
631                && let Some(prefix) = include_path_prefix(src, pos)
632            {
633                // When in include/require context, return path completions (even if empty)
634                // instead of falling back to keywords/symbols
635                let items = include_path_completions(uri, &prefix);
636                return items;
637            }
638
639            // Classes (label, kind, FQN) per other doc, collected lazily once
640            // per request: the sub-namespace branch below falls through to the
641            // default cross-file loop when nothing matches, which previously
642            // re-collected the same lists from every doc's AST a second time.
643            let other_classes_cell: std::cell::OnceCell<
644                Vec<Vec<(String, CompletionItemKind, String)>>,
645            > = std::cell::OnceCell::new();
646            let other_classes = || {
647                other_classes_cell.get_or_init(|| {
648                    other_docs
649                        .iter()
650                        .map(|other| {
651                            let mut classes = Vec::new();
652                            collect_classes_with_ns(&other.program().stmts, "", &mut classes);
653                            classes
654                        })
655                        .collect()
656                })
657            };
658
659            // Feature 3: Sub-namespace \ completions outside use statement
660            if let (Some(src), Some(pos)) = (source, position)
661                && let Some(prefix) = typed_prefix(Some(src), Some(pos))
662                && prefix.contains('\\')
663            {
664                // Check we're NOT in a use statement
665                let is_use = use_completion_prefix(src, pos).is_some();
666                if !is_use {
667                    let prefix_lc = prefix.trim_start_matches('\\').to_lowercase();
668                    let mut ns_items: Vec<CompletionItem> = Vec::new();
669                    for classes in other_classes() {
670                        for (label, kind, fqn) in classes {
671                            if fqn
672                                .get(..prefix_lc.len())
673                                .is_some_and(|s| s.eq_ignore_ascii_case(&prefix_lc))
674                            {
675                                ns_items.push(CompletionItem {
676                                    label: label.clone(),
677                                    kind: Some(*kind),
678                                    insert_text: Some(label.clone()),
679                                    detail: Some(fqn.clone()),
680                                    ..Default::default()
681                                });
682                            }
683                        }
684                    }
685                    let mut classes = Vec::new();
686                    collect_classes_with_ns(&doc.program().stmts, "", &mut classes);
687                    for (label, kind, fqn) in classes {
688                        if fqn
689                            .get(..prefix_lc.len())
690                            .is_some_and(|s| s.eq_ignore_ascii_case(&prefix_lc))
691                        {
692                            ns_items.push(CompletionItem {
693                                label: label.clone(),
694                                kind: Some(kind),
695                                insert_text: Some(label),
696                                detail: Some(fqn),
697                                ..Default::default()
698                            });
699                        }
700                    }
701                    if !ns_items.is_empty() {
702                        return ns_items;
703                    }
704                }
705            }
706
707            // Feature 7: match arm completions
708            if let (Some(src), Some(pos)) = (source, position)
709                && let Some(match_items) = match_arm_completions(
710                    src,
711                    doc,
712                    other_docs,
713                    pos,
714                    &|| whole_doc_type_map(ctx, doc, meta),
715                    ctx.analysis,
716                )
717                && !match_items.is_empty()
718            {
719                let mut all = match_items;
720                // extend with normal items below, but return early here
721                let mut normal_items = keyword_completions();
722                normal_items.extend(magic_constant_completions());
723                normal_items.extend(builtin_completions());
724                normal_items.extend(superglobal_completions());
725                normal_items.extend(symbol_completions(doc));
726                all.extend(normal_items);
727
728                // Deduplicate by label (first occurrence wins)
729                let mut seen = std::collections::HashSet::new();
730                all.retain(|i| seen.insert(i.label.clone()));
731
732                return all;
733            }
734
735            // Feature 5: Magic method completions in class body
736            let mut magic_items: Vec<CompletionItem> = Vec::new();
737            if let (Some(src), Some(pos)) = (source, position)
738                && enclosing_class_at(src, doc, pos).is_some()
739            {
740                magic_items.extend(magic_method_completions());
741            }
742
743            // Extract the typed prefix early: it bounds the workspace-wide
744            // class search below, and — for a plain identifier prefix — the
745            // static item sources apply the same camel-match the final retain
746            // would, so hundreds of keyword/builtin items aren't materialized
747            // per keystroke just to be dropped.
748            let prefix = typed_prefix(source, position).unwrap_or_default();
749            let ident_query = (!prefix.is_empty() && !prefix.contains('\\'))
750                .then(|| crate::text::FuzzyQuery::new(&prefix));
751            let keep_static =
752                |label: &str| ident_query.as_ref().is_none_or(|fq| fq.camel_match(label));
753
754            let mut items = keyword_completions_matching(&keep_static);
755            items.extend(magic_constant_completions_matching(&keep_static));
756            items.extend(builtin_completions_matching(&keep_static));
757            items.extend(superglobal_completions_matching(&keep_static));
758            // Feature 2: scope variable completions to before cursor line
759            let sym_items = if let (Some(_src), Some(pos)) = (source, position) {
760                symbol_completions_before(doc, pos.line)
761            } else {
762                symbol_completions(doc)
763            };
764            items.extend(sym_items);
765            items.extend(magic_items);
766
767            let cur_ns = current_file_namespace(&doc.program().stmts);
768
769            // Same for every class candidate in this request (depends only on
770            // `source`) — computed once instead of per candidate, since a single
771            // request may build up to dozens of class items (open-doc classes
772            // plus up to `WORKSPACE_CLASS_SEARCH_LIMIT` workspace-index matches).
773            let use_pos = source.map(use_insert_position);
774
775            // Builds a class completion item, adding a `use` insertion edit
776            // unless the class is already reachable (same namespace, global,
777            // or already imported).
778            let push_class_item = |items: &mut Vec<CompletionItem>,
779                                   label: &str,
780                                   kind: CompletionItemKind,
781                                   fqn: &str| {
782                let additional_text_edits = if let Some(pos) = use_pos {
783                    let in_same_ns = !cur_ns.is_empty() && fqn == format!("{}\\{}", cur_ns, label);
784                    let is_global = !fqn.contains('\\');
785                    let already = imports.contains_key(label);
786                    if !in_same_ns && !is_global && !already {
787                        Some(vec![TextEdit {
788                            range: Range {
789                                start: pos,
790                                end: pos,
791                            },
792                            new_text: format!("use {};\n", fqn),
793                        }])
794                    } else {
795                        None
796                    }
797                } else {
798                    None
799                };
800                items.push(CompletionItem {
801                    label: label.to_string(),
802                    kind: Some(kind),
803                    detail: if fqn.contains('\\') {
804                        Some(fqn.to_string())
805                    } else {
806                        None
807                    },
808                    additional_text_edits,
809                    ..Default::default()
810                });
811            };
812
813            for (other, classes) in other_docs.iter().zip(other_classes()) {
814                // Class-like symbols: add `use` insertion when needed.
815                for (label, kind, fqn) in classes {
816                    push_class_item(&mut items, label, *kind, fqn);
817                }
818                // Non-class symbols (functions, methods, constants) need no use statement.
819                let cross: Vec<CompletionItem> = symbol_completions(other)
820                    .into_iter()
821                    .filter(|i| {
822                        !matches!(
823                            i.kind,
824                            Some(CompletionItemKind::CLASS)
825                                | Some(CompletionItemKind::INTERFACE)
826                                | Some(CompletionItemKind::ENUM)
827                        ) && i.kind != Some(CompletionItemKind::VARIABLE)
828                    })
829                    .collect();
830                items.extend(cross);
831            }
832
833            // Classes from files that aren't open in the editor (e.g.
834            // vendor/), found via the workspace index. Only fires once a
835            // prefix is typed, both to bound the search and because an
836            // empty-prefix workspace-wide dump would swamp the list.
837            if !prefix.is_empty()
838                && !prefix.contains('\\')
839                && let Some(search) = ctx.workspace_class_search
840            {
841                for (label, kind, fqn) in search(&prefix) {
842                    push_class_item(&mut items, &label, kind, &fqn);
843                }
844            }
845
846            let mut seen = std::collections::HashSet::new();
847            items.retain(|i| seen.insert(i.label.clone()));
848
849            if prefix.contains('\\') {
850                // Namespace-qualified prefix: filter by FQN prefix match.
851                let ns_prefix = prefix.trim_start_matches('\\').to_lowercase();
852                items.retain(|i| {
853                    let fqn = i.detail.as_deref().unwrap_or(&i.label);
854                    fqn.get(..ns_prefix.len())
855                        .is_some_and(|s| s.eq_ignore_ascii_case(&ns_prefix))
856                });
857            } else if !prefix.is_empty() {
858                let fq = crate::text::FuzzyQuery::new(&prefix);
859                items.retain(|i| fq.camel_match(&i.label));
860                for item in &mut items {
861                    item.sort_text = Some(camel_sort_key(&prefix, &item.label));
862                    item.filter_text = Some(item.label.clone());
863                }
864            }
865            items
866        }
867    }
868}
869
870#[cfg(test)]
871mod tests {
872    use super::*;
873
874    fn labels(items: &[CompletionItem]) -> Vec<&str> {
875        items.iter().map(|i| i.label.as_str()).collect()
876    }
877
878    #[test]
879    fn keywords_list_is_non_empty() {
880        let kws = keyword_completions();
881        assert!(
882            kws.len() >= 20,
883            "expected at least 20 keywords, got {}",
884            kws.len()
885        );
886    }
887
888    #[test]
889    fn keywords_contain_common_php_keywords() {
890        let kws = keyword_completions();
891        let ls = labels(&kws);
892        for expected in &[
893            "function",
894            "class",
895            "return",
896            "foreach",
897            "match",
898            "namespace",
899        ] {
900            assert!(ls.contains(expected), "missing keyword: {expected}");
901        }
902    }
903
904    #[test]
905    fn type_keywords_present() {
906        let kws = keyword_completions();
907        let ls = labels(&kws);
908        for expected in &[
909            "bool", "float", "int", "iterable", "mixed", "never", "object", "string", "void",
910            "parent",
911        ] {
912            assert!(ls.contains(expected), "missing type keyword: {expected}");
913        }
914    }
915
916    #[test]
917    fn all_keyword_items_have_keyword_kind() {
918        for item in keyword_completions() {
919            assert_eq!(item.kind, Some(CompletionItemKind::KEYWORD));
920        }
921    }
922
923    #[test]
924    fn magic_constants_all_present() {
925        let items = magic_constant_completions();
926        let ls: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
927        for name in &[
928            "__FILE__",
929            "__DIR__",
930            "__LINE__",
931            "__CLASS__",
932            "__FUNCTION__",
933            "__METHOD__",
934            "__NAMESPACE__",
935            "__TRAIT__",
936        ] {
937            assert!(ls.contains(name), "missing magic constant: {name}");
938        }
939    }
940
941    #[test]
942    fn magic_constants_have_constant_kind() {
943        for item in magic_constant_completions() {
944            assert_eq!(
945                item.kind,
946                Some(CompletionItemKind::CONSTANT),
947                "{} should have CONSTANT kind",
948                item.label
949            );
950        }
951    }
952
953    #[test]
954    fn resolve_attribute_class_extracts_name() {
955        let src = "<?php\n#[Route(\n";
956        // Position right after the '(' on line 1
957        let pos = Position {
958            line: 1,
959            character: 8,
960        };
961        let result = resolve_attribute_class(src, pos);
962        assert_eq!(result.as_deref(), Some("Route"));
963    }
964
965    #[test]
966    fn resolve_attribute_class_fqn_extracts_short_name() {
967        let src = "<?php\n#[\\Symfony\\Component\\Routing\\Route(\n";
968        let pos = Position {
969            line: 1,
970            character: 38,
971        };
972        let result = resolve_attribute_class(src, pos);
973        assert_eq!(result.as_deref(), Some("Route"));
974    }
975
976    #[test]
977    fn resolve_attribute_class_returns_none_for_regular_call() {
978        let src = "<?php\nsomeFunction(\n";
979        let pos = Position {
980            line: 1,
981            character: 14,
982        };
983        let result = resolve_attribute_class(src, pos);
984        assert!(result.is_none(), "should not match regular function call");
985    }
986}