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    // Suppress all completions when the cursor is inside a string literal or
462    // comment — except for include/require path strings and Laravel
463    // string-key/request-field calls, where completions are legitimate
464    // inside the string argument.
465    if let (Some(src), Some(pos)) = (source, position) {
466        let cursor_byte = doc.view().byte_of_position(pos) as usize;
467        if cursor_in_string_or_comment(src, cursor_byte)
468            && include_path_prefix(src, pos).is_none()
469            && laravel_completions.is_none()
470            && blade_completions.is_none()
471            && request_field_completions.is_none()
472        {
473            return vec![];
474        }
475    }
476    let empty_imports = HashMap::new();
477    let imports = ctx.file_imports.unwrap_or(&empty_imports);
478
479    match trigger_character {
480        Some("$") => {
481            let mut items = superglobal_completions();
482            items.extend(
483                symbol_completions(doc)
484                    .into_iter()
485                    .filter(|i| i.kind == Some(CompletionItemKind::VARIABLE)),
486            );
487            items
488        }
489        Some(">") => {
490            // Arrow: $obj->  or  $this->
491            if let (Some(src), Some(pos)) = (source, position)
492                && let Some(class_names) = resolve_receiver_class(src, doc, pos, ctx.analysis)
493            {
494                // Feature 5: support union types (Foo|Bar)
495                let mut items = Vec::new();
496                let mut seen = std::collections::HashSet::new();
497                for class_name in class_names.split('|') {
498                    let class_name = class_name.trim();
499                    for item in all_instance_members(
500                        class_name,
501                        doc,
502                        other_docs,
503                        ctx.find_class_doc,
504                        ctx.session.as_deref(),
505                    ) {
506                        if seen.insert(item.label.clone()) {
507                            items.push(item);
508                        }
509                    }
510                }
511                if !items.is_empty() {
512                    return items;
513                }
514            }
515            // Fallback: all methods from current doc
516            symbol_completions(doc)
517                .into_iter()
518                .filter(|i| i.kind == Some(CompletionItemKind::METHOD))
519                .collect()
520        }
521        Some(":") => {
522            // Static access: ClassName:: / self:: / static:: / parent::
523            if let (Some(src), Some(pos)) = (source, position)
524                && let Some(class_name) =
525                    resolve_static_receiver(src, doc, other_docs, pos, imports)
526            {
527                let items = all_static_members(
528                    &class_name,
529                    doc,
530                    other_docs,
531                    ctx.find_class_doc,
532                    ctx.session.as_deref(),
533                );
534                if !items.is_empty() {
535                    return items;
536                }
537            }
538            vec![]
539        }
540        Some("[") => {
541            // PHP attribute: #[ — suggest only #[\Attribute]-annotated classes.
542            if let (Some(src), Some(pos)) = (source, position) {
543                let line = src.lines().nth(pos.line as usize).unwrap_or("");
544                let col = utf16_offset_to_byte(line, pos.character as usize);
545                let before = &line[..col];
546                if before.trim_end_matches('[').trim_end().ends_with('#') {
547                    return attribute_completions(src, pos, doc, other_docs, imports);
548                }
549            }
550            vec![]
551        }
552        Some("(") => {
553            // Named argument: funcName(
554            if let (Some(src), Some(pos)) = (source, position) {
555                let params = resolve_call_params(src, doc, other_docs, pos, ctx);
556                if !params.is_empty() {
557                    return params
558                        .into_iter()
559                        .map(|p| CompletionItem {
560                            label: format!("{p}:"),
561                            kind: Some(CompletionItemKind::VARIABLE),
562                            ..Default::default()
563                        })
564                        .collect();
565                }
566                // Attribute constructor: #[ClassName(
567                if let Some(attr_class) = resolve_attribute_class(src, pos) {
568                    let mut attr_params = params_of_method(doc, &attr_class, "__construct");
569                    if attr_params.is_empty() {
570                        for other in other_docs {
571                            attr_params = params_of_method(other, &attr_class, "__construct");
572                            if !attr_params.is_empty() {
573                                break;
574                            }
575                        }
576                    }
577                    if !attr_params.is_empty() {
578                        return attr_params
579                            .into_iter()
580                            .map(|p| CompletionItem {
581                                label: format!("{p}:"),
582                                kind: Some(CompletionItemKind::VARIABLE),
583                                detail: Some(format!("#{attr_class} argument")),
584                                ..Default::default()
585                            })
586                            .collect();
587                    }
588                }
589            }
590            vec![]
591        }
592        _ => {
593            // Static access context: ClassName::member (invoked without trigger char).
594            // Strip any identifier chars being typed as the member prefix.
595            if let (Some(src), Some(pos)) = (source, position) {
596                let line = src.lines().nth(pos.line as usize).unwrap_or("");
597                let col = utf16_offset_to_byte(line, pos.character as usize);
598                let before = &line[..col];
599                let pre_colon = before.trim_end_matches(|c: char| c.is_alphanumeric() || c == '_');
600                if pre_colon.ends_with("::") {
601                    let colon_end_char = pre_colon.encode_utf16().count() as u32;
602                    let colon_pos = tower_lsp_server::ls_types::Position {
603                        line: pos.line,
604                        character: colon_end_char,
605                    };
606                    if let Some(class_name) =
607                        resolve_static_receiver(src, doc, other_docs, colon_pos, imports)
608                    {
609                        let items = all_static_members(
610                            &class_name,
611                            doc,
612                            other_docs,
613                            ctx.find_class_doc,
614                            ctx.session.as_deref(),
615                        );
616                        if !items.is_empty() {
617                            return items;
618                        }
619                    }
620                }
621            }
622
623            // Detect $obj->member context (invoked completion without trigger char).
624            // Returns only the receiver class's instance members so unrelated class
625            // methods don't pollute the list.
626            if let (Some(src), Some(pos)) = (source, position) {
627                let line = src.lines().nth(pos.line as usize).unwrap_or("");
628                let col = utf16_offset_to_byte(line, pos.character as usize);
629                let before = &line[..col];
630                // Strip any identifier chars the user is typing as the member prefix.
631                let pre_arrow = before.trim_end_matches(|c: char| c.is_alphanumeric() || c == '_');
632                let has_arrow = pre_arrow.ends_with("->") || pre_arrow.ends_with("?->");
633                if has_arrow {
634                    // Synthesise a cursor that sits right at the end of the arrow so
635                    // that `resolve_receiver_class` — which strips the trailing `->` /
636                    // `?->` itself — can locate the receiver.  This correctly handles
637                    // simple variables ($obj->), `(new Foo())->`, method chains
638                    // ($obj->getUser()->), and nullable operators ($obj?->).
639                    let arrow_end_char = pre_arrow.encode_utf16().count() as u32;
640                    let arrow_pos = tower_lsp_server::ls_types::Position {
641                        line: pos.line,
642                        character: arrow_end_char,
643                    };
644                    if let Some(cls) = resolve_receiver_class(src, doc, arrow_pos, ctx.analysis) {
645                        let mut items = Vec::new();
646                        let mut seen = std::collections::HashSet::new();
647                        for class_name in cls.split('|') {
648                            for item in all_instance_members(
649                                class_name.trim(),
650                                doc,
651                                other_docs,
652                                ctx.find_class_doc,
653                                ctx.session.as_deref(),
654                            ) {
655                                if seen.insert(item.label.clone()) {
656                                    items.push(item);
657                                }
658                            }
659                        }
660                        if !items.is_empty() {
661                            // Apply fuzzy filtering based on the typed prefix.
662                            let prefix = before.strip_prefix(pre_arrow).unwrap_or("").to_string();
663                            if !prefix.is_empty() {
664                                let fq = crate::text::FuzzyQuery::new(&prefix);
665                                items.retain(|i| {
666                                    let match_against = if i.label.starts_with('$') {
667                                        i.label.strip_prefix('$').unwrap_or(&i.label)
668                                    } else {
669                                        &i.label
670                                    };
671                                    fq.camel_match(match_against)
672                                });
673                                for item in &mut items {
674                                    let match_against = if item.label.starts_with('$') {
675                                        item.label.strip_prefix('$').unwrap_or(&item.label)
676                                    } else {
677                                        &item.label
678                                    };
679                                    item.sort_text =
680                                        Some(crate::text::camel_sort_key(&prefix, match_against));
681                                    item.filter_text = Some(item.label.clone());
682                                }
683                            }
684                            return items;
685                        }
686                    }
687                }
688            }
689
690            // Attribute context: #[ or #[PartialName — invoked without trigger char.
691            if let (Some(src), Some(pos)) = (source, position) {
692                let line = src.lines().nth(pos.line as usize).unwrap_or("");
693                let col = utf16_offset_to_byte(line, pos.character as usize);
694                let before = &line[..col];
695                let pre_ident =
696                    before.trim_end_matches(|c: char| c.is_alphanumeric() || c == '_' || c == '\\');
697                if pre_ident.trim_end().ends_with("#[") || pre_ident.trim_end() == "#[" {
698                    let items = attribute_completions(src, pos, doc, other_docs, imports);
699                    if !items.is_empty() {
700                        return items;
701                    }
702                }
703            }
704
705            // Feature 4: detect `use `/`use function `/`use const ` context and
706            // suggest FQNs from other docs, scoped to the right symbol kind.
707            if let (Some(src), Some(pos)) = (source, position)
708                && let Some((use_kind, use_prefix)) = use_completion_prefix(src, pos)
709            {
710                let mut use_items: Vec<CompletionItem> = Vec::new();
711                for other in other_docs {
712                    collect_fqns_with_prefix(
713                        &other.program().stmts,
714                        "",
715                        &use_prefix,
716                        use_kind,
717                        &mut use_items,
718                    );
719                }
720                // Also check current doc
721                collect_fqns_with_prefix(
722                    &doc.program().stmts,
723                    "",
724                    &use_prefix,
725                    use_kind,
726                    &mut use_items,
727                );
728                if !use_items.is_empty() {
729                    return use_items;
730                }
731            }
732
733            // Blade directive/tag completions take priority over Feature 9
734            // below: `@include('` textually ends in `include(` just like a
735            // real PHP `include(...)` statement, and `blade_completions` is
736            // always `None` outside `.blade.php` files, so this can't affect
737            // plain-PHP `include`/`require` path completions.
738            if let Some(items) = blade_completions {
739                return items;
740            }
741
742            // Feature 9: include/require path completions
743            if let (Some(src), Some(pos), Some(uri)) = (source, position, doc_uri)
744                && let Some(prefix) = include_path_prefix(src, pos)
745            {
746                // When in include/require context, return path completions (even if empty)
747                // instead of falling back to keywords/symbols
748                let items = include_path_completions(uri, &prefix);
749                return items;
750            }
751
752            // Feature 10: Laravel string-key completions (env/config/...)
753            if let Some(items) = laravel_completions {
754                return items;
755            }
756            if let Some(items) = request_field_completions {
757                return items;
758            }
759
760            // Classes (label, kind, FQN) per other doc, collected lazily once
761            // per request: the sub-namespace branch below falls through to the
762            // default cross-file loop when nothing matches, which previously
763            // re-collected the same lists from every doc's AST a second time.
764            let other_classes_cell: std::cell::OnceCell<
765                Vec<Vec<(String, CompletionItemKind, String)>>,
766            > = std::cell::OnceCell::new();
767            let other_classes = || {
768                other_classes_cell.get_or_init(|| {
769                    other_docs
770                        .iter()
771                        .map(|other| {
772                            let mut classes = Vec::new();
773                            collect_classes_with_ns(&other.program().stmts, "", &mut classes);
774                            classes
775                        })
776                        .collect()
777                })
778            };
779
780            // Feature 3: Sub-namespace \ completions outside use statement
781            if let (Some(src), Some(pos)) = (source, position)
782                && let Some(prefix) = typed_prefix(Some(src), Some(pos))
783                && prefix.contains('\\')
784            {
785                // Check we're NOT in a use statement
786                let is_use = use_completion_prefix(src, pos).is_some();
787                if !is_use {
788                    let prefix_lc = prefix.trim_start_matches('\\').to_lowercase();
789                    let mut ns_items: Vec<CompletionItem> = Vec::new();
790                    for classes in other_classes() {
791                        for (label, kind, fqn) in classes {
792                            if fqn
793                                .get(..prefix_lc.len())
794                                .is_some_and(|s| s.eq_ignore_ascii_case(&prefix_lc))
795                            {
796                                ns_items.push(CompletionItem {
797                                    label: label.clone(),
798                                    kind: Some(*kind),
799                                    insert_text: Some(label.clone()),
800                                    detail: Some(fqn.clone()),
801                                    ..Default::default()
802                                });
803                            }
804                        }
805                    }
806                    let mut classes = Vec::new();
807                    collect_classes_with_ns(&doc.program().stmts, "", &mut classes);
808                    for (label, kind, fqn) in classes {
809                        if fqn
810                            .get(..prefix_lc.len())
811                            .is_some_and(|s| s.eq_ignore_ascii_case(&prefix_lc))
812                        {
813                            ns_items.push(CompletionItem {
814                                label: label.clone(),
815                                kind: Some(kind),
816                                insert_text: Some(label),
817                                detail: Some(fqn),
818                                ..Default::default()
819                            });
820                        }
821                    }
822                    if !ns_items.is_empty() {
823                        return ns_items;
824                    }
825                }
826            }
827
828            // Feature 7: match arm completions
829            if let (Some(src), Some(pos)) = (source, position)
830                && let Some(match_items) = match_arm_completions(
831                    src,
832                    doc,
833                    other_docs,
834                    pos,
835                    ctx.analysis,
836                    ctx.find_class_doc,
837                )
838                && !match_items.is_empty()
839            {
840                let mut all = match_items;
841                // extend with normal items below, but return early here
842                let mut normal_items = keyword_completions();
843                normal_items.extend(magic_constant_completions());
844                normal_items.extend(builtin_completions());
845                normal_items.extend(superglobal_completions());
846                normal_items.extend(symbol_completions(doc));
847                all.extend(normal_items);
848
849                // Deduplicate by label (first occurrence wins)
850                let mut seen = std::collections::HashSet::new();
851                all.retain(|i| seen.insert(i.label.clone()));
852
853                return all;
854            }
855
856            // Feature 5: Magic method completions in class body
857            let mut magic_items: Vec<CompletionItem> = Vec::new();
858            if let (Some(src), Some(pos)) = (source, position)
859                && enclosing_class_at(src, doc, pos).is_some()
860            {
861                magic_items.extend(magic_method_completions());
862            }
863
864            // Extract the typed prefix early: it bounds the workspace-wide
865            // class search below, and — for a plain identifier prefix — the
866            // static item sources apply the same camel-match the final retain
867            // would, so hundreds of keyword/builtin items aren't materialized
868            // per keystroke just to be dropped.
869            let prefix = typed_prefix(source, position).unwrap_or_default();
870            let ident_query = (!prefix.is_empty() && !prefix.contains('\\'))
871                .then(|| crate::text::FuzzyQuery::new(&prefix));
872            let keep_static =
873                |label: &str| ident_query.as_ref().is_none_or(|fq| fq.camel_match(label));
874
875            let mut items = keyword_completions_matching(&keep_static);
876            items.extend(magic_constant_completions_matching(&keep_static));
877            items.extend(builtin_completions_matching(&keep_static));
878            items.extend(superglobal_completions_matching(&keep_static));
879            // Feature 2: scope variable completions to before cursor line
880            let sym_items = if let (Some(_src), Some(pos)) = (source, position) {
881                symbol_completions_before(doc, pos.line)
882            } else {
883                symbol_completions(doc)
884            };
885            items.extend(sym_items);
886            items.extend(magic_items);
887
888            let cur_ns = current_file_namespace(&doc.program().stmts);
889
890            // Same for every class candidate in this request (depends only on
891            // `source`) — computed once instead of per candidate, since a single
892            // request may build up to dozens of class items (open-doc classes
893            // plus up to `WORKSPACE_CLASS_SEARCH_LIMIT` workspace-index matches).
894            let use_pos = source.map(use_insert_position);
895
896            // Builds a class completion item, adding a `use` insertion edit
897            // unless the class is already reachable (same namespace, global,
898            // or already imported).
899            let push_class_item = |items: &mut Vec<CompletionItem>,
900                                   label: &str,
901                                   kind: CompletionItemKind,
902                                   fqn: &str| {
903                let additional_text_edits = if let Some(pos) = use_pos {
904                    let in_same_ns = !cur_ns.is_empty() && fqn == format!("{}\\{}", cur_ns, label);
905                    let is_global = !fqn.contains('\\');
906                    let already = imports.contains_key(label);
907                    if !in_same_ns && !is_global && !already {
908                        Some(vec![TextEdit {
909                            range: Range {
910                                start: pos,
911                                end: pos,
912                            },
913                            new_text: format!("use {};\n", fqn),
914                        }])
915                    } else {
916                        None
917                    }
918                } else {
919                    None
920                };
921                items.push(CompletionItem {
922                    label: label.to_string(),
923                    kind: Some(kind),
924                    detail: if fqn.contains('\\') {
925                        Some(fqn.to_string())
926                    } else {
927                        None
928                    },
929                    additional_text_edits,
930                    ..Default::default()
931                });
932            };
933
934            for (other, classes) in other_docs.iter().zip(other_classes()) {
935                // Class-like symbols: add `use` insertion when needed.
936                for (label, kind, fqn) in classes {
937                    push_class_item(&mut items, label, *kind, fqn);
938                }
939                // Non-class symbols (functions, methods, constants) need no use statement.
940                let cross: Vec<CompletionItem> = symbol_completions(other)
941                    .into_iter()
942                    .filter(|i| {
943                        !matches!(
944                            i.kind,
945                            Some(CompletionItemKind::CLASS)
946                                | Some(CompletionItemKind::INTERFACE)
947                                | Some(CompletionItemKind::ENUM)
948                        ) && i.kind != Some(CompletionItemKind::VARIABLE)
949                    })
950                    .collect();
951                items.extend(cross);
952            }
953
954            // Classes from files that aren't open in the editor (e.g.
955            // vendor/), found via the workspace index. Only fires once a
956            // prefix is typed, both to bound the search and because an
957            // empty-prefix workspace-wide dump would swamp the list.
958            if !prefix.is_empty()
959                && !prefix.contains('\\')
960                && let Some(search) = ctx.workspace_class_search
961            {
962                for (label, kind, fqn) in search(&prefix) {
963                    push_class_item(&mut items, &label, kind, &fqn);
964                }
965            }
966
967            let mut seen = std::collections::HashSet::new();
968            items.retain(|i| seen.insert(i.label.clone()));
969
970            if prefix.contains('\\') {
971                // Namespace-qualified prefix: filter by FQN prefix match.
972                let ns_prefix = prefix.trim_start_matches('\\').to_lowercase();
973                items.retain(|i| {
974                    let fqn = i.detail.as_deref().unwrap_or(&i.label);
975                    fqn.get(..ns_prefix.len())
976                        .is_some_and(|s| s.eq_ignore_ascii_case(&ns_prefix))
977                });
978            } else if !prefix.is_empty() {
979                let fq = crate::text::FuzzyQuery::new(&prefix);
980                items.retain(|i| fq.camel_match(&i.label));
981                for item in &mut items {
982                    item.sort_text = Some(camel_sort_key(&prefix, &item.label));
983                    item.filter_text = Some(item.label.clone());
984                }
985            }
986            items
987        }
988    }
989}
990
991#[cfg(test)]
992mod tests {
993    use super::*;
994
995    fn labels(items: &[CompletionItem]) -> Vec<&str> {
996        items.iter().map(|i| i.label.as_str()).collect()
997    }
998
999    #[test]
1000    fn keywords_list_is_non_empty() {
1001        let kws = keyword_completions();
1002        assert!(
1003            kws.len() >= 20,
1004            "expected at least 20 keywords, got {}",
1005            kws.len()
1006        );
1007    }
1008
1009    #[test]
1010    fn keywords_contain_common_php_keywords() {
1011        let kws = keyword_completions();
1012        let ls = labels(&kws);
1013        for expected in &[
1014            "function",
1015            "class",
1016            "return",
1017            "foreach",
1018            "match",
1019            "namespace",
1020        ] {
1021            assert!(ls.contains(expected), "missing keyword: {expected}");
1022        }
1023    }
1024
1025    #[test]
1026    fn type_keywords_present() {
1027        let kws = keyword_completions();
1028        let ls = labels(&kws);
1029        for expected in &[
1030            "bool", "float", "int", "iterable", "mixed", "never", "object", "string", "void",
1031            "parent",
1032        ] {
1033            assert!(ls.contains(expected), "missing type keyword: {expected}");
1034        }
1035    }
1036
1037    #[test]
1038    fn all_keyword_items_have_keyword_kind() {
1039        for item in keyword_completions() {
1040            assert_eq!(item.kind, Some(CompletionItemKind::KEYWORD));
1041        }
1042    }
1043
1044    #[test]
1045    fn magic_constants_all_present() {
1046        let items = magic_constant_completions();
1047        let ls: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
1048        for name in &[
1049            "__FILE__",
1050            "__DIR__",
1051            "__LINE__",
1052            "__CLASS__",
1053            "__FUNCTION__",
1054            "__METHOD__",
1055            "__NAMESPACE__",
1056            "__TRAIT__",
1057        ] {
1058            assert!(ls.contains(name), "missing magic constant: {name}");
1059        }
1060    }
1061
1062    #[test]
1063    fn magic_constants_have_constant_kind() {
1064        for item in magic_constant_completions() {
1065            assert_eq!(
1066                item.kind,
1067                Some(CompletionItemKind::CONSTANT),
1068                "{} should have CONSTANT kind",
1069                item.label
1070            );
1071        }
1072    }
1073
1074    #[test]
1075    fn resolve_attribute_class_extracts_name() {
1076        let src = "<?php\n#[Route(\n";
1077        // Position right after the '(' on line 1
1078        let pos = Position {
1079            line: 1,
1080            character: 8,
1081        };
1082        let result = resolve_attribute_class(src, pos);
1083        assert_eq!(result.as_deref(), Some("Route"));
1084    }
1085
1086    #[test]
1087    fn resolve_attribute_class_fqn_extracts_short_name() {
1088        let src = "<?php\n#[\\Symfony\\Component\\Routing\\Route(\n";
1089        let pos = Position {
1090            line: 1,
1091            character: 38,
1092        };
1093        let result = resolve_attribute_class(src, pos);
1094        assert_eq!(result.as_deref(), Some("Route"));
1095    }
1096
1097    #[test]
1098    fn resolve_attribute_class_returns_none_for_regular_call() {
1099        let src = "<?php\nsomeFunction(\n";
1100        let pos = Position {
1101            line: 1,
1102            character: 14,
1103        };
1104        let result = resolve_attribute_class(src, pos);
1105        assert!(result.is_none(), "should not match regular function call");
1106    }
1107}