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::{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    /// mir-analyzer session for querying phpstorm-stubs member info on
237    /// built-in PHP classes. `None` in unit tests that don't require stubs.
238    pub session: Option<std::sync::Arc<mir_analyzer::AnalysisSession>>,
239    /// Laravel string-key index (`env`/`config`/`view`/... — see
240    /// `crate::laravel`), for completion inside those helper calls' string
241    /// arguments. `None` in unit tests that don't supply it, and inert
242    /// (empty) for non-Laravel workspaces.
243    pub laravel: Option<&'a crate::laravel::LaravelIndex>,
244}
245
246/// Returns `true` when `cursor_byte` falls inside a PHP string literal or
247/// comment. Scans the source from the beginning with a simple state machine;
248/// handles single/double-quoted strings (with backslash escapes), `// …` and
249/// `# …` line comments, and `/* … */` block comments. Heredoc/nowdoc are not
250/// tracked — they are too rare in interactive editing contexts to warrant the
251/// complexity, and missing them produces a false-negative (completions shown
252/// inside a heredoc), not a false-positive (completions suppressed outside one).
253pub(crate) fn cursor_in_string_or_comment(source: &str, cursor_byte: usize) -> bool {
254    #[derive(PartialEq)]
255    enum S {
256        Normal,
257        Single,
258        Double,
259        Line,
260        Block,
261    }
262    let bytes = source.as_bytes();
263    let limit = bytes.len().min(cursor_byte);
264    let mut i = 0usize;
265    let mut state = S::Normal;
266    while i < limit {
267        match state {
268            S::Normal => match bytes[i] {
269                b'\'' => {
270                    state = S::Single;
271                    i += 1;
272                }
273                b'"' => {
274                    state = S::Double;
275                    i += 1;
276                }
277                b'/' if i + 1 < limit && bytes[i + 1] == b'/' => {
278                    state = S::Line;
279                    i += 2;
280                }
281                // `#[` is a PHP 8 attribute — not a comment.
282                b'#' if !(i + 1 < limit && bytes[i + 1] == b'[') => {
283                    state = S::Line;
284                    i += 1;
285                }
286                b'/' if i + 1 < limit && bytes[i + 1] == b'*' => {
287                    state = S::Block;
288                    i += 2;
289                }
290                _ => {
291                    i += 1;
292                }
293            },
294            S::Single => match bytes[i] {
295                b'\\' => {
296                    i += 2;
297                }
298                b'\'' => {
299                    state = S::Normal;
300                    i += 1;
301                }
302                _ => {
303                    i += 1;
304                }
305            },
306            S::Double => match bytes[i] {
307                b'\\' => {
308                    i += 2;
309                }
310                b'"' => {
311                    state = S::Normal;
312                    i += 1;
313                }
314                _ => {
315                    i += 1;
316                }
317            },
318            S::Line => {
319                if bytes[i] == b'\n' {
320                    state = S::Normal;
321                }
322                i += 1;
323            }
324            S::Block => {
325                if bytes[i] == b'*' && i + 1 < limit && bytes[i + 1] == b'/' {
326                    state = S::Normal;
327                    i += 2;
328                } else {
329                    i += 1;
330                }
331            }
332        }
333    }
334    state != S::Normal
335}
336
337/// Completions filtered by trigger character, with optional context
338/// so that `->` completions can be scoped to the variable's class.
339pub fn filtered_completions_at(
340    doc: &ParsedDoc,
341    other_docs: &[Arc<ParsedDoc>],
342    trigger_character: Option<&str>,
343    ctx: &CompletionCtx<'_>,
344) -> Vec<CompletionItem> {
345    let source = ctx.source;
346    let position = ctx.position;
347
348    let doc_uri = ctx.doc_uri;
349
350    // `completions_for_string_key` checks `is_laravel` first (one bool read
351    // on an already-loaded `Arc`) so non-Laravel workspaces never pay for the
352    // line scan inside it. Computed once and reused by both the
353    // string-suppression guard and Feature 10.
354    let laravel_completions = source
355        .zip(position)
356        .and_then(|(src, pos)| crate::laravel::completions_for_string_key(src, pos, ctx.laravel));
357
358    // Suppress all completions when the cursor is inside a string literal or
359    // comment — except for include/require path strings and Laravel
360    // string-key calls, where completions are legitimate inside the string
361    // 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)
365            && include_path_prefix(src, pos).is_none()
366            && laravel_completions.is_none()
367        {
368            return vec![];
369        }
370    }
371    let empty_imports = HashMap::new();
372    let imports = ctx.file_imports.unwrap_or(&empty_imports);
373
374    match trigger_character {
375        Some("$") => {
376            let mut items = superglobal_completions();
377            items.extend(
378                symbol_completions(doc)
379                    .into_iter()
380                    .filter(|i| i.kind == Some(CompletionItemKind::VARIABLE)),
381            );
382            items
383        }
384        Some(">") => {
385            // Arrow: $obj->  or  $this->
386            if let (Some(src), Some(pos)) = (source, position)
387                && let Some(class_names) = resolve_receiver_class(src, doc, pos, ctx.analysis)
388            {
389                // Feature 5: support union types (Foo|Bar)
390                let mut items = Vec::new();
391                let mut seen = std::collections::HashSet::new();
392                for class_name in class_names.split('|') {
393                    let class_name = class_name.trim();
394                    for item in all_instance_members(
395                        class_name,
396                        doc,
397                        other_docs,
398                        ctx.find_class_doc,
399                        ctx.session.as_deref(),
400                    ) {
401                        if seen.insert(item.label.clone()) {
402                            items.push(item);
403                        }
404                    }
405                }
406                if !items.is_empty() {
407                    return items;
408                }
409            }
410            // Fallback: all methods from current doc
411            symbol_completions(doc)
412                .into_iter()
413                .filter(|i| i.kind == Some(CompletionItemKind::METHOD))
414                .collect()
415        }
416        Some(":") => {
417            // Static access: ClassName:: / self:: / static:: / parent::
418            if let (Some(src), Some(pos)) = (source, position)
419                && let Some(class_name) =
420                    resolve_static_receiver(src, doc, other_docs, pos, imports)
421            {
422                let items = all_static_members(
423                    &class_name,
424                    doc,
425                    other_docs,
426                    ctx.find_class_doc,
427                    ctx.session.as_deref(),
428                );
429                if !items.is_empty() {
430                    return items;
431                }
432            }
433            vec![]
434        }
435        Some("[") => {
436            // PHP attribute: #[ — suggest only #[\Attribute]-annotated classes.
437            if let (Some(src), Some(pos)) = (source, position) {
438                let line = src.lines().nth(pos.line as usize).unwrap_or("");
439                let col = utf16_offset_to_byte(line, pos.character as usize);
440                let before = &line[..col];
441                if before.trim_end_matches('[').trim_end().ends_with('#') {
442                    return attribute_completions(src, pos, doc, other_docs, imports);
443                }
444            }
445            vec![]
446        }
447        Some("(") => {
448            // Named argument: funcName(
449            if let (Some(src), Some(pos)) = (source, position) {
450                let params = resolve_call_params(src, doc, other_docs, pos);
451                if !params.is_empty() {
452                    return params
453                        .into_iter()
454                        .map(|p| CompletionItem {
455                            label: format!("{p}:"),
456                            kind: Some(CompletionItemKind::VARIABLE),
457                            ..Default::default()
458                        })
459                        .collect();
460                }
461                // Attribute constructor: #[ClassName(
462                if let Some(attr_class) = resolve_attribute_class(src, pos) {
463                    let mut attr_params = params_of_method(doc, &attr_class, "__construct");
464                    if attr_params.is_empty() {
465                        for other in other_docs {
466                            attr_params = params_of_method(other, &attr_class, "__construct");
467                            if !attr_params.is_empty() {
468                                break;
469                            }
470                        }
471                    }
472                    if !attr_params.is_empty() {
473                        return attr_params
474                            .into_iter()
475                            .map(|p| CompletionItem {
476                                label: format!("{p}:"),
477                                kind: Some(CompletionItemKind::VARIABLE),
478                                detail: Some(format!("#{attr_class} argument")),
479                                ..Default::default()
480                            })
481                            .collect();
482                    }
483                }
484            }
485            vec![]
486        }
487        _ => {
488            // Static access context: ClassName::member (invoked without trigger char).
489            // Strip any identifier chars being typed as the member prefix.
490            if let (Some(src), Some(pos)) = (source, position) {
491                let line = src.lines().nth(pos.line as usize).unwrap_or("");
492                let col = utf16_offset_to_byte(line, pos.character as usize);
493                let before = &line[..col];
494                let pre_colon = before.trim_end_matches(|c: char| c.is_alphanumeric() || c == '_');
495                if pre_colon.ends_with("::") {
496                    let colon_end_char = pre_colon.encode_utf16().count() as u32;
497                    let colon_pos = tower_lsp::lsp_types::Position {
498                        line: pos.line,
499                        character: colon_end_char,
500                    };
501                    if let Some(class_name) =
502                        resolve_static_receiver(src, doc, other_docs, colon_pos, imports)
503                    {
504                        let items = all_static_members(
505                            &class_name,
506                            doc,
507                            other_docs,
508                            ctx.find_class_doc,
509                            ctx.session.as_deref(),
510                        );
511                        if !items.is_empty() {
512                            return items;
513                        }
514                    }
515                }
516            }
517
518            // Detect $obj->member context (invoked completion without trigger char).
519            // Returns only the receiver class's instance members so unrelated class
520            // methods don't pollute the list.
521            if let (Some(src), Some(pos)) = (source, position) {
522                let line = src.lines().nth(pos.line as usize).unwrap_or("");
523                let col = utf16_offset_to_byte(line, pos.character as usize);
524                let before = &line[..col];
525                // Strip any identifier chars the user is typing as the member prefix.
526                let pre_arrow = before.trim_end_matches(|c: char| c.is_alphanumeric() || c == '_');
527                let has_arrow = pre_arrow.ends_with("->") || pre_arrow.ends_with("?->");
528                if has_arrow {
529                    // Synthesise a cursor that sits right at the end of the arrow so
530                    // that `resolve_receiver_class` — which strips the trailing `->` /
531                    // `?->` itself — can locate the receiver.  This correctly handles
532                    // simple variables ($obj->), `(new Foo())->`, method chains
533                    // ($obj->getUser()->), and nullable operators ($obj?->).
534                    let arrow_end_char = pre_arrow.encode_utf16().count() as u32;
535                    let arrow_pos = tower_lsp::lsp_types::Position {
536                        line: pos.line,
537                        character: arrow_end_char,
538                    };
539                    if let Some(cls) = resolve_receiver_class(src, doc, arrow_pos, ctx.analysis) {
540                        let mut items = Vec::new();
541                        let mut seen = std::collections::HashSet::new();
542                        for class_name in cls.split('|') {
543                            for item in all_instance_members(
544                                class_name.trim(),
545                                doc,
546                                other_docs,
547                                ctx.find_class_doc,
548                                ctx.session.as_deref(),
549                            ) {
550                                if seen.insert(item.label.clone()) {
551                                    items.push(item);
552                                }
553                            }
554                        }
555                        if !items.is_empty() {
556                            // Apply fuzzy filtering based on the typed prefix.
557                            let prefix = before.strip_prefix(pre_arrow).unwrap_or("").to_string();
558                            if !prefix.is_empty() {
559                                let fq = crate::text::FuzzyQuery::new(&prefix);
560                                items.retain(|i| {
561                                    let match_against = if i.label.starts_with('$') {
562                                        i.label.strip_prefix('$').unwrap_or(&i.label)
563                                    } else {
564                                        &i.label
565                                    };
566                                    fq.camel_match(match_against)
567                                });
568                                for item in &mut items {
569                                    let match_against = if item.label.starts_with('$') {
570                                        item.label.strip_prefix('$').unwrap_or(&item.label)
571                                    } else {
572                                        &item.label
573                                    };
574                                    item.sort_text =
575                                        Some(crate::text::camel_sort_key(&prefix, match_against));
576                                    item.filter_text = Some(item.label.clone());
577                                }
578                            }
579                            return items;
580                        }
581                    }
582                }
583            }
584
585            // Attribute context: #[ or #[PartialName — invoked without trigger char.
586            if let (Some(src), Some(pos)) = (source, position) {
587                let line = src.lines().nth(pos.line as usize).unwrap_or("");
588                let col = utf16_offset_to_byte(line, pos.character as usize);
589                let before = &line[..col];
590                let pre_ident =
591                    before.trim_end_matches(|c: char| c.is_alphanumeric() || c == '_' || c == '\\');
592                if pre_ident.trim_end().ends_with("#[") || pre_ident.trim_end() == "#[" {
593                    let items = attribute_completions(src, pos, doc, other_docs, imports);
594                    if !items.is_empty() {
595                        return items;
596                    }
597                }
598            }
599
600            // Feature 4: detect `use ` context and suggest FQNs from other docs
601            if let (Some(src), Some(pos)) = (source, position)
602                && let Some(use_prefix) = use_completion_prefix(src, pos)
603            {
604                let mut use_items: Vec<CompletionItem> = Vec::new();
605                for other in other_docs {
606                    collect_fqns_with_prefix(
607                        &other.program().stmts,
608                        "",
609                        &use_prefix,
610                        &mut use_items,
611                    );
612                }
613                // Also check current doc
614                collect_fqns_with_prefix(&doc.program().stmts, "", &use_prefix, &mut use_items);
615                if !use_items.is_empty() {
616                    return use_items;
617                }
618            }
619
620            // Feature 9: include/require path completions
621            if let (Some(src), Some(pos), Some(uri)) = (source, position, doc_uri)
622                && let Some(prefix) = include_path_prefix(src, pos)
623            {
624                // When in include/require context, return path completions (even if empty)
625                // instead of falling back to keywords/symbols
626                let items = include_path_completions(uri, &prefix);
627                return items;
628            }
629
630            // Feature 10: Laravel string-key completions (env/config/...)
631            if let Some(items) = laravel_completions {
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                    ctx.analysis,
711                    ctx.find_class_doc,
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            // Extract the typed prefix early: it bounds the workspace-wide
740            // class search below, and — for a plain identifier prefix — the
741            // static item sources apply the same camel-match the final retain
742            // would, so hundreds of keyword/builtin items aren't materialized
743            // per keystroke just to be dropped.
744            let prefix = typed_prefix(source, position).unwrap_or_default();
745            let ident_query = (!prefix.is_empty() && !prefix.contains('\\'))
746                .then(|| crate::text::FuzzyQuery::new(&prefix));
747            let keep_static =
748                |label: &str| ident_query.as_ref().is_none_or(|fq| fq.camel_match(label));
749
750            let mut items = keyword_completions_matching(&keep_static);
751            items.extend(magic_constant_completions_matching(&keep_static));
752            items.extend(builtin_completions_matching(&keep_static));
753            items.extend(superglobal_completions_matching(&keep_static));
754            // Feature 2: scope variable completions to before cursor line
755            let sym_items = if let (Some(_src), Some(pos)) = (source, position) {
756                symbol_completions_before(doc, pos.line)
757            } else {
758                symbol_completions(doc)
759            };
760            items.extend(sym_items);
761            items.extend(magic_items);
762
763            let cur_ns = current_file_namespace(&doc.program().stmts);
764
765            // Same for every class candidate in this request (depends only on
766            // `source`) — computed once instead of per candidate, since a single
767            // request may build up to dozens of class items (open-doc classes
768            // plus up to `WORKSPACE_CLASS_SEARCH_LIMIT` workspace-index matches).
769            let use_pos = source.map(use_insert_position);
770
771            // Builds a class completion item, adding a `use` insertion edit
772            // unless the class is already reachable (same namespace, global,
773            // or already imported).
774            let push_class_item = |items: &mut Vec<CompletionItem>,
775                                   label: &str,
776                                   kind: CompletionItemKind,
777                                   fqn: &str| {
778                let additional_text_edits = if let Some(pos) = use_pos {
779                    let in_same_ns = !cur_ns.is_empty() && fqn == format!("{}\\{}", cur_ns, label);
780                    let is_global = !fqn.contains('\\');
781                    let already = imports.contains_key(label);
782                    if !in_same_ns && !is_global && !already {
783                        Some(vec![TextEdit {
784                            range: Range {
785                                start: pos,
786                                end: pos,
787                            },
788                            new_text: format!("use {};\n", fqn),
789                        }])
790                    } else {
791                        None
792                    }
793                } else {
794                    None
795                };
796                items.push(CompletionItem {
797                    label: label.to_string(),
798                    kind: Some(kind),
799                    detail: if fqn.contains('\\') {
800                        Some(fqn.to_string())
801                    } else {
802                        None
803                    },
804                    additional_text_edits,
805                    ..Default::default()
806                });
807            };
808
809            for (other, classes) in other_docs.iter().zip(other_classes()) {
810                // Class-like symbols: add `use` insertion when needed.
811                for (label, kind, fqn) in classes {
812                    push_class_item(&mut items, label, *kind, fqn);
813                }
814                // Non-class symbols (functions, methods, constants) need no use statement.
815                let cross: Vec<CompletionItem> = symbol_completions(other)
816                    .into_iter()
817                    .filter(|i| {
818                        !matches!(
819                            i.kind,
820                            Some(CompletionItemKind::CLASS)
821                                | Some(CompletionItemKind::INTERFACE)
822                                | Some(CompletionItemKind::ENUM)
823                        ) && i.kind != Some(CompletionItemKind::VARIABLE)
824                    })
825                    .collect();
826                items.extend(cross);
827            }
828
829            // Classes from files that aren't open in the editor (e.g.
830            // vendor/), found via the workspace index. Only fires once a
831            // prefix is typed, both to bound the search and because an
832            // empty-prefix workspace-wide dump would swamp the list.
833            if !prefix.is_empty()
834                && !prefix.contains('\\')
835                && let Some(search) = ctx.workspace_class_search
836            {
837                for (label, kind, fqn) in search(&prefix) {
838                    push_class_item(&mut items, &label, kind, &fqn);
839                }
840            }
841
842            let mut seen = std::collections::HashSet::new();
843            items.retain(|i| seen.insert(i.label.clone()));
844
845            if prefix.contains('\\') {
846                // Namespace-qualified prefix: filter by FQN prefix match.
847                let ns_prefix = prefix.trim_start_matches('\\').to_lowercase();
848                items.retain(|i| {
849                    let fqn = i.detail.as_deref().unwrap_or(&i.label);
850                    fqn.get(..ns_prefix.len())
851                        .is_some_and(|s| s.eq_ignore_ascii_case(&ns_prefix))
852                });
853            } else if !prefix.is_empty() {
854                let fq = crate::text::FuzzyQuery::new(&prefix);
855                items.retain(|i| fq.camel_match(&i.label));
856                for item in &mut items {
857                    item.sort_text = Some(camel_sort_key(&prefix, &item.label));
858                    item.filter_text = Some(item.label.clone());
859                }
860            }
861            items
862        }
863    }
864}
865
866#[cfg(test)]
867mod tests {
868    use super::*;
869
870    fn labels(items: &[CompletionItem]) -> Vec<&str> {
871        items.iter().map(|i| i.label.as_str()).collect()
872    }
873
874    #[test]
875    fn keywords_list_is_non_empty() {
876        let kws = keyword_completions();
877        assert!(
878            kws.len() >= 20,
879            "expected at least 20 keywords, got {}",
880            kws.len()
881        );
882    }
883
884    #[test]
885    fn keywords_contain_common_php_keywords() {
886        let kws = keyword_completions();
887        let ls = labels(&kws);
888        for expected in &[
889            "function",
890            "class",
891            "return",
892            "foreach",
893            "match",
894            "namespace",
895        ] {
896            assert!(ls.contains(expected), "missing keyword: {expected}");
897        }
898    }
899
900    #[test]
901    fn type_keywords_present() {
902        let kws = keyword_completions();
903        let ls = labels(&kws);
904        for expected in &[
905            "bool", "float", "int", "iterable", "mixed", "never", "object", "string", "void",
906            "parent",
907        ] {
908            assert!(ls.contains(expected), "missing type keyword: {expected}");
909        }
910    }
911
912    #[test]
913    fn all_keyword_items_have_keyword_kind() {
914        for item in keyword_completions() {
915            assert_eq!(item.kind, Some(CompletionItemKind::KEYWORD));
916        }
917    }
918
919    #[test]
920    fn magic_constants_all_present() {
921        let items = magic_constant_completions();
922        let ls: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
923        for name in &[
924            "__FILE__",
925            "__DIR__",
926            "__LINE__",
927            "__CLASS__",
928            "__FUNCTION__",
929            "__METHOD__",
930            "__NAMESPACE__",
931            "__TRAIT__",
932        ] {
933            assert!(ls.contains(name), "missing magic constant: {name}");
934        }
935    }
936
937    #[test]
938    fn magic_constants_have_constant_kind() {
939        for item in magic_constant_completions() {
940            assert_eq!(
941                item.kind,
942                Some(CompletionItemKind::CONSTANT),
943                "{} should have CONSTANT kind",
944                item.label
945            );
946        }
947    }
948
949    #[test]
950    fn resolve_attribute_class_extracts_name() {
951        let src = "<?php\n#[Route(\n";
952        // Position right after the '(' on line 1
953        let pos = Position {
954            line: 1,
955            character: 8,
956        };
957        let result = resolve_attribute_class(src, pos);
958        assert_eq!(result.as_deref(), Some("Route"));
959    }
960
961    #[test]
962    fn resolve_attribute_class_fqn_extracts_short_name() {
963        let src = "<?php\n#[\\Symfony\\Component\\Routing\\Route(\n";
964        let pos = Position {
965            line: 1,
966            character: 38,
967        };
968        let result = resolve_attribute_class(src, pos);
969        assert_eq!(result.as_deref(), Some("Route"));
970    }
971
972    #[test]
973    fn resolve_attribute_class_returns_none_for_regular_call() {
974        let src = "<?php\nsomeFunction(\n";
975        let pos = Position {
976            line: 1,
977            character: 14,
978        };
979        let result = resolve_attribute_class(src, pos);
980        assert!(result.is_none(), "should not match regular function call");
981    }
982}