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