Skip to main content

php_lsp/completion/
mod.rs

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