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