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