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    /// Store handle for querying phpstorm-stubs member info on built-in PHP
304    /// classes via its mir-analyzer session. A handle rather than a borrowed
305    /// `&mut AnalysisSession`: `find_class_doc` (above) can itself lock the
306    /// session (`DocumentStore::resolve_class_ref_by_fqn`), and mir's
307    /// session mutex isn't reentrant, so nothing here may hold it across
308    /// that call — each session use below is its own short-lived lock.
309    /// `None` in unit tests that don't require stubs.
310    pub session: Option<&'a crate::document::document_store::DocumentStore>,
311    /// Laravel string-key index (`env`/`config`/`view`/... — see
312    /// `crate::laravel`), for completion inside those helper calls' string
313    /// arguments. `None` in unit tests that don't supply it, and inert
314    /// (empty) for non-Laravel workspaces.
315    pub laravel: Option<&'a crate::laravel::LaravelIndex>,
316}
317
318/// Returns `true` when `cursor_byte` falls inside a PHP string literal or
319/// comment. Scans the source from the beginning with a simple state machine;
320/// handles single/double-quoted strings (with backslash escapes), `// …` and
321/// `# …` line comments, and `/* … */` block comments. Heredoc/nowdoc are not
322/// tracked — they are too rare in interactive editing contexts to warrant the
323/// complexity, and missing them produces a false-negative (completions shown
324/// inside a heredoc), not a false-positive (completions suppressed outside one).
325pub(crate) fn cursor_in_string_or_comment(source: &str, cursor_byte: usize) -> bool {
326    #[derive(PartialEq)]
327    enum S {
328        Normal,
329        Single,
330        Double,
331        Line,
332        Block,
333    }
334    let bytes = source.as_bytes();
335    let limit = bytes.len().min(cursor_byte);
336    let mut i = 0usize;
337    let mut state = S::Normal;
338    while i < limit {
339        match state {
340            S::Normal => match bytes[i] {
341                b'\'' => {
342                    state = S::Single;
343                    i += 1;
344                }
345                b'"' => {
346                    state = S::Double;
347                    i += 1;
348                }
349                b'/' if i + 1 < limit && bytes[i + 1] == b'/' => {
350                    state = S::Line;
351                    i += 2;
352                }
353                // `#[` is a PHP 8 attribute — not a comment.
354                b'#' if !(i + 1 < limit && bytes[i + 1] == b'[') => {
355                    state = S::Line;
356                    i += 1;
357                }
358                b'/' if i + 1 < limit && bytes[i + 1] == b'*' => {
359                    state = S::Block;
360                    i += 2;
361                }
362                _ => {
363                    i += 1;
364                }
365            },
366            S::Single => match bytes[i] {
367                b'\\' => {
368                    i += 2;
369                }
370                b'\'' => {
371                    state = S::Normal;
372                    i += 1;
373                }
374                _ => {
375                    i += 1;
376                }
377            },
378            S::Double => match bytes[i] {
379                b'\\' => {
380                    i += 2;
381                }
382                b'"' => {
383                    state = S::Normal;
384                    i += 1;
385                }
386                _ => {
387                    i += 1;
388                }
389            },
390            S::Line => {
391                if bytes[i] == b'\n' {
392                    state = S::Normal;
393                }
394                i += 1;
395            }
396            S::Block => {
397                if bytes[i] == b'*' && i + 1 < limit && bytes[i + 1] == b'/' {
398                    state = S::Normal;
399                    i += 2;
400                } else {
401                    i += 1;
402                }
403            }
404        }
405    }
406    state != S::Normal
407}
408
409/// Completions filtered by trigger character, with optional context
410/// so that `->` completions can be scoped to the variable's class.
411pub fn filtered_completions_at(
412    doc: &ParsedDoc,
413    other_docs: &[Arc<ParsedDoc>],
414    trigger_character: Option<&str>,
415    ctx: &CompletionCtx<'_>,
416) -> Vec<CompletionItem> {
417    let source = ctx.source;
418    let position = ctx.position;
419
420    let doc_uri = ctx.doc_uri;
421
422    // `completions_for_string_key` checks `is_laravel` first (one bool read
423    // on an already-loaded `Arc`) so non-Laravel workspaces never pay for the
424    // line scan inside it. Computed once and reused by both the
425    // string-suppression guard and Feature 10.
426    let laravel_completions = source
427        .zip(position)
428        .and_then(|(src, pos)| crate::laravel::completions_for_string_key(src, pos, ctx.laravel));
429
430    // Blade-specific completions (component/Livewire tags, view/Livewire
431    // directive arguments) — bare helper calls inside `{{ }}` are already
432    // covered by `laravel_completions` above, a pure text scan that doesn't
433    // care whether it's running inside a Blade expression or plain PHP.
434    let blade_completions = doc_uri
435        .zip(source)
436        .zip(position)
437        .and_then(|((uri, src), pos)| {
438            crate::laravel::blade::completions(uri, src, pos, ctx.laravel)
439        });
440
441    // Request-field completion inside `$request->input('...')`/`get`/`post`/
442    // `query` — a naming-convention heuristic (see `laravel::request_fields`
443    // module docs), gated the same way every other Laravel feature is.
444    let request_field_completions = source.zip(position).and_then(|(src, pos)| {
445        if !ctx.laravel.is_some_and(|l| l.is_laravel) {
446            return None;
447        }
448        let (receiver, prefix) =
449            crate::laravel::request_fields::method_call_string_prefix(src, pos)?;
450        let fields =
451            crate::laravel::request_fields::harvest_fields(&doc.program().stmts, &receiver);
452        Some(
453            fields
454                .into_iter()
455                .filter(|f| f.starts_with(&prefix))
456                .map(|f| CompletionItem {
457                    label: f.clone(),
458                    kind: Some(CompletionItemKind::FIELD),
459                    insert_text: Some(f),
460                    ..Default::default()
461                })
462                .collect::<Vec<_>>(),
463        )
464    });
465
466    // Validation rule-name completion inside `->validate([...])`/
467    // `Validator::make([...])`/a `rules()` method's `return [...]` — gated
468    // on the AST (see `laravel::validation_rules` module docs for why this
469    // one isn't a pure text scan like the others above).
470    let validation_rule_completions = source.zip(position).and_then(|(src, pos)| {
471        if !ctx.laravel.is_some_and(|l| l.is_laravel) {
472            return None;
473        }
474        let prefix = crate::laravel::validation_rules::validation_rule_prefix(doc, src, pos)?;
475        Some(crate::laravel::validation_rules::validation_rule_completions(&prefix))
476    });
477
478    // Suppress all completions when the cursor is inside a string literal or
479    // comment — except for include/require path strings and Laravel
480    // string-key/request-field calls, where completions are legitimate
481    // inside the string argument.
482    if let (Some(src), Some(pos)) = (source, position) {
483        let cursor_byte = doc.view().byte_of_position(pos) as usize;
484        if cursor_in_string_or_comment(src, cursor_byte)
485            && include_path_prefix(src, pos).is_none()
486            && laravel_completions.is_none()
487            && blade_completions.is_none()
488            && request_field_completions.is_none()
489            && validation_rule_completions.is_none()
490        {
491            return vec![];
492        }
493    }
494    let empty_imports = HashMap::new();
495    let imports = ctx.file_imports.unwrap_or(&empty_imports);
496    let in_class_scope = matches!(
497        (source, position),
498        (Some(src), Some(pos)) if enclosing_class_at(src, doc, pos).is_some()
499    );
500
501    match trigger_character {
502        Some("$") => {
503            let mut items = superglobal_completions();
504            items.extend(
505                symbol_completions(doc)
506                    .into_iter()
507                    .filter(|i| i.kind == Some(CompletionItemKind::VARIABLE)),
508            );
509            items
510        }
511        Some(">") => {
512            // Arrow: $obj->  or  $this->
513            if let (Some(src), Some(pos)) = (source, position)
514                && let Some(class_names) = resolve_receiver_class(src, doc, pos, ctx.analysis)
515            {
516                // Feature 5: support union types (Foo|Bar)
517                let mut items = Vec::new();
518                let mut seen = std::collections::HashSet::new();
519                for class_name in class_names.split('|') {
520                    let class_name = class_name.trim();
521                    for item in all_instance_members(
522                        class_name,
523                        doc,
524                        other_docs,
525                        ctx.find_class_doc,
526                        ctx.session,
527                        in_class_scope,
528                    ) {
529                        if seen.insert(item.label.clone()) {
530                            items.push(item);
531                        }
532                    }
533                }
534                if !items.is_empty() {
535                    return items;
536                }
537            }
538            // Fallback: all methods from current doc
539            symbol_completions(doc)
540                .into_iter()
541                .filter(|i| i.kind == Some(CompletionItemKind::METHOD))
542                .collect()
543        }
544        Some(":") => {
545            // Static access: ClassName:: / self:: / static:: / parent::
546            if let (Some(src), Some(pos)) = (source, position)
547                && let Some(class_name) =
548                    resolve_static_receiver(src, doc, other_docs, pos, imports)
549            {
550                let items = all_static_members(
551                    &class_name,
552                    doc,
553                    other_docs,
554                    ctx.find_class_doc,
555                    ctx.session,
556                    in_class_scope,
557                );
558                if !items.is_empty() {
559                    return items;
560                }
561            }
562            vec![]
563        }
564        Some("[") => {
565            // PHP attribute: #[ — suggest only #[\Attribute]-annotated classes.
566            if let (Some(src), Some(pos)) = (source, position) {
567                let line = src.lines().nth(pos.line as usize).unwrap_or("");
568                let col = utf16_offset_to_byte(line, pos.character as usize);
569                let before = &line[..col];
570                if before.trim_end_matches('[').trim_end().ends_with('#') {
571                    return attribute_completions(src, pos, doc, other_docs, imports);
572                }
573            }
574            vec![]
575        }
576        Some("(") => {
577            // Named argument: funcName(
578            if let (Some(src), Some(pos)) = (source, position) {
579                let params = resolve_call_params(src, doc, other_docs, pos, ctx);
580                if !params.is_empty() {
581                    return params
582                        .into_iter()
583                        .map(|p| CompletionItem {
584                            label: format!("{p}:"),
585                            kind: Some(CompletionItemKind::VARIABLE),
586                            ..Default::default()
587                        })
588                        .collect();
589                }
590                // Attribute constructor: #[ClassName(
591                if let Some(attr_class) = resolve_attribute_class(src, pos) {
592                    let mut attr_params = params_of_method(doc, &attr_class, "__construct");
593                    if attr_params.is_empty() {
594                        for other in other_docs {
595                            attr_params = params_of_method(other, &attr_class, "__construct");
596                            if !attr_params.is_empty() {
597                                break;
598                            }
599                        }
600                    }
601                    if !attr_params.is_empty() {
602                        return attr_params
603                            .into_iter()
604                            .map(|p| CompletionItem {
605                                label: format!("{p}:"),
606                                kind: Some(CompletionItemKind::VARIABLE),
607                                detail: Some(format!("#{attr_class} argument")),
608                                ..Default::default()
609                            })
610                            .collect();
611                    }
612                }
613            }
614            vec![]
615        }
616        _ => {
617            // Static access context: ClassName::member (invoked without trigger char).
618            // Strip any identifier chars being typed as the member prefix.
619            if let (Some(src), Some(pos)) = (source, position) {
620                let line = src.lines().nth(pos.line as usize).unwrap_or("");
621                let col = utf16_offset_to_byte(line, pos.character as usize);
622                let before = &line[..col];
623                let pre_colon = before.trim_end_matches(|c: char| c.is_alphanumeric() || c == '_');
624                if pre_colon.ends_with("::") {
625                    let colon_end_char = pre_colon.encode_utf16().count() as u32;
626                    let colon_pos = tower_lsp_server::ls_types::Position {
627                        line: pos.line,
628                        character: colon_end_char,
629                    };
630                    if let Some(class_name) =
631                        resolve_static_receiver(src, doc, other_docs, colon_pos, imports)
632                    {
633                        let items = all_static_members(
634                            &class_name,
635                            doc,
636                            other_docs,
637                            ctx.find_class_doc,
638                            ctx.session,
639                            in_class_scope,
640                        );
641                        if !items.is_empty() {
642                            return items;
643                        }
644                    }
645                }
646            }
647
648            // Detect $obj->member context (invoked completion without trigger char).
649            // Returns only the receiver class's instance members so unrelated class
650            // methods don't pollute the list.
651            if let (Some(src), Some(pos)) = (source, position) {
652                let line = src.lines().nth(pos.line as usize).unwrap_or("");
653                let col = utf16_offset_to_byte(line, pos.character as usize);
654                let before = &line[..col];
655                // Strip any identifier chars the user is typing as the member prefix.
656                let pre_arrow = before.trim_end_matches(|c: char| c.is_alphanumeric() || c == '_');
657                let has_arrow = pre_arrow.ends_with("->") || pre_arrow.ends_with("?->");
658                if has_arrow {
659                    // Synthesise a cursor that sits right at the end of the arrow so
660                    // that `resolve_receiver_class` — which strips the trailing `->` /
661                    // `?->` itself — can locate the receiver.  This correctly handles
662                    // simple variables ($obj->), `(new Foo())->`, method chains
663                    // ($obj->getUser()->), and nullable operators ($obj?->).
664                    let arrow_end_char = pre_arrow.encode_utf16().count() as u32;
665                    let arrow_pos = tower_lsp_server::ls_types::Position {
666                        line: pos.line,
667                        character: arrow_end_char,
668                    };
669                    if let Some(cls) = resolve_receiver_class(src, doc, arrow_pos, ctx.analysis) {
670                        let mut items = Vec::new();
671                        let mut seen = std::collections::HashSet::new();
672                        for class_name in cls.split('|') {
673                            for item in all_instance_members(
674                                class_name.trim(),
675                                doc,
676                                other_docs,
677                                ctx.find_class_doc,
678                                ctx.session,
679                                in_class_scope,
680                            ) {
681                                if seen.insert(item.label.clone()) {
682                                    items.push(item);
683                                }
684                            }
685                        }
686                        if !items.is_empty() {
687                            // Apply fuzzy filtering based on the typed prefix.
688                            let prefix = before.strip_prefix(pre_arrow).unwrap_or("").to_string();
689                            if !prefix.is_empty() {
690                                let fq = crate::text::FuzzyQuery::new(&prefix);
691                                items.retain(|i| {
692                                    let match_against = if i.label.starts_with('$') {
693                                        i.label.strip_prefix('$').unwrap_or(&i.label)
694                                    } else {
695                                        &i.label
696                                    };
697                                    fq.camel_match(match_against)
698                                });
699                                for item in &mut items {
700                                    let match_against = if item.label.starts_with('$') {
701                                        item.label.strip_prefix('$').unwrap_or(&item.label)
702                                    } else {
703                                        &item.label
704                                    };
705                                    item.sort_text =
706                                        Some(crate::text::camel_sort_key(&prefix, match_against));
707                                    item.filter_text = Some(item.label.clone());
708                                }
709                            }
710                            return items;
711                        }
712                    }
713                }
714            }
715
716            // Attribute context: #[ or #[PartialName — invoked without trigger char.
717            if let (Some(src), Some(pos)) = (source, position) {
718                let line = src.lines().nth(pos.line as usize).unwrap_or("");
719                let col = utf16_offset_to_byte(line, pos.character as usize);
720                let before = &line[..col];
721                let pre_ident =
722                    before.trim_end_matches(|c: char| c.is_alphanumeric() || c == '_' || c == '\\');
723                if pre_ident.trim_end().ends_with("#[") || pre_ident.trim_end() == "#[" {
724                    let items = attribute_completions(src, pos, doc, other_docs, imports);
725                    if !items.is_empty() {
726                        return items;
727                    }
728                }
729            }
730
731            // Feature 4: detect `use `/`use function `/`use const ` context and
732            // suggest FQNs from other docs, scoped to the right symbol kind.
733            if let (Some(src), Some(pos)) = (source, position)
734                && let Some((use_kind, use_prefix)) = use_completion_prefix(src, pos)
735            {
736                let mut use_items: Vec<CompletionItem> = Vec::new();
737                for other in other_docs {
738                    collect_fqns_with_prefix(
739                        &other.program().stmts,
740                        "",
741                        &use_prefix,
742                        use_kind,
743                        &mut use_items,
744                    );
745                }
746                // Also check current doc
747                collect_fqns_with_prefix(
748                    &doc.program().stmts,
749                    "",
750                    &use_prefix,
751                    use_kind,
752                    &mut use_items,
753                );
754                if !use_items.is_empty() {
755                    return use_items;
756                }
757            }
758
759            // Blade directive/tag completions take priority over Feature 9
760            // below: `@include('` textually ends in `include(` just like a
761            // real PHP `include(...)` statement, and `blade_completions` is
762            // always `None` outside `.blade.php` files, so this can't affect
763            // plain-PHP `include`/`require` path completions.
764            if let Some(items) = blade_completions {
765                return items;
766            }
767
768            // Feature 9: include/require path completions
769            if let (Some(src), Some(pos), Some(uri)) = (source, position, doc_uri)
770                && let Some(prefix) = include_path_prefix(src, pos)
771            {
772                // When in include/require context, return path completions (even if empty)
773                // instead of falling back to keywords/symbols
774                let items = include_path_completions(uri, &prefix);
775                return items;
776            }
777
778            // Feature 10: Laravel string-key completions (env/config/...)
779            if let Some(items) = laravel_completions {
780                return items;
781            }
782            if let Some(items) = request_field_completions {
783                return items;
784            }
785            if let Some(items) = validation_rule_completions {
786                return items;
787            }
788
789            // Classes (label, kind, FQN) per other doc, collected lazily once
790            // per request: the sub-namespace branch below falls through to the
791            // default cross-file loop when nothing matches, which previously
792            // re-collected the same lists from every doc's AST a second time.
793            let other_classes_cell: std::cell::OnceCell<
794                Vec<Vec<(String, CompletionItemKind, String)>>,
795            > = std::cell::OnceCell::new();
796            let other_classes = || {
797                other_classes_cell.get_or_init(|| {
798                    other_docs
799                        .iter()
800                        .map(|other| {
801                            let mut classes = Vec::new();
802                            collect_classes_with_ns(&other.program().stmts, "", &mut classes);
803                            classes
804                        })
805                        .collect()
806                })
807            };
808
809            // Feature 3: Sub-namespace \ completions outside use statement
810            if let (Some(src), Some(pos)) = (source, position)
811                && let Some(prefix) = typed_prefix(Some(src), Some(pos))
812                && prefix.contains('\\')
813            {
814                // Check we're NOT in a use statement
815                let is_use = use_completion_prefix(src, pos).is_some();
816                if !is_use {
817                    let prefix_lc = prefix.trim_start_matches('\\').to_lowercase();
818                    let mut ns_items: Vec<CompletionItem> = Vec::new();
819                    for classes in other_classes() {
820                        for (label, kind, fqn) in classes {
821                            if fqn
822                                .get(..prefix_lc.len())
823                                .is_some_and(|s| s.eq_ignore_ascii_case(&prefix_lc))
824                            {
825                                ns_items.push(CompletionItem {
826                                    label: label.clone(),
827                                    kind: Some(*kind),
828                                    insert_text: Some(label.clone()),
829                                    detail: Some(fqn.clone()),
830                                    ..Default::default()
831                                });
832                            }
833                        }
834                    }
835                    let mut classes = Vec::new();
836                    collect_classes_with_ns(&doc.program().stmts, "", &mut classes);
837                    for (label, kind, fqn) in classes {
838                        if fqn
839                            .get(..prefix_lc.len())
840                            .is_some_and(|s| s.eq_ignore_ascii_case(&prefix_lc))
841                        {
842                            ns_items.push(CompletionItem {
843                                label: label.clone(),
844                                kind: Some(kind),
845                                insert_text: Some(label),
846                                detail: Some(fqn),
847                                ..Default::default()
848                            });
849                        }
850                    }
851                    if !ns_items.is_empty() {
852                        return ns_items;
853                    }
854                }
855            }
856
857            // Feature 7: match arm completions
858            if let (Some(src), Some(pos)) = (source, position)
859                && let Some(match_items) = match_arm_completions(
860                    src,
861                    doc,
862                    other_docs,
863                    pos,
864                    ctx.analysis,
865                    ctx.find_class_doc,
866                )
867                && !match_items.is_empty()
868            {
869                let mut all = match_items;
870                // extend with normal items below, but return early here
871                let mut normal_items = keyword_completions();
872                normal_items.extend(magic_constant_completions());
873                normal_items.extend(builtin_completions());
874                normal_items.extend(superglobal_completions());
875                normal_items.extend(symbol_completions(doc));
876                all.extend(normal_items);
877
878                // Deduplicate by label (first occurrence wins)
879                let mut seen = std::collections::HashSet::new();
880                all.retain(|i| seen.insert(i.label.clone()));
881
882                return all;
883            }
884
885            // Feature 5: Magic method completions in class body
886            let mut magic_items: Vec<CompletionItem> = Vec::new();
887            if let (Some(src), Some(pos)) = (source, position)
888                && enclosing_class_at(src, doc, pos).is_some()
889            {
890                magic_items.extend(magic_method_completions());
891            }
892
893            // Extract the typed prefix early: it bounds the workspace-wide
894            // class search below, and — for a plain identifier prefix — the
895            // static item sources apply the same camel-match the final retain
896            // would, so hundreds of keyword/builtin items aren't materialized
897            // per keystroke just to be dropped.
898            let prefix = typed_prefix(source, position).unwrap_or_default();
899            let ident_query = (!prefix.is_empty() && !prefix.contains('\\'))
900                .then(|| crate::text::FuzzyQuery::new(&prefix));
901            let keep_static =
902                |label: &str| ident_query.as_ref().is_none_or(|fq| fq.camel_match(label));
903
904            let mut items = keyword_completions_matching(&keep_static);
905            items.extend(magic_constant_completions_matching(&keep_static));
906            items.extend(builtin_completions_matching(&keep_static));
907            items.extend(superglobal_completions_matching(&keep_static));
908            // Feature 2: scope variable completions to before cursor line
909            let sym_items = if let (Some(_src), Some(pos)) = (source, position) {
910                symbol_completions_before(doc, pos.line)
911            } else {
912                symbol_completions(doc)
913            };
914            items.extend(sym_items);
915            items.extend(magic_items);
916
917            let cur_ns = current_file_namespace(&doc.program().stmts);
918
919            // Same for every class candidate in this request (depends only on
920            // `source`) — computed once instead of per candidate, since a single
921            // request may build up to dozens of class items (open-doc classes
922            // plus up to `WORKSPACE_CLASS_SEARCH_LIMIT` workspace-index matches).
923            let use_pos = source.map(use_insert_position);
924
925            // Builds a class completion item, adding a `use` insertion edit
926            // unless the class is already reachable (same namespace, global,
927            // or already imported).
928            let push_class_item = |items: &mut Vec<CompletionItem>,
929                                   label: &str,
930                                   kind: CompletionItemKind,
931                                   fqn: &str| {
932                let additional_text_edits = if let Some(pos) = use_pos {
933                    let in_same_ns = !cur_ns.is_empty() && fqn == format!("{}\\{}", cur_ns, label);
934                    let is_global = !fqn.contains('\\');
935                    let already = imports.contains_key(label);
936                    if !in_same_ns && !is_global && !already {
937                        Some(vec![TextEdit {
938                            range: Range {
939                                start: pos,
940                                end: pos,
941                            },
942                            new_text: format!("use {};\n", fqn),
943                        }])
944                    } else {
945                        None
946                    }
947                } else {
948                    None
949                };
950                items.push(CompletionItem {
951                    label: label.to_string(),
952                    kind: Some(kind),
953                    detail: if fqn.contains('\\') {
954                        Some(fqn.to_string())
955                    } else {
956                        None
957                    },
958                    additional_text_edits,
959                    ..Default::default()
960                });
961            };
962
963            for (other, classes) in other_docs.iter().zip(other_classes()) {
964                // Class-like symbols: add `use` insertion when needed.
965                for (label, kind, fqn) in classes {
966                    push_class_item(&mut items, label, *kind, fqn);
967                }
968                // Non-class symbols (functions, methods, constants) need no use statement.
969                let cross: Vec<CompletionItem> = symbol_completions(other)
970                    .into_iter()
971                    .filter(|i| {
972                        !matches!(
973                            i.kind,
974                            Some(CompletionItemKind::CLASS)
975                                | Some(CompletionItemKind::INTERFACE)
976                                | Some(CompletionItemKind::ENUM)
977                        ) && i.kind != Some(CompletionItemKind::VARIABLE)
978                    })
979                    .collect();
980                items.extend(cross);
981            }
982
983            // Classes from files that aren't open in the editor (e.g.
984            // vendor/), found via the workspace index. Only fires once a
985            // prefix is typed, both to bound the search and because an
986            // empty-prefix workspace-wide dump would swamp the list.
987            if !prefix.is_empty()
988                && !prefix.contains('\\')
989                && let Some(search) = ctx.workspace_class_search
990            {
991                for (label, kind, fqn) in search(&prefix) {
992                    push_class_item(&mut items, &label, kind, &fqn);
993                }
994            }
995
996            let mut seen = std::collections::HashSet::new();
997            items.retain(|i| seen.insert(i.label.clone()));
998
999            if prefix.contains('\\') {
1000                // Namespace-qualified prefix: filter by FQN prefix match.
1001                let ns_prefix = prefix.trim_start_matches('\\').to_lowercase();
1002                items.retain(|i| {
1003                    let fqn = i.detail.as_deref().unwrap_or(&i.label);
1004                    fqn.get(..ns_prefix.len())
1005                        .is_some_and(|s| s.eq_ignore_ascii_case(&ns_prefix))
1006                });
1007            } else if !prefix.is_empty() {
1008                let fq = crate::text::FuzzyQuery::new(&prefix);
1009                items.retain(|i| fq.camel_match(&i.label));
1010                for item in &mut items {
1011                    item.sort_text = Some(camel_sort_key(&prefix, &item.label));
1012                    item.filter_text = Some(item.label.clone());
1013                }
1014            }
1015            items
1016        }
1017    }
1018}
1019
1020#[cfg(test)]
1021mod tests {
1022    use super::*;
1023
1024    #[test]
1025    fn method_params_ignore_same_short_name_in_other_namespace() {
1026        let doc = ParsedDoc::parse("<?php".to_string());
1027        let others = [
1028            Arc::new(ParsedDoc::parse(
1029                "<?php\nnamespace Other;\nclass Svc { public function run(int $wrong) {} }"
1030                    .to_string(),
1031            )),
1032            Arc::new(ParsedDoc::parse(
1033                "<?php\nnamespace App;\nclass Svc { public function run(int $right) {} }"
1034                    .to_string(),
1035            )),
1036        ];
1037        let params =
1038            params_of_method_anywhere("App\\Svc", "run", &doc, &others, &CompletionCtx::default());
1039        assert_eq!(params, ["right"]);
1040    }
1041
1042    fn labels(items: &[CompletionItem]) -> Vec<&str> {
1043        items.iter().map(|i| i.label.as_str()).collect()
1044    }
1045
1046    #[test]
1047    fn keywords_list_is_non_empty() {
1048        let kws = keyword_completions();
1049        assert!(
1050            kws.len() >= 20,
1051            "expected at least 20 keywords, got {}",
1052            kws.len()
1053        );
1054    }
1055
1056    #[test]
1057    fn keywords_contain_common_php_keywords() {
1058        let kws = keyword_completions();
1059        let ls = labels(&kws);
1060        for expected in &[
1061            "function",
1062            "class",
1063            "return",
1064            "foreach",
1065            "match",
1066            "namespace",
1067        ] {
1068            assert!(ls.contains(expected), "missing keyword: {expected}");
1069        }
1070    }
1071
1072    #[test]
1073    fn type_keywords_present() {
1074        let kws = keyword_completions();
1075        let ls = labels(&kws);
1076        for expected in &[
1077            "bool", "float", "int", "iterable", "mixed", "never", "object", "string", "void",
1078            "parent",
1079        ] {
1080            assert!(ls.contains(expected), "missing type keyword: {expected}");
1081        }
1082    }
1083
1084    #[test]
1085    fn all_keyword_items_have_keyword_kind() {
1086        for item in keyword_completions() {
1087            assert_eq!(item.kind, Some(CompletionItemKind::KEYWORD));
1088        }
1089    }
1090
1091    #[test]
1092    fn magic_constants_all_present() {
1093        let items = magic_constant_completions();
1094        let ls: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
1095        for name in &[
1096            "__FILE__",
1097            "__DIR__",
1098            "__LINE__",
1099            "__CLASS__",
1100            "__FUNCTION__",
1101            "__METHOD__",
1102            "__NAMESPACE__",
1103            "__TRAIT__",
1104        ] {
1105            assert!(ls.contains(name), "missing magic constant: {name}");
1106        }
1107    }
1108
1109    #[test]
1110    fn magic_constants_have_constant_kind() {
1111        for item in magic_constant_completions() {
1112            assert_eq!(
1113                item.kind,
1114                Some(CompletionItemKind::CONSTANT),
1115                "{} should have CONSTANT kind",
1116                item.label
1117            );
1118        }
1119    }
1120
1121    #[test]
1122    fn resolve_attribute_class_extracts_name() {
1123        let src = "<?php\n#[Route(\n";
1124        // Position right after the '(' on line 1
1125        let pos = Position {
1126            line: 1,
1127            character: 8,
1128        };
1129        let result = resolve_attribute_class(src, pos);
1130        assert_eq!(result.as_deref(), Some("Route"));
1131    }
1132
1133    #[test]
1134    fn resolve_attribute_class_fqn_extracts_short_name() {
1135        let src = "<?php\n#[\\Symfony\\Component\\Routing\\Route(\n";
1136        let pos = Position {
1137            line: 1,
1138            character: 38,
1139        };
1140        let result = resolve_attribute_class(src, pos);
1141        assert_eq!(result.as_deref(), Some("Route"));
1142    }
1143
1144    #[test]
1145    fn resolve_attribute_class_returns_none_for_regular_call() {
1146        let src = "<?php\nsomeFunction(\n";
1147        let pos = Position {
1148            line: 1,
1149            character: 14,
1150        };
1151        let result = resolve_attribute_class(src, pos);
1152        assert!(result.is_none(), "should not match regular function call");
1153    }
1154}