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