Skip to main content

php_lsp/completion/
mod.rs

1mod keyword;
2pub use keyword::{keyword_completions, magic_constant_completions};
3
4mod symbols;
5pub use symbols::{
6    builtin_completions, superglobal_completions, symbol_completions, symbol_completions_before,
7};
8
9mod member;
10use member::{
11    all_instance_members, all_static_members, line_byte_offset, magic_method_completions,
12    receiver_class_at, resolve_receiver_class, resolve_static_receiver,
13};
14
15mod namespace;
16use namespace::{
17    collect_attribute_classes, collect_classes_with_ns, collect_fqns_with_prefix,
18    current_file_namespace, infer_attribute_target, typed_prefix, use_completion_prefix,
19    use_insert_position,
20};
21
22use std::sync::Arc;
23
24use tower_lsp::lsp_types::{
25    CompletionItem, CompletionItemKind, InsertTextFormat, Position, Range, TextEdit, Url,
26};
27
28use tower_lsp::lsp_types::{Documentation, MarkupContent, MarkupKind};
29
30use crate::ast::{ParsedDoc, format_type_hint};
31use crate::docblock::find_docblock;
32use crate::hover::format_params_str;
33use crate::phpstorm_meta::PhpStormMeta;
34use crate::type_map::{
35    TypeMap, enclosing_class_at, members_of_class, params_of_function, params_of_method,
36};
37use crate::util::{camel_sort_key, utf16_offset_to_byte};
38use std::collections::HashMap;
39
40/// Build a `CompletionItem` for a callable (function or method).
41///
42/// If the function has parameters the item uses snippet format with `$1`
43/// inside the parentheses so the cursor lands there.  Zero-parameter
44/// callables insert `name()` as plain text.
45fn callable_item(label: &str, kind: CompletionItemKind, has_params: bool) -> CompletionItem {
46    if has_params {
47        CompletionItem {
48            label: label.to_string(),
49            kind: Some(kind),
50            insert_text: Some(format!("{}($1)", label)),
51            insert_text_format: Some(InsertTextFormat::SNIPPET),
52            ..Default::default()
53        }
54    } else {
55        CompletionItem {
56            label: label.to_string(),
57            kind: Some(kind),
58            insert_text: Some(format!("{}()", label)),
59            ..Default::default()
60        }
61    }
62}
63
64/// Build a named-argument `CompletionItem` for a callable when param names are
65/// known.  Produces a label like `create(name:, age:)` and a snippet like
66/// `create(name: $1, age: $2)`.  Returns `None` when the param list is empty
67/// (no advantage over the positional item in that case).
68fn named_arg_item(
69    label: &str,
70    kind: CompletionItemKind,
71    params: &[php_ast::Param<'_, '_>],
72) -> Option<CompletionItem> {
73    if params.is_empty() {
74        return None;
75    }
76    let named_label = format!(
77        "{}({})",
78        label,
79        params
80            .iter()
81            .map(|p| format!("{}:", &p.name.to_string()))
82            .collect::<Vec<_>>()
83            .join(", ")
84    );
85    let snippet = format!(
86        "{}({})",
87        label,
88        params
89            .iter()
90            .enumerate()
91            .map(|(i, p)| format!("{}: ${}", p.name, i + 1))
92            .collect::<Vec<_>>()
93            .join(", ")
94    );
95    Some(CompletionItem {
96        label: named_label,
97        kind: Some(kind),
98        insert_text: Some(snippet),
99        insert_text_format: Some(InsertTextFormat::SNIPPET),
100        detail: Some("named args".to_string()),
101        ..Default::default()
102    })
103}
104
105/// Build the full signature string for a callable, e.g.
106/// `"function foo(string $bar, int $baz): bool"`.
107fn build_function_sig(
108    name: &str,
109    params: &[php_ast::Param<'_, '_>],
110    return_type: Option<&php_ast::TypeHint<'_, '_>>,
111) -> String {
112    let params_str = format_params_str(params);
113    let ret = return_type
114        .map(|r| format!(": {}", format_type_hint(r)))
115        .unwrap_or_default();
116    format!("function {}({}){}", name, params_str, ret)
117}
118
119/// Build a `Documentation` value from a docblock found before `sym_name` in `doc`.
120fn docblock_docs(doc: &ParsedDoc, sym_name: &str) -> Option<Documentation> {
121    let db = find_docblock(&doc.program().stmts, sym_name)?;
122    let md = db.to_markdown();
123    if md.is_empty() {
124        None
125    } else {
126        Some(Documentation::MarkupContent(MarkupContent {
127            kind: MarkupKind::Markdown,
128            value: md,
129        }))
130    }
131}
132
133/// If the `(` trigger occurs inside an attribute like `#[ClassName(`, extract
134/// the attribute class name so we can offer its `__construct` parameter names.
135fn resolve_attribute_class(source: &str, position: Position) -> Option<String> {
136    let line = source.lines().nth(position.line as usize)?;
137    let col = utf16_offset_to_byte(line, position.character as usize);
138    let before = line[..col].trim_end_matches('(').trim_end();
139    // Look backwards on the same line for `#[ClassName` or `#[\NS\ClassName`
140    let hash_pos = before.rfind("#[")?;
141    let after_bracket = before[hash_pos + 2..].trim_start();
142    // Strip leading backslashes (FQN), keep the short name
143    let name: String = after_bracket
144        .trim_start_matches('\\')
145        .rsplit('\\')
146        .next()
147        .unwrap_or("")
148        .chars()
149        .take_while(|c| c.is_alphanumeric() || *c == '_')
150        .collect();
151    if name.is_empty() { None } else { Some(name) }
152}
153
154fn resolve_call_params(
155    source: &str,
156    doc: &ParsedDoc,
157    other_docs: &[Arc<ParsedDoc>],
158    position: Position,
159) -> Vec<String> {
160    let line = match source.lines().nth(position.line as usize) {
161        Some(l) => l,
162        None => return vec![],
163    };
164    let col = utf16_offset_to_byte(line, position.character as usize);
165    let before = &line[..col];
166    let before = before.strip_suffix('(').unwrap_or(before);
167    let func_name: String = before
168        .chars()
169        .rev()
170        .take_while(|&c| c.is_alphanumeric() || c == '_')
171        .collect::<String>()
172        .chars()
173        .rev()
174        .collect();
175    if func_name.is_empty() {
176        return vec![];
177    }
178    let mut params = params_of_function(doc, &func_name);
179    if params.is_empty() {
180        for other in other_docs {
181            params = params_of_function(other, &func_name);
182            if !params.is_empty() {
183                break;
184            }
185        }
186    }
187    params
188}
189
190/// Workspace-index-backed class lookup: maps a short class name to the
191/// `ParsedDoc` that defines it. Used by `all_instance_members` and
192/// `all_static_members` to avoid scanning all workspace docs linearly.
193pub type ClassDocLookup<'a> = &'a dyn Fn(&str) -> Option<Arc<ParsedDoc>>;
194
195/// Optional context for completion requests that enables richer results
196/// (e.g. auto-import edits, `->` scoping to a class).
197#[derive(Default)]
198pub struct CompletionCtx<'a> {
199    pub source: Option<&'a str>,
200    pub position: Option<Position>,
201    pub meta: Option<&'a PhpStormMeta>,
202    pub doc_uri: Option<&'a Url>,
203    pub file_imports: Option<&'a HashMap<String, String>>,
204    /// Optional O(1) class-document lookup backed by the workspace index.
205    /// When `Some`, `all_instance_members` and `all_static_members` use it
206    /// to find the defining doc directly instead of scanning `other_docs`
207    /// linearly (O(n files × inheritance depth) → O(depth)).
208    /// Pass `None` to fall back to the existing linear scan.
209    pub find_class_doc: Option<ClassDocLookup<'a>>,
210    /// Retained mir body analysis for the primary doc. Receiver-variable types
211    /// (`$obj->`, match subjects) are read from its `symbol_at`; `None` in unit
212    /// tests that don't supply it.
213    pub analysis: Option<&'a mir_analyzer::FileAnalysis>,
214    /// Optional cross-request cache for the whole-doc [`TypeMap`]. The backend
215    /// wires this to `DocumentStore::cached_type_map` so the receiver-type
216    /// paths (`->`, `::`, match arms) reuse one map per document revision
217    /// instead of walking the full AST on every completion request. `None`
218    /// (unit tests) builds the map fresh.
219    pub type_map: Option<&'a dyn Fn() -> Arc<TypeMap>>,
220}
221
222/// Whole-doc [`TypeMap`] through the ctx cache when wired, else a fresh build.
223fn whole_doc_type_map(
224    ctx: &CompletionCtx<'_>,
225    doc: &ParsedDoc,
226    meta: Option<&PhpStormMeta>,
227) -> Arc<TypeMap> {
228    match ctx.type_map {
229        Some(get) => get(),
230        None => Arc::new(TypeMap::from_doc_with_meta(doc, meta)),
231    }
232}
233
234/// Returns `true` when `cursor_byte` falls inside a PHP string literal or
235/// comment. Scans the source from the beginning with a simple state machine;
236/// handles single/double-quoted strings (with backslash escapes), `// …` and
237/// `# …` line comments, and `/* … */` block comments. Heredoc/nowdoc are not
238/// tracked — they are too rare in interactive editing contexts to warrant the
239/// complexity, and missing them produces a false-negative (completions shown
240/// inside a heredoc), not a false-positive (completions suppressed outside one).
241pub(crate) fn cursor_in_string_or_comment(source: &str, cursor_byte: usize) -> bool {
242    #[derive(PartialEq)]
243    enum S {
244        Normal,
245        Single,
246        Double,
247        Line,
248        Block,
249    }
250    let bytes = source.as_bytes();
251    let limit = bytes.len().min(cursor_byte);
252    let mut i = 0usize;
253    let mut state = S::Normal;
254    while i < limit {
255        match state {
256            S::Normal => match bytes[i] {
257                b'\'' => {
258                    state = S::Single;
259                    i += 1;
260                }
261                b'"' => {
262                    state = S::Double;
263                    i += 1;
264                }
265                b'/' if i + 1 < limit && bytes[i + 1] == b'/' => {
266                    state = S::Line;
267                    i += 2;
268                }
269                // `#[` is a PHP 8 attribute — not a comment.
270                b'#' if !(i + 1 < limit && bytes[i + 1] == b'[') => {
271                    state = S::Line;
272                    i += 1;
273                }
274                b'/' if i + 1 < limit && bytes[i + 1] == b'*' => {
275                    state = S::Block;
276                    i += 2;
277                }
278                _ => {
279                    i += 1;
280                }
281            },
282            S::Single => match bytes[i] {
283                b'\\' => {
284                    i += 2;
285                }
286                b'\'' => {
287                    state = S::Normal;
288                    i += 1;
289                }
290                _ => {
291                    i += 1;
292                }
293            },
294            S::Double => match bytes[i] {
295                b'\\' => {
296                    i += 2;
297                }
298                b'"' => {
299                    state = S::Normal;
300                    i += 1;
301                }
302                _ => {
303                    i += 1;
304                }
305            },
306            S::Line => {
307                if bytes[i] == b'\n' {
308                    state = S::Normal;
309                }
310                i += 1;
311            }
312            S::Block => {
313                if bytes[i] == b'*' && i + 1 < limit && bytes[i + 1] == b'/' {
314                    state = S::Normal;
315                    i += 2;
316                } else {
317                    i += 1;
318                }
319            }
320        }
321    }
322    state != S::Normal
323}
324
325/// Completions filtered by trigger character, with optional context
326/// so that `->` completions can be scoped to the variable's class.
327pub fn filtered_completions_at(
328    doc: &ParsedDoc,
329    other_docs: &[Arc<ParsedDoc>],
330    trigger_character: Option<&str>,
331    ctx: &CompletionCtx<'_>,
332) -> Vec<CompletionItem> {
333    let source = ctx.source;
334    let position = ctx.position;
335
336    let doc_uri = ctx.doc_uri;
337
338    // Suppress all completions when the cursor is inside a string literal or
339    // comment — except for include/require path strings, where file-path
340    // completions are legitimate inside the string argument.
341    if let (Some(src), Some(pos)) = (source, position) {
342        let cursor_byte = doc.view().byte_of_position(pos) as usize;
343        if cursor_in_string_or_comment(src, cursor_byte) && include_path_prefix(src, pos).is_none()
344        {
345            return vec![];
346        }
347    }
348    let meta = ctx.meta;
349    let empty_imports = HashMap::new();
350    let imports = ctx.file_imports.unwrap_or(&empty_imports);
351
352    match trigger_character {
353        Some("$") => {
354            let mut items = superglobal_completions();
355            items.extend(
356                symbol_completions(doc)
357                    .into_iter()
358                    .filter(|i| i.kind == Some(CompletionItemKind::VARIABLE)),
359            );
360            items
361        }
362        Some(">") => {
363            // Arrow: $obj->  or  $this->
364            if let (Some(src), Some(pos)) = (source, position) {
365                let type_map = whole_doc_type_map(ctx, doc, meta);
366                if let Some(class_names) =
367                    resolve_receiver_class(src, doc, pos, ctx.analysis, &type_map)
368                {
369                    // Feature 5: support union types (Foo|Bar)
370                    let mut items = Vec::new();
371                    let mut seen = std::collections::HashSet::new();
372                    for class_name in class_names.split('|') {
373                        let class_name = class_name.trim();
374                        for item in
375                            all_instance_members(class_name, doc, other_docs, ctx.find_class_doc)
376                        {
377                            if seen.insert(item.label.clone()) {
378                                items.push(item);
379                            }
380                        }
381                    }
382                    if !items.is_empty() {
383                        return items;
384                    }
385                }
386            }
387            // Fallback: all methods from current doc
388            symbol_completions(doc)
389                .into_iter()
390                .filter(|i| i.kind == Some(CompletionItemKind::METHOD))
391                .collect()
392        }
393        Some(":") => {
394            // Static access: ClassName:: / self:: / static:: / parent::
395            if let (Some(src), Some(pos)) = (source, position)
396                && let Some(class_name) = resolve_static_receiver(src, doc, other_docs, pos)
397            {
398                let items = all_static_members(&class_name, doc, other_docs, ctx.find_class_doc);
399                if !items.is_empty() {
400                    return items;
401                }
402            }
403            vec![]
404        }
405        Some("[") => {
406            // PHP attribute: #[ — suggest only #[\Attribute]-annotated classes.
407            if let (Some(src), Some(pos)) = (source, position) {
408                let line = src.lines().nth(pos.line as usize).unwrap_or("");
409                let col = utf16_offset_to_byte(line, pos.character as usize);
410                let before = &line[..col];
411                if before.trim_end_matches('[').trim_end().ends_with('#') {
412                    return attribute_completions(src, pos, doc, other_docs, imports);
413                }
414            }
415            vec![]
416        }
417        Some("(") => {
418            // Named argument: funcName(
419            if let (Some(src), Some(pos)) = (source, position) {
420                let params = resolve_call_params(src, doc, other_docs, pos);
421                if !params.is_empty() {
422                    return params
423                        .into_iter()
424                        .map(|p| CompletionItem {
425                            label: format!("{p}:"),
426                            kind: Some(CompletionItemKind::VARIABLE),
427                            ..Default::default()
428                        })
429                        .collect();
430                }
431                // Attribute constructor: #[ClassName(
432                if let Some(attr_class) = resolve_attribute_class(src, pos) {
433                    let mut attr_params = params_of_method(doc, &attr_class, "__construct");
434                    if attr_params.is_empty() {
435                        for other in other_docs {
436                            attr_params = params_of_method(other, &attr_class, "__construct");
437                            if !attr_params.is_empty() {
438                                break;
439                            }
440                        }
441                    }
442                    if !attr_params.is_empty() {
443                        return attr_params
444                            .into_iter()
445                            .map(|p| CompletionItem {
446                                label: format!("{p}:"),
447                                kind: Some(CompletionItemKind::VARIABLE),
448                                detail: Some(format!("#{attr_class} argument")),
449                                ..Default::default()
450                            })
451                            .collect();
452                    }
453                }
454            }
455            vec![]
456        }
457        _ => {
458            // Detect $obj->member context (invoked completion without trigger char).
459            // Returns only the receiver class's instance members so unrelated class
460            // methods don't pollute the list.
461            if let (Some(src), Some(pos)) = (source, position) {
462                let line = src.lines().nth(pos.line as usize).unwrap_or("");
463                let col = utf16_offset_to_byte(line, pos.character as usize);
464                let before = &line[..col];
465                // Strip any identifier chars the user is typing as the member prefix.
466                let pre_arrow = before.trim_end_matches(|c: char| c.is_alphanumeric() || c == '_');
467                let has_arrow = pre_arrow.ends_with("->") || pre_arrow.ends_with("?->");
468                if has_arrow {
469                    // Extract receiver var from text before the arrow.
470                    let arrow_stripped = pre_arrow
471                        .strip_suffix("->")
472                        .or_else(|| pre_arrow.strip_suffix("?->"))
473                        .unwrap_or(pre_arrow);
474                    let receiver: String = arrow_stripped
475                        .chars()
476                        .rev()
477                        .take_while(|&c| c.is_alphanumeric() || c == '_' || c == '$')
478                        .collect::<String>()
479                        .chars()
480                        .rev()
481                        .collect();
482                    let receiver = if receiver.starts_with('$') {
483                        receiver
484                    } else if !receiver.is_empty() {
485                        format!("${receiver}")
486                    } else {
487                        String::new()
488                    };
489                    // `arrow_stripped` ends with the receiver var; its last byte
490                    // lands inside the var's end-exclusive mir span.
491                    let var_offset = line_byte_offset(doc, pos.line, arrow_stripped.len());
492                    let type_map = whole_doc_type_map(ctx, doc, meta);
493                    let class_name = if receiver == "$this" {
494                        enclosing_class_at(src, doc, pos)
495                            .or_else(|| ctx.analysis.and_then(|a| receiver_class_at(a, var_offset)))
496                            .or_else(|| type_map.get("$this").map(str::to_owned))
497                    } else if !receiver.is_empty() {
498                        ctx.analysis
499                            .and_then(|a| receiver_class_at(a, var_offset))
500                            .or_else(|| type_map.get(&receiver).map(str::to_owned))
501                    } else {
502                        None
503                    };
504                    if let Some(cls) = class_name {
505                        let mut items = Vec::new();
506                        let mut seen = std::collections::HashSet::new();
507                        for class_name in cls.split('|') {
508                            for item in all_instance_members(
509                                class_name.trim(),
510                                doc,
511                                other_docs,
512                                ctx.find_class_doc,
513                            ) {
514                                if seen.insert(item.label.clone()) {
515                                    items.push(item);
516                                }
517                            }
518                        }
519                        if !items.is_empty() {
520                            // Apply fuzzy filtering based on the typed prefix
521                            let prefix = before.strip_prefix(pre_arrow).unwrap_or("").to_string();
522                            if !prefix.is_empty() {
523                                let fq = crate::util::FuzzyQuery::new(&prefix);
524                                items.retain(|i| {
525                                    // For properties (label starts with $), match against the
526                                    // name without the $. For methods/other items, match the
527                                    // label directly.
528                                    let match_against = if i.label.starts_with('$') {
529                                        i.label.strip_prefix('$').unwrap_or(&i.label)
530                                    } else {
531                                        &i.label
532                                    };
533                                    fq.camel_match(match_against)
534                                });
535                                for item in &mut items {
536                                    let match_against = if item.label.starts_with('$') {
537                                        item.label.strip_prefix('$').unwrap_or(&item.label)
538                                    } else {
539                                        &item.label
540                                    };
541                                    item.sort_text =
542                                        Some(crate::util::camel_sort_key(&prefix, match_against));
543                                    item.filter_text = Some(item.label.clone());
544                                }
545                            }
546                            return items;
547                        }
548                    }
549                }
550            }
551
552            // Attribute context: #[ or #[PartialName — invoked without trigger char.
553            if let (Some(src), Some(pos)) = (source, position) {
554                let line = src.lines().nth(pos.line as usize).unwrap_or("");
555                let col = utf16_offset_to_byte(line, pos.character as usize);
556                let before = &line[..col];
557                let pre_ident =
558                    before.trim_end_matches(|c: char| c.is_alphanumeric() || c == '_' || c == '\\');
559                if pre_ident.trim_end().ends_with("#[") || pre_ident.trim_end() == "#[" {
560                    let items = attribute_completions(src, pos, doc, other_docs, imports);
561                    if !items.is_empty() {
562                        return items;
563                    }
564                }
565            }
566
567            // Feature 4: detect `use ` context and suggest FQNs from other docs
568            if let (Some(src), Some(pos)) = (source, position)
569                && let Some(use_prefix) = use_completion_prefix(src, pos)
570            {
571                let mut use_items: Vec<CompletionItem> = Vec::new();
572                for other in other_docs {
573                    collect_fqns_with_prefix(
574                        &other.program().stmts,
575                        "",
576                        &use_prefix,
577                        &mut use_items,
578                    );
579                }
580                // Also check current doc
581                collect_fqns_with_prefix(&doc.program().stmts, "", &use_prefix, &mut use_items);
582                if !use_items.is_empty() {
583                    return use_items;
584                }
585            }
586
587            // Feature 9: include/require path completions
588            if let (Some(src), Some(pos), Some(uri)) = (source, position, doc_uri)
589                && let Some(prefix) = include_path_prefix(src, pos)
590            {
591                // When in include/require context, return path completions (even if empty)
592                // instead of falling back to keywords/symbols
593                let items = include_path_completions(uri, &prefix);
594                return items;
595            }
596
597            // Classes (label, kind, FQN) per other doc, collected lazily once
598            // per request: the sub-namespace branch below falls through to the
599            // default cross-file loop when nothing matches, which previously
600            // re-collected the same lists from every doc's AST a second time.
601            let other_classes_cell: std::cell::OnceCell<
602                Vec<Vec<(String, CompletionItemKind, String)>>,
603            > = std::cell::OnceCell::new();
604            let other_classes = || {
605                other_classes_cell.get_or_init(|| {
606                    other_docs
607                        .iter()
608                        .map(|other| {
609                            let mut classes = Vec::new();
610                            collect_classes_with_ns(&other.program().stmts, "", &mut classes);
611                            classes
612                        })
613                        .collect()
614                })
615            };
616
617            // Feature 3: Sub-namespace \ completions outside use statement
618            if let (Some(src), Some(pos)) = (source, position)
619                && let Some(prefix) = typed_prefix(Some(src), Some(pos))
620                && prefix.contains('\\')
621            {
622                // Check we're NOT in a use statement
623                let is_use = use_completion_prefix(src, pos).is_some();
624                if !is_use {
625                    let prefix_lc = prefix.trim_start_matches('\\').to_lowercase();
626                    let mut ns_items: Vec<CompletionItem> = Vec::new();
627                    for classes in other_classes() {
628                        for (label, kind, fqn) in classes {
629                            if fqn
630                                .get(..prefix_lc.len())
631                                .is_some_and(|s| s.eq_ignore_ascii_case(&prefix_lc))
632                            {
633                                ns_items.push(CompletionItem {
634                                    label: label.clone(),
635                                    kind: Some(*kind),
636                                    insert_text: Some(label.clone()),
637                                    detail: Some(fqn.clone()),
638                                    ..Default::default()
639                                });
640                            }
641                        }
642                    }
643                    let mut classes = Vec::new();
644                    collect_classes_with_ns(&doc.program().stmts, "", &mut classes);
645                    for (label, kind, fqn) in classes {
646                        if fqn
647                            .get(..prefix_lc.len())
648                            .is_some_and(|s| s.eq_ignore_ascii_case(&prefix_lc))
649                        {
650                            ns_items.push(CompletionItem {
651                                label: label.clone(),
652                                kind: Some(kind),
653                                insert_text: Some(label),
654                                detail: Some(fqn),
655                                ..Default::default()
656                            });
657                        }
658                    }
659                    if !ns_items.is_empty() {
660                        return ns_items;
661                    }
662                }
663            }
664
665            // Feature 7: match arm completions
666            if let (Some(src), Some(pos)) = (source, position)
667                && let Some(match_items) = match_arm_completions(
668                    src,
669                    doc,
670                    other_docs,
671                    pos,
672                    &|| whole_doc_type_map(ctx, doc, meta),
673                    ctx.analysis,
674                )
675                && !match_items.is_empty()
676            {
677                let mut all = match_items;
678                // extend with normal items below, but return early here
679                let mut normal_items = keyword_completions();
680                normal_items.extend(magic_constant_completions());
681                normal_items.extend(builtin_completions());
682                normal_items.extend(superglobal_completions());
683                normal_items.extend(symbol_completions(doc));
684                all.extend(normal_items);
685
686                // Deduplicate by label (first occurrence wins)
687                let mut seen = std::collections::HashSet::new();
688                all.retain(|i| seen.insert(i.label.clone()));
689
690                return all;
691            }
692
693            // Feature 5: Magic method completions in class body
694            let mut magic_items: Vec<CompletionItem> = Vec::new();
695            if let (Some(src), Some(pos)) = (source, position)
696                && enclosing_class_at(src, doc, pos).is_some()
697            {
698                magic_items.extend(magic_method_completions());
699            }
700
701            let mut items = keyword_completions();
702            items.extend(magic_constant_completions());
703            items.extend(builtin_completions());
704            items.extend(superglobal_completions());
705            // Feature 2: scope variable completions to before cursor line
706            let sym_items = if let (Some(_src), Some(pos)) = (source, position) {
707                symbol_completions_before(doc, pos.line)
708            } else {
709                symbol_completions(doc)
710            };
711            items.extend(sym_items);
712            items.extend(magic_items);
713
714            let cur_ns = current_file_namespace(&doc.program().stmts);
715
716            for (other, classes) in other_docs.iter().zip(other_classes()) {
717                // Class-like symbols: add `use` insertion when needed.
718                for (label, kind, fqn) in classes {
719                    let additional_text_edits = if let Some(src) = source {
720                        let in_same_ns =
721                            !cur_ns.is_empty() && *fqn == format!("{}\\{}", cur_ns, label);
722                        let is_global = !fqn.contains('\\');
723                        let already = imports.contains_key(label);
724                        if !in_same_ns && !is_global && !already {
725                            let pos = use_insert_position(src);
726                            Some(vec![TextEdit {
727                                range: Range {
728                                    start: pos,
729                                    end: pos,
730                                },
731                                new_text: format!("use {};\n", fqn),
732                            }])
733                        } else {
734                            None
735                        }
736                    } else {
737                        None
738                    };
739                    items.push(CompletionItem {
740                        label: label.clone(),
741                        kind: Some(*kind),
742                        detail: if fqn.contains('\\') {
743                            Some(fqn.clone())
744                        } else {
745                            None
746                        },
747                        additional_text_edits,
748                        ..Default::default()
749                    });
750                }
751                // Non-class symbols (functions, methods, constants) need no use statement.
752                let cross: Vec<CompletionItem> = symbol_completions(other)
753                    .into_iter()
754                    .filter(|i| {
755                        !matches!(
756                            i.kind,
757                            Some(CompletionItemKind::CLASS)
758                                | Some(CompletionItemKind::INTERFACE)
759                                | Some(CompletionItemKind::ENUM)
760                        ) && i.kind != Some(CompletionItemKind::VARIABLE)
761                    })
762                    .collect();
763                items.extend(cross);
764            }
765            let mut seen = std::collections::HashSet::new();
766            items.retain(|i| seen.insert(i.label.clone()));
767
768            // Extract the typed prefix for fuzzy camel/underscore filtering.
769            let prefix = typed_prefix(source, position).unwrap_or_default();
770            if prefix.contains('\\') {
771                // Namespace-qualified prefix: filter by FQN prefix match.
772                let ns_prefix = prefix.trim_start_matches('\\').to_lowercase();
773                items.retain(|i| {
774                    let fqn = i.detail.as_deref().unwrap_or(&i.label);
775                    fqn.get(..ns_prefix.len())
776                        .is_some_and(|s| s.eq_ignore_ascii_case(&ns_prefix))
777                });
778            } else if !prefix.is_empty() {
779                let fq = crate::util::FuzzyQuery::new(&prefix);
780                items.retain(|i| fq.camel_match(&i.label));
781                for item in &mut items {
782                    item.sort_text = Some(camel_sort_key(&prefix, &item.label));
783                    item.filter_text = Some(item.label.clone());
784                }
785            }
786            items
787        }
788    }
789}
790
791/// Build attribute-class completion items for a `#[` context.
792///
793/// Only classes annotated with `#[\Attribute]` are included. If the
794/// look-ahead finds a specific declaration (class / function / property),
795/// items are further filtered by the matching `TARGET_*` bitmask.
796fn attribute_completions(
797    source: &str,
798    position: Position,
799    doc: &ParsedDoc,
800    other_docs: &[Arc<ParsedDoc>],
801    imports: &HashMap<String, String>,
802) -> Vec<CompletionItem> {
803    let context_target = infer_attribute_target(source, position);
804    let cur_ns = current_file_namespace(&doc.program().stmts);
805    let mut items: Vec<CompletionItem> = Vec::new();
806    let mut seen = std::collections::HashSet::new();
807
808    // Current doc — no auto-import needed.
809    let mut cur_entries = Vec::new();
810    collect_attribute_classes(&doc.program().stmts, "", &mut cur_entries);
811    for entry in cur_entries {
812        if entry.target & context_target == 0 {
813            continue;
814        }
815        if seen.insert(entry.label.clone()) {
816            items.push(CompletionItem {
817                label: entry.label,
818                kind: Some(CompletionItemKind::CLASS),
819                ..Default::default()
820            });
821        }
822    }
823
824    // Other docs — add `use` statement when crossing namespaces.
825    for other in other_docs {
826        let mut entries = Vec::new();
827        collect_attribute_classes(&other.program().stmts, "", &mut entries);
828        for entry in entries {
829            if entry.target & context_target == 0 {
830                continue;
831            }
832            if !seen.insert(entry.label.clone()) {
833                continue;
834            }
835            let in_same_ns =
836                !cur_ns.is_empty() && entry.fqn == format!("{}\\{}", cur_ns, entry.label);
837            let is_global = !entry.fqn.contains('\\');
838            let already = imports.contains_key(&entry.label);
839            let additional_text_edits = if !in_same_ns && !is_global && !already {
840                let insert_pos = use_insert_position(source);
841                Some(vec![TextEdit {
842                    range: Range {
843                        start: insert_pos,
844                        end: insert_pos,
845                    },
846                    new_text: format!("use {};\n", entry.fqn),
847                }])
848            } else {
849                None
850            };
851            items.push(CompletionItem {
852                label: entry.label,
853                kind: Some(CompletionItemKind::CLASS),
854                detail: if entry.fqn.contains('\\') {
855                    Some(entry.fqn)
856                } else {
857                    None
858                },
859                additional_text_edits,
860                ..Default::default()
861            });
862        }
863    }
864    items
865}
866
867#[allow(clippy::too_many_arguments)]
868fn match_arm_completions(
869    source: &str,
870    doc: &ParsedDoc,
871    other_docs: &[Arc<ParsedDoc>],
872    position: Position,
873    get_type_map: &dyn Fn() -> Arc<TypeMap>,
874    analysis: Option<&mir_analyzer::FileAnalysis>,
875) -> Option<Vec<CompletionItem>> {
876    let start_line = position.line as usize;
877    let end_line = start_line.saturating_sub(5);
878    let all_lines: Vec<&str> = source.lines().collect();
879    let type_map_cell: std::cell::OnceCell<Arc<TypeMap>> = std::cell::OnceCell::new();
880    for line_idx in (end_line..=start_line).rev() {
881        let line = all_lines.get(line_idx).copied()?;
882        if let Some(cap) = extract_match_subject(line) {
883            let class_name = if cap == "this" {
884                enclosing_class_at(source, doc, position)?
885            } else {
886                // Resolve the match subject `$cap` via mir at its position on
887                // this line (`$cap` always appears on the matched line); fall
888                // back to TypeMap for types mir resolves to `mixed`.
889                let subject_byte = line.find(&format!("${cap}"))?;
890                let var_offset = line_byte_offset(doc, line_idx as u32, subject_byte + 1);
891                analysis
892                    .and_then(|a| receiver_class_at(a, var_offset))
893                    .or_else(|| {
894                        let type_map = type_map_cell.get_or_init(get_type_map);
895                        type_map.get(&format!("${cap}")).map(str::to_owned)
896                    })?
897            };
898            let all_docs: Vec<&ParsedDoc> = std::iter::once(doc)
899                .chain(other_docs.iter().map(|d| d.as_ref()))
900                .collect();
901            for d in &all_docs {
902                let members = members_of_class(d, &class_name);
903                if !members.constants.is_empty() {
904                    return Some(
905                        members
906                            .constants
907                            .iter()
908                            .map(|c| CompletionItem {
909                                label: format!("{class_name}::{c}"),
910                                kind: Some(CompletionItemKind::CONSTANT),
911                                ..Default::default()
912                            })
913                            .collect(),
914                    );
915                }
916            }
917        }
918    }
919    None
920}
921
922/// Returns the path prefix typed inside a string on an include/require line, or None.
923/// Only triggers for relative paths (starting with `./`, `../`, or empty after the quote)
924/// so that absolute-path strings are left alone.
925fn include_path_prefix(source: &str, position: Position) -> Option<String> {
926    let line = source.lines().nth(position.line as usize)?;
927    // Check if line contains include/require keyword (may be after <?php)
928    if !line.contains("include") && !line.contains("require") {
929        return None;
930    }
931    // Find the string being typed
932    let col = utf16_offset_to_byte(line, position.character as usize);
933    let before = &line[..col];
934    let quote_pos = before.rfind(['\'', '"'])?;
935    let typed = &before[quote_pos + 1..];
936    // Only offer completions for relative paths (./  ../  or empty start)
937    // and not for absolute paths (starting with /) or PHP stream wrappers.
938    if typed.starts_with('/') || typed.contains("://") {
939        return None;
940    }
941    Some(typed.to_string())
942}
943
944/// Build completion items for include/require path strings.
945///
946/// `prefix` is the partial path typed so far (e.g. `"../lib/"` or `"./"`).
947/// The returned `insert_text` for each item is the full replacement text
948/// from the opening quote to the end of the completed entry, so that the
949/// LSP client can replace the whole typed path (not just the last segment).
950fn include_path_completions(doc_uri: &Url, prefix: &str) -> Vec<CompletionItem> {
951    use std::path::Path;
952
953    let doc_path = match doc_uri.to_file_path() {
954        Ok(p) => p,
955        Err(_) => return vec![],
956    };
957    let doc_dir = match doc_path.parent() {
958        Some(d) => d.to_path_buf(),
959        None => return vec![],
960    };
961
962    // Split prefix into a directory part (already traversed) and the partial filename.
963    let (dir_prefix, typed_file) = if prefix.ends_with('/') || prefix.ends_with('\\') {
964        (prefix.to_string(), String::new())
965    } else {
966        let p = Path::new(prefix);
967        let parent = p
968            .parent()
969            .map(|p| {
970                let s = p.to_string_lossy();
971                if s.is_empty() {
972                    String::new()
973                } else {
974                    format!("{}/", s)
975                }
976            })
977            .unwrap_or_default();
978        let file = p
979            .file_name()
980            .map(|f| f.to_string_lossy().into_owned())
981            .unwrap_or_default();
982        (parent, file)
983    };
984
985    let dir_to_list = doc_dir.join(&dir_prefix);
986
987    let entries = match std::fs::read_dir(&dir_to_list) {
988        Ok(e) => e,
989        Err(_) => return vec![],
990    };
991
992    let mut items = Vec::new();
993    for entry in entries.flatten() {
994        let name = entry.file_name().to_string_lossy().into_owned();
995        // Skip hidden files/dirs unless the prefix already starts with a dot.
996        if name.starts_with('.') && !typed_file.starts_with('.') {
997            continue;
998        }
999        let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
1000        let is_php = name.ends_with(".php") || name.ends_with(".inc") || name.ends_with(".phtml");
1001        if !is_dir && !is_php {
1002            continue;
1003        }
1004        let entry_name = if is_dir {
1005            format!("{}/", name)
1006        } else {
1007            name.clone()
1008        };
1009        // insert_text is the full path from the opening quote so the whole
1010        // typed prefix (e.g. "../lib/") is preserved in the replacement.
1011        let insert_text = format!("{}{}", dir_prefix, entry_name);
1012        items.push(CompletionItem {
1013            label: name,
1014            kind: Some(if is_dir {
1015                CompletionItemKind::FOLDER
1016            } else {
1017                CompletionItemKind::FILE
1018            }),
1019            insert_text: Some(insert_text),
1020            ..Default::default()
1021        });
1022    }
1023    items.sort_by(|a, b| {
1024        // Directories first, then files
1025        let a_dir = a.kind == Some(CompletionItemKind::FOLDER);
1026        let b_dir = b.kind == Some(CompletionItemKind::FOLDER);
1027        b_dir.cmp(&a_dir).then(a.label.cmp(&b.label))
1028    });
1029    items
1030}
1031
1032fn extract_match_subject(line: &str) -> Option<String> {
1033    let trimmed = line.trim();
1034    let after = trimmed.strip_prefix("match")?.trim_start();
1035    let after = after.strip_prefix('(')?;
1036    let inner: String = after.chars().take_while(|&c| c != ')').collect();
1037    let var = inner.trim().trim_start_matches('$');
1038    if var.is_empty() {
1039        None
1040    } else {
1041        Some(var.to_string())
1042    }
1043}
1044
1045#[cfg(test)]
1046mod tests {
1047    use super::*;
1048
1049    fn doc(source: &str) -> ParsedDoc {
1050        ParsedDoc::parse(source.to_string())
1051    }
1052
1053    fn labels(items: &[CompletionItem]) -> Vec<&str> {
1054        items.iter().map(|i| i.label.as_str()).collect()
1055    }
1056
1057    #[test]
1058    fn keywords_list_is_non_empty() {
1059        let kws = keyword_completions();
1060        assert!(
1061            kws.len() >= 20,
1062            "expected at least 20 keywords, got {}",
1063            kws.len()
1064        );
1065    }
1066
1067    #[test]
1068    fn keywords_contain_common_php_keywords() {
1069        let kws = keyword_completions();
1070        let ls = labels(&kws);
1071        for expected in &[
1072            "function",
1073            "class",
1074            "return",
1075            "foreach",
1076            "match",
1077            "namespace",
1078        ] {
1079            assert!(ls.contains(expected), "missing keyword: {expected}");
1080        }
1081    }
1082
1083    #[test]
1084    fn all_keyword_items_have_keyword_kind() {
1085        for item in keyword_completions() {
1086            assert_eq!(item.kind, Some(CompletionItemKind::KEYWORD));
1087        }
1088    }
1089
1090    #[test]
1091    fn magic_constants_all_present() {
1092        let items = magic_constant_completions();
1093        let ls: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
1094        for name in &[
1095            "__FILE__",
1096            "__DIR__",
1097            "__LINE__",
1098            "__CLASS__",
1099            "__FUNCTION__",
1100            "__METHOD__",
1101            "__NAMESPACE__",
1102            "__TRAIT__",
1103        ] {
1104            assert!(ls.contains(name), "missing magic constant: {name}");
1105        }
1106    }
1107
1108    #[test]
1109    fn magic_constants_have_constant_kind() {
1110        for item in magic_constant_completions() {
1111            assert_eq!(
1112                item.kind,
1113                Some(CompletionItemKind::CONSTANT),
1114                "{} should have CONSTANT kind",
1115                item.label
1116            );
1117        }
1118    }
1119
1120    #[test]
1121    fn resolve_attribute_class_extracts_name() {
1122        let src = "<?php\n#[Route(\n";
1123        // Position right after the '(' on line 1
1124        let pos = Position {
1125            line: 1,
1126            character: 8,
1127        };
1128        let result = resolve_attribute_class(src, pos);
1129        assert_eq!(result.as_deref(), Some("Route"));
1130    }
1131
1132    #[test]
1133    fn resolve_attribute_class_fqn_extracts_short_name() {
1134        let src = "<?php\n#[\\Symfony\\Component\\Routing\\Route(\n";
1135        let pos = Position {
1136            line: 1,
1137            character: 38,
1138        };
1139        let result = resolve_attribute_class(src, pos);
1140        assert_eq!(result.as_deref(), Some("Route"));
1141    }
1142
1143    #[test]
1144    fn resolve_attribute_class_returns_none_for_regular_call() {
1145        let src = "<?php\nsomeFunction(\n";
1146        let pos = Position {
1147            line: 1,
1148            character: 14,
1149        };
1150        let result = resolve_attribute_class(src, pos);
1151        assert!(result.is_none(), "should not match regular function call");
1152    }
1153
1154    #[test]
1155    fn extracts_top_level_function_name() {
1156        let d = doc("<?php\nfunction greet() {}");
1157        let items = symbol_completions(&d);
1158        assert!(labels(&items).contains(&"greet"));
1159        let greet = items.iter().find(|i| i.label == "greet").unwrap();
1160        assert_eq!(greet.kind, Some(CompletionItemKind::FUNCTION));
1161    }
1162
1163    #[test]
1164    fn extracts_top_level_class_name() {
1165        let d = doc("<?php\nclass MyService {}");
1166        let items = symbol_completions(&d);
1167        assert!(labels(&items).contains(&"MyService"));
1168        let cls = items.iter().find(|i| i.label == "MyService").unwrap();
1169        assert_eq!(cls.kind, Some(CompletionItemKind::CLASS));
1170    }
1171
1172    #[test]
1173    fn extracts_class_method_names() {
1174        let d = doc("<?php\nclass Calc { public function add() {} public function sub() {} }");
1175        let items = symbol_completions(&d);
1176        let ls = labels(&items);
1177        assert!(ls.contains(&"add"), "missing 'add'");
1178        assert!(ls.contains(&"sub"), "missing 'sub'");
1179        for item in items
1180            .iter()
1181            .filter(|i| i.label == "add" || i.label == "sub")
1182        {
1183            assert_eq!(item.kind, Some(CompletionItemKind::METHOD));
1184        }
1185    }
1186
1187    #[test]
1188    fn extracts_function_parameters_as_variables() {
1189        let d = doc("<?php\nfunction process($input, $count) {}");
1190        let items = symbol_completions(&d);
1191        let ls = labels(&items);
1192        assert!(ls.contains(&"$input"), "missing '$input'");
1193        assert!(ls.contains(&"$count"), "missing '$count'");
1194    }
1195
1196    #[test]
1197    fn extracts_symbols_inside_namespace() {
1198        let d = doc("<?php\nnamespace App {\nfunction render() {}\nclass View {}\n}");
1199        let items = symbol_completions(&d);
1200        let ls = labels(&items);
1201        assert!(ls.contains(&"render"), "missing 'render'");
1202        assert!(ls.contains(&"View"), "missing 'View'");
1203    }
1204
1205    #[test]
1206    fn extracts_interface_name() {
1207        let d = doc("<?php\ninterface Serializable {}");
1208        let items = symbol_completions(&d);
1209        let item = items.iter().find(|i| i.label == "Serializable");
1210        assert!(item.is_some(), "missing 'Serializable'");
1211        assert_eq!(item.unwrap().kind, Some(CompletionItemKind::INTERFACE));
1212    }
1213
1214    #[test]
1215    fn variable_assignment_produces_variable_item() {
1216        let d = doc("<?php\n$name = 'Alice';");
1217        let items = symbol_completions(&d);
1218        assert!(labels(&items).contains(&"$name"), "missing '$name'");
1219    }
1220
1221    #[test]
1222    fn class_property_appears_in_completions() {
1223        let d = doc("<?php\nclass User { public string $name; private int $age; }");
1224        let items = symbol_completions(&d);
1225        let ls = labels(&items);
1226        assert!(ls.contains(&"$name"), "missing '$name'");
1227        assert!(ls.contains(&"$age"), "missing '$age'");
1228        for item in items
1229            .iter()
1230            .filter(|i| i.label == "$name" || i.label == "$age")
1231        {
1232            assert_eq!(item.kind, Some(CompletionItemKind::PROPERTY));
1233        }
1234    }
1235
1236    #[test]
1237    fn class_constant_appears_in_completions() {
1238        let d = doc("<?php\nclass Status { const ACTIVE = 1; const INACTIVE = 0; }");
1239        let items = symbol_completions(&d);
1240        let ls = labels(&items);
1241        assert!(ls.contains(&"ACTIVE"), "missing 'ACTIVE'");
1242        assert!(ls.contains(&"INACTIVE"), "missing 'INACTIVE'");
1243    }
1244
1245    #[test]
1246    fn dollar_trigger_returns_only_variables() {
1247        let d = doc("<?php\nfunction greet($name) {}\nclass Foo {}\n$bar = 1;");
1248        let items = filtered_completions_at(&d, &[], Some("$"), &CompletionCtx::default());
1249        assert!(!items.is_empty(), "should have variable items");
1250        for item in &items {
1251            assert_eq!(item.kind, Some(CompletionItemKind::VARIABLE));
1252        }
1253        let ls = labels(&items);
1254        assert!(!ls.contains(&"greet"), "should not contain function");
1255        assert!(!ls.contains(&"Foo"), "should not contain class");
1256    }
1257
1258    #[test]
1259    fn arrow_trigger_returns_only_methods() {
1260        let d = doc("<?php\nclass Calc { public function add() {} public function sub() {} }");
1261        let items = filtered_completions_at(&d, &[], Some(">"), &CompletionCtx::default());
1262        assert!(!items.is_empty(), "should have method items");
1263        for item in &items {
1264            assert_eq!(item.kind, Some(CompletionItemKind::METHOD));
1265        }
1266    }
1267
1268    #[test]
1269    fn none_trigger_returns_keywords_functions_classes() {
1270        let d = doc("<?php\nfunction greet() {}\nclass MyApp {}");
1271        let items = filtered_completions_at(&d, &[], None, &CompletionCtx::default());
1272        let ls = labels(&items);
1273        assert!(
1274            ls.contains(&"function"),
1275            "should contain keyword 'function'"
1276        );
1277        assert!(ls.contains(&"greet"), "should contain function 'greet'");
1278        assert!(ls.contains(&"MyApp"), "should contain class 'MyApp'");
1279    }
1280
1281    #[test]
1282    fn builtins_appear_in_default_completions() {
1283        let d = doc("<?php");
1284        let items = filtered_completions_at(&d, &[], None, &CompletionCtx::default());
1285        let ls = labels(&items);
1286        assert!(ls.contains(&"strlen"), "missing strlen");
1287        assert!(ls.contains(&"array_map"), "missing array_map");
1288        assert!(ls.contains(&"json_encode"), "missing json_encode");
1289    }
1290
1291    #[test]
1292    fn colon_trigger_returns_static_members() {
1293        let src = "<?php\nclass Cfg { public static function load(): void {} public static int $debug = 0; const VERSION = '1'; }\nCfg::";
1294        let d = doc(src);
1295        let pos = Position {
1296            line: 2,
1297            character: 5,
1298        };
1299        let items = filtered_completions_at(
1300            &d,
1301            &[],
1302            Some(":"),
1303            &CompletionCtx {
1304                source: Some(src),
1305                position: Some(pos),
1306                ..Default::default()
1307            },
1308        );
1309        let ls = labels(&items);
1310        assert!(ls.contains(&"load"), "missing static method");
1311        assert!(ls.contains(&"VERSION"), "missing constant");
1312    }
1313
1314    #[test]
1315    fn inherited_methods_appear_in_arrow_completion() {
1316        let src = "<?php\nclass Base { public function baseMethod() {} }\nclass Child extends Base { public function childMethod() {} }\n$c = new Child();\n$c->";
1317        let d = doc(src);
1318        let pos = Position {
1319            line: 4,
1320            character: 4,
1321        };
1322        let items = filtered_completions_at(
1323            &d,
1324            &[],
1325            Some(">"),
1326            &CompletionCtx {
1327                source: Some(src),
1328                position: Some(pos),
1329                ..Default::default()
1330            },
1331        );
1332        let ls = labels(&items);
1333        assert!(ls.contains(&"baseMethod"), "missing inherited baseMethod");
1334        assert!(ls.contains(&"childMethod"), "missing childMethod");
1335    }
1336
1337    #[test]
1338    fn param_named_arg_completion() {
1339        let src = "<?php\nfunction connect(string $host, int $port): void {}\nconnect(";
1340        let d = doc(src);
1341        let pos = Position {
1342            line: 2,
1343            character: 8,
1344        };
1345        let items = filtered_completions_at(
1346            &d,
1347            &[],
1348            Some("("),
1349            &CompletionCtx {
1350                source: Some(src),
1351                position: Some(pos),
1352                ..Default::default()
1353            },
1354        );
1355        let ls = labels(&items);
1356        assert!(ls.contains(&"host:"), "missing host:");
1357        assert!(ls.contains(&"port:"), "missing port:");
1358    }
1359
1360    #[test]
1361    fn cross_file_symbols_appear_in_default_completions() {
1362        let d = doc("<?php\nfunction localFn() {}");
1363        let other = Arc::new(ParsedDoc::parse(
1364            "<?php\nclass RemoteService {}\nfunction remoteHelper() {}".to_string(),
1365        ));
1366        let items = filtered_completions_at(&d, &[other], None, &CompletionCtx::default());
1367        let ls = labels(&items);
1368        assert!(ls.contains(&"localFn"), "missing local function");
1369        assert!(ls.contains(&"RemoteService"), "missing cross-file class");
1370        assert!(ls.contains(&"remoteHelper"), "missing cross-file function");
1371    }
1372
1373    #[test]
1374    fn cross_file_variables_not_included_in_default_completions() {
1375        let d = doc("<?php\n$localVar = 1;");
1376        let other = Arc::new(ParsedDoc::parse("<?php\n$remoteVar = 2;".to_string()));
1377        let items = filtered_completions_at(&d, &[other], None, &CompletionCtx::default());
1378        let ls = labels(&items);
1379        assert!(
1380            !ls.contains(&"$remoteVar"),
1381            "cross-file variable should not appear"
1382        );
1383    }
1384
1385    #[test]
1386    fn cross_file_class_gets_use_insertion() {
1387        let current_src = "<?php\nnamespace App;\n\n$x = new ";
1388        let d = doc(current_src);
1389        let other = Arc::new(ParsedDoc::parse(
1390            "<?php\nnamespace Lib;\nclass Mailer {}".to_string(),
1391        ));
1392        let pos = Position {
1393            line: 3,
1394            character: 9,
1395        };
1396        let items = filtered_completions_at(
1397            &d,
1398            &[other],
1399            None,
1400            &CompletionCtx {
1401                source: Some(current_src),
1402                position: Some(pos),
1403                ..Default::default()
1404            },
1405        );
1406        let mailer = items.iter().find(|i| i.label == "Mailer");
1407        assert!(mailer.is_some(), "Mailer should appear in completions");
1408        let edits = mailer.unwrap().additional_text_edits.as_ref();
1409        assert!(edits.is_some(), "Mailer should have additionalTextEdits");
1410        let edit_text = &edits.unwrap()[0].new_text;
1411        assert!(
1412            edit_text.contains("use Lib\\Mailer;"),
1413            "edit should insert 'use Lib\\Mailer;', got: {edit_text}"
1414        );
1415    }
1416
1417    #[test]
1418    fn same_namespace_class_gets_no_use_insertion() {
1419        let current_src = "<?php\nnamespace Lib;\n$x = new ";
1420        let d = doc(current_src);
1421        let other = Arc::new(ParsedDoc::parse(
1422            "<?php\nnamespace Lib;\nclass Mailer {}".to_string(),
1423        ));
1424        let pos = Position {
1425            line: 2,
1426            character: 9,
1427        };
1428        let items = filtered_completions_at(
1429            &d,
1430            &[other],
1431            None,
1432            &CompletionCtx {
1433                source: Some(current_src),
1434                position: Some(pos),
1435                ..Default::default()
1436            },
1437        );
1438        let mailer = items.iter().find(|i| i.label == "Mailer");
1439        assert!(mailer.is_some(), "Mailer should appear in completions");
1440        assert!(
1441            mailer.unwrap().additional_text_edits.is_none(),
1442            "same-namespace class should not get a use edit"
1443        );
1444    }
1445
1446    #[test]
1447    fn function_with_params_gets_snippet() {
1448        let d = doc("<?php\nfunction process($input) {}");
1449        let items = symbol_completions(&d);
1450        let item = items.iter().find(|i| i.label == "process").unwrap();
1451        assert_eq!(item.insert_text_format, Some(InsertTextFormat::SNIPPET));
1452        assert_eq!(item.insert_text.as_deref(), Some("process($1)"));
1453    }
1454
1455    #[test]
1456    fn function_without_params_gets_plain_call() {
1457        let d = doc("<?php\nfunction doThing() {}");
1458        let items = symbol_completions(&d);
1459        let item = items.iter().find(|i| i.label == "doThing").unwrap();
1460        // No snippet format needed for zero-arg functions.
1461        assert_eq!(item.insert_text.as_deref(), Some("doThing()"));
1462        assert_ne!(item.insert_text_format, Some(InsertTextFormat::SNIPPET));
1463    }
1464
1465    #[test]
1466    fn builtin_functions_get_snippet() {
1467        let items = builtin_completions();
1468        let strlen = items.iter().find(|i| i.label == "strlen").unwrap();
1469        assert_eq!(strlen.insert_text_format, Some(InsertTextFormat::SNIPPET));
1470        assert_eq!(strlen.insert_text.as_deref(), Some("strlen($1)"));
1471    }
1472
1473    #[test]
1474    fn enum_arrow_completion_includes_name_property() {
1475        let src = "<?php\nenum Suit { case Hearts; }\n$s = new Suit();\n$s->";
1476        let d = doc(src);
1477        let pos = Position {
1478            line: 3,
1479            character: 4,
1480        };
1481        let items = filtered_completions_at(
1482            &d,
1483            &[],
1484            Some(">"),
1485            &CompletionCtx {
1486                source: Some(src),
1487                position: Some(pos),
1488                ..Default::default()
1489            },
1490        );
1491        assert!(
1492            items.iter().any(|i| i.label == "name"),
1493            "enum should have ->name"
1494        );
1495    }
1496
1497    #[test]
1498    fn backed_enum_arrow_completion_includes_value_property() {
1499        let src =
1500            "<?php\nenum Status: string { case Active = 'active'; }\n$s = new Status();\n$s->";
1501        let d = doc(src);
1502        let pos = Position {
1503            line: 3,
1504            character: 4,
1505        };
1506        let items = filtered_completions_at(
1507            &d,
1508            &[],
1509            Some(">"),
1510            &CompletionCtx {
1511                source: Some(src),
1512                position: Some(pos),
1513                ..Default::default()
1514            },
1515        );
1516        assert!(
1517            items.iter().any(|i| i.label == "name"),
1518            "backed enum should have ->name"
1519        );
1520        assert!(
1521            items.iter().any(|i| i.label == "value"),
1522            "backed enum should have ->value"
1523        );
1524    }
1525
1526    #[test]
1527    fn pure_enum_arrow_completion_has_no_value_property() {
1528        let src = "<?php\nenum Suit { case Hearts; }\n$s = new Suit();\n$s->";
1529        let d = doc(src);
1530        let pos = Position {
1531            line: 3,
1532            character: 4,
1533        };
1534        let items = filtered_completions_at(
1535            &d,
1536            &[],
1537            Some(">"),
1538            &CompletionCtx {
1539                source: Some(src),
1540                position: Some(pos),
1541                ..Default::default()
1542            },
1543        );
1544        assert!(
1545            !items.iter().any(|i| i.label == "value"),
1546            "pure enum should not have ->value"
1547        );
1548    }
1549
1550    #[test]
1551    fn superglobals_appear_on_dollar_trigger() {
1552        let d = doc("<?php\n");
1553        let items = filtered_completions_at(&d, &[], Some("$"), &CompletionCtx::default());
1554        let ls = labels(&items);
1555        assert!(ls.contains(&"$_SERVER"), "missing $_SERVER");
1556        assert!(ls.contains(&"$_GET"), "missing $_GET");
1557        assert!(ls.contains(&"$_POST"), "missing $_POST");
1558        assert!(ls.contains(&"$_SESSION"), "missing $_SESSION");
1559        assert!(ls.contains(&"$GLOBALS"), "missing $GLOBALS");
1560    }
1561
1562    #[test]
1563    fn superglobals_appear_in_default_completions() {
1564        let d = doc("<?php\n");
1565        let items = filtered_completions_at(&d, &[], None, &CompletionCtx::default());
1566        let ls = labels(&items);
1567        assert!(
1568            ls.contains(&"$_SERVER"),
1569            "missing $_SERVER in default completions"
1570        );
1571    }
1572
1573    #[test]
1574    fn instanceof_narrowing_provides_arrow_completions() {
1575        // $x instanceof Foo should narrow $x to Foo inside the if body
1576        let src =
1577            "<?php\nclass Foo { public function doFoo() {} }\nif ($x instanceof Foo) {\n    $x->";
1578        let d = doc(src);
1579        let pos = Position {
1580            line: 3,
1581            character: 8,
1582        };
1583        let items = filtered_completions_at(
1584            &d,
1585            &[],
1586            Some(">"),
1587            &CompletionCtx {
1588                source: Some(src),
1589                position: Some(pos),
1590                ..Default::default()
1591            },
1592        );
1593        let ls = labels(&items);
1594        assert!(
1595            ls.contains(&"doFoo"),
1596            "instanceof narrowing should make Foo methods available"
1597        );
1598    }
1599
1600    #[test]
1601    fn constructor_chain_arrow_completion() {
1602        let src = "<?php\nclass Builder { public function build() {} public function reset() {} }\n(new Builder())->";
1603        let d = doc(src);
1604        let pos = Position {
1605            line: 2,
1606            character: 16,
1607        };
1608        let items = filtered_completions_at(
1609            &d,
1610            &[],
1611            Some(">"),
1612            &CompletionCtx {
1613                source: Some(src),
1614                position: Some(pos),
1615                ..Default::default()
1616            },
1617        );
1618        let ls = labels(&items);
1619        assert!(
1620            ls.contains(&"build"),
1621            "constructor chain should complete Builder methods"
1622        );
1623        assert!(
1624            ls.contains(&"reset"),
1625            "constructor chain should complete Builder methods"
1626        );
1627    }
1628
1629    // Feature 4: use statement FQN completions
1630    #[test]
1631    fn use_statement_suggests_fqns() {
1632        let d = doc("<?php\nuse ");
1633        let other = Arc::new(ParsedDoc::parse(
1634            "<?php\nnamespace App\\Services;\nclass Mailer {}".to_string(),
1635        ));
1636        let pos = Position {
1637            line: 1,
1638            character: 4,
1639        };
1640        let items = filtered_completions_at(
1641            &d,
1642            &[other],
1643            None,
1644            &CompletionCtx {
1645                source: Some("<?php\nuse "),
1646                position: Some(pos),
1647                ..Default::default()
1648            },
1649        );
1650        assert!(
1651            items.iter().any(|i| i.label.contains("Mailer")),
1652            "use completion should suggest Mailer"
1653        );
1654    }
1655
1656    // Feature 5: union type param completions
1657    #[test]
1658    fn union_type_param_completes_both_classes() {
1659        let src = "<?php\nclass Foo { public function fooMethod() {} }\nclass Bar { public function barMethod() {} }\n/**\n * @param Foo|Bar $x\n */\nfunction handle($x) {\n    $x->";
1660        let d = doc(src);
1661        let pos = Position {
1662            line: 7,
1663            character: 8,
1664        };
1665        let items = filtered_completions_at(
1666            &d,
1667            &[],
1668            Some(">"),
1669            &CompletionCtx {
1670                source: Some(src),
1671                position: Some(pos),
1672                ..Default::default()
1673            },
1674        );
1675        let ls = labels(&items);
1676        assert!(
1677            ls.contains(&"fooMethod"),
1678            "should complete Foo methods from union"
1679        );
1680        assert!(
1681            ls.contains(&"barMethod"),
1682            "should complete Bar methods from union"
1683        );
1684    }
1685
1686    // Feature 6: attribute bracket completions — only #[\Attribute]-annotated classes
1687    #[test]
1688    fn attribute_bracket_suggests_classes() {
1689        let src = "<?php\n#[\\Attribute]\nclass Route {}\n#[\\Attribute]\nclass Middleware {}\nclass Plain {}\n#[";
1690        let d = doc(src);
1691        let pos = Position {
1692            line: 6,
1693            character: 2,
1694        };
1695        let items = filtered_completions_at(
1696            &d,
1697            &[],
1698            Some("["),
1699            &CompletionCtx {
1700                source: Some(src),
1701                position: Some(pos),
1702                ..Default::default()
1703            },
1704        );
1705        let ls = labels(&items);
1706        assert!(ls.contains(&"Route"), "should suggest Route as attribute");
1707        assert!(
1708            ls.contains(&"Middleware"),
1709            "should suggest Middleware as attribute"
1710        );
1711        assert!(
1712            !ls.contains(&"Plain"),
1713            "plain class without #[Attribute] must not appear"
1714        );
1715    }
1716
1717    #[test]
1718    fn attribute_bracket_cross_ns_gets_use_insertion() {
1719        let current_src = "<?php\nnamespace App\\Controllers;\n\n#[";
1720        let d = doc(current_src);
1721        let other = Arc::new(ParsedDoc::parse(
1722            "<?php\nnamespace App\\Attributes;\n#[\\Attribute]\nclass Route {}".to_string(),
1723        ));
1724        let pos = Position {
1725            line: 3,
1726            character: 2,
1727        };
1728        let items = filtered_completions_at(
1729            &d,
1730            &[other],
1731            Some("["),
1732            &CompletionCtx {
1733                source: Some(current_src),
1734                position: Some(pos),
1735                ..Default::default()
1736            },
1737        );
1738        let route = items.iter().find(|i| i.label == "Route");
1739        assert!(
1740            route.is_some(),
1741            "Route should appear in attribute completions"
1742        );
1743        let edits = route.unwrap().additional_text_edits.as_ref();
1744        assert!(
1745            edits.is_some(),
1746            "Route attribute should have additionalTextEdits for auto-import"
1747        );
1748        let edit_text = &edits.unwrap()[0].new_text;
1749        assert!(
1750            edit_text.contains("use App\\Attributes\\Route;"),
1751            "edit should insert 'use App\\Attributes\\Route;', got: {edit_text}"
1752        );
1753    }
1754
1755    #[test]
1756    fn attribute_bracket_same_ns_no_use_insertion() {
1757        let current_src = "<?php\nnamespace App\\Attributes;\n\n#[";
1758        let d = doc(current_src);
1759        let other = Arc::new(ParsedDoc::parse(
1760            "<?php\nnamespace App\\Attributes;\n#[\\Attribute]\nclass Route {}".to_string(),
1761        ));
1762        let pos = Position {
1763            line: 3,
1764            character: 2,
1765        };
1766        let items = filtered_completions_at(
1767            &d,
1768            &[other],
1769            Some("["),
1770            &CompletionCtx {
1771                source: Some(current_src),
1772                position: Some(pos),
1773                ..Default::default()
1774            },
1775        );
1776        let route = items.iter().find(|i| i.label == "Route");
1777        assert!(
1778            route.is_some(),
1779            "Route should appear in attribute completions"
1780        );
1781        assert!(
1782            route.unwrap().additional_text_edits.is_none(),
1783            "same-namespace attribute class should not get a use edit"
1784        );
1785    }
1786
1787    // Feature 7: match arm completions
1788    #[test]
1789    fn match_arm_suggests_enum_cases() {
1790        let src = "<?php\nenum Status { case Active; case Inactive; case Pending; }\n$s = new Status();\nmatch ($s) {\n    ";
1791        let d = doc(src);
1792        let pos = Position {
1793            line: 4,
1794            character: 4,
1795        };
1796        let items = filtered_completions_at(
1797            &d,
1798            &[],
1799            None,
1800            &CompletionCtx {
1801                source: Some(src),
1802                position: Some(pos),
1803                ..Default::default()
1804            },
1805        );
1806        let ls = labels(&items);
1807        assert!(
1808            ls.iter().any(|l| l.contains("Active")),
1809            "match should suggest Status::Active"
1810        );
1811    }
1812
1813    // Feature 10: readonly property recognition
1814    #[test]
1815    fn readonly_property_has_detail_tag() {
1816        let src = "<?php\nclass Config { public readonly string $name; }\n$c = new Config();\n$c->";
1817        let d = doc(src);
1818        let pos = Position {
1819            line: 3,
1820            character: 4,
1821        };
1822        let items = filtered_completions_at(
1823            &d,
1824            &[],
1825            Some(">"),
1826            &CompletionCtx {
1827                source: Some(src),
1828                position: Some(pos),
1829                ..Default::default()
1830            },
1831        );
1832        let name_item = items.iter().find(|i| i.label == "$name");
1833        assert!(name_item.is_some(), "should have $name in completions");
1834        assert_eq!(
1835            name_item.unwrap().detail.as_deref(),
1836            Some("readonly"),
1837            "$name should be tagged readonly"
1838        );
1839    }
1840
1841    // Feature 2: variables scoped to cursor line
1842    #[test]
1843    fn variables_after_cursor_not_suggested() {
1844        let src = "<?php\n$early = new Foo();\n// cursor here\n$late = new Bar();";
1845        let d = doc(src);
1846        let pos = Position {
1847            line: 2,
1848            character: 0,
1849        };
1850        let items = filtered_completions_at(
1851            &d,
1852            &[],
1853            None,
1854            &CompletionCtx {
1855                source: Some(src),
1856                position: Some(pos),
1857                ..Default::default()
1858            },
1859        );
1860        let ls = labels(&items);
1861        assert!(ls.contains(&"$early"), "$early should be suggested");
1862        assert!(
1863            !ls.contains(&"$late"),
1864            "$late declared after cursor should not be suggested"
1865        );
1866    }
1867
1868    // Feature 3: sub-namespace backslash completions
1869    #[test]
1870    fn backslash_prefix_suggests_matching_classes() {
1871        let d = doc("<?php\n$x = new App\\");
1872        let other = Arc::new(ParsedDoc::parse(
1873            "<?php\nnamespace App\\Services;\nclass Mailer {}\nclass Logger {}".to_string(),
1874        ));
1875        let pos = Position {
1876            line: 1,
1877            character: 18,
1878        };
1879        let items = filtered_completions_at(
1880            &d,
1881            &[other],
1882            None,
1883            &CompletionCtx {
1884                source: Some("<?php\n$x = new App\\"),
1885                position: Some(pos),
1886                ..Default::default()
1887            },
1888        );
1889        let ls = labels(&items);
1890        assert!(
1891            ls.contains(&"Mailer"),
1892            "should suggest Mailer under App\\Services"
1893        );
1894    }
1895
1896    // Feature 1: nullsafe ?-> completions
1897    #[test]
1898    fn nullsafe_arrow_triggers_member_completions() {
1899        let src = "<?php\nclass Service { public function run() {} public string $status; }\n$s = new Service();\n$s?->";
1900        let d = doc(src);
1901        let pos = Position {
1902            line: 3,
1903            character: 5,
1904        };
1905        let items = filtered_completions_at(
1906            &d,
1907            &[],
1908            Some(">"),
1909            &CompletionCtx {
1910                source: Some(src),
1911                position: Some(pos),
1912                ..Default::default()
1913            },
1914        );
1915        let ls = labels(&items);
1916        assert!(ls.contains(&"run"), "?-> should complete Service::run()");
1917        assert!(
1918            ls.iter().any(|l| l.contains("status")),
1919            "?-> should complete Service::$status"
1920        );
1921    }
1922
1923    // Feature 5: magic methods in class body
1924    #[test]
1925    fn magic_methods_suggested_in_class_body() {
1926        let src = "<?php\nclass Foo {\n    __\n}";
1927        let d = doc(src);
1928        let pos = Position {
1929            line: 2,
1930            character: 6,
1931        };
1932        let items = filtered_completions_at(
1933            &d,
1934            &[],
1935            None,
1936            &CompletionCtx {
1937                source: Some(src),
1938                position: Some(pos),
1939                ..Default::default()
1940            },
1941        );
1942        let ls = labels(&items);
1943        assert!(ls.contains(&"__construct"), "should suggest __construct");
1944        assert!(ls.contains(&"__toString"), "should suggest __toString");
1945    }
1946
1947    #[test]
1948    fn arrow_trigger_does_not_complete_on_unknown_receiver() {
1949        // $unknown-> has no type info, so no class members should be returned.
1950        // The fallback returns methods from the current doc, but since the doc
1951        // has no class, the result should be empty (no methods available).
1952        let src = "<?php\n$unknown->";
1953        let d = doc(src);
1954        let pos = Position {
1955            line: 1,
1956            character: 10,
1957        };
1958        let items = filtered_completions_at(
1959            &d,
1960            &[],
1961            Some(">"),
1962            &CompletionCtx {
1963                source: Some(src),
1964                position: Some(pos),
1965                ..Default::default()
1966            },
1967        );
1968        // No class is defined in this doc, so the fallback method list is empty.
1969        assert!(
1970            items.is_empty(),
1971            "unknown receiver should yield no completions, got: {:?}",
1972            labels(&items)
1973        );
1974    }
1975
1976    #[test]
1977    fn static_trigger_shows_only_static_members() {
1978        // ClassName:: should only return static methods/constants, NOT instance methods.
1979        let src = concat!(
1980            "<?php\n",
1981            "class MyClass {\n",
1982            "    public static function staticMethod(): void {}\n",
1983            "    public function instanceMethod(): void {}\n",
1984            "    public static int $staticProp = 0;\n",
1985            "    const MY_CONST = 42;\n",
1986            "}\n",
1987            "MyClass::",
1988        );
1989        let d = doc(src);
1990        let pos = Position {
1991            line: 7,
1992            character: 9,
1993        };
1994        let items = filtered_completions_at(
1995            &d,
1996            &[],
1997            Some(":"),
1998            &CompletionCtx {
1999                source: Some(src),
2000                position: Some(pos),
2001                ..Default::default()
2002            },
2003        );
2004        let ls = labels(&items);
2005        assert!(ls.contains(&"staticMethod"), "should include static method");
2006        assert!(ls.contains(&"MY_CONST"), "should include constant");
2007        assert!(
2008            !ls.contains(&"instanceMethod"),
2009            "should NOT include instance method in static completion, got: {:?}",
2010            ls
2011        );
2012    }
2013
2014    // ── Snapshot tests ───────────────────────────────────────────────────────
2015
2016    use expect_test::expect;
2017
2018    #[test]
2019    fn snapshot_keyword_completions_present() {
2020        // Verify a handful of core PHP keywords appear in the default completion list.
2021        let items = keyword_completions();
2022        let mut ls: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
2023        ls.sort_unstable();
2024        // Snapshot just the first 10 sorted keywords so the test is stable even
2025        // if new keywords are added later.
2026        let first_ten = ls[..10.min(ls.len())].join("\n");
2027        expect![[r#"
2028            abstract
2029            and
2030            array
2031            as
2032            break
2033            callable
2034            case
2035            catch
2036            class
2037            clone"#]]
2038        .assert_eq(&first_ten);
2039    }
2040
2041    #[test]
2042    fn snapshot_symbol_completions_for_simple_class() {
2043        let d = doc(
2044            "<?php\nclass Counter { public function increment(): void {} public function reset(): void {} }",
2045        );
2046        let items = symbol_completions(&d);
2047        let mut ls: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
2048        ls.sort_unstable();
2049        expect![[r#"
2050            Counter
2051            increment
2052            reset"#]]
2053        .assert_eq(&ls.join("\n"));
2054    }
2055
2056    #[test]
2057    fn snapshot_symbol_completions_for_function_with_params() {
2058        let d = doc("<?php\nfunction connect(string $host, int $port): void {}");
2059        let items = symbol_completions(&d);
2060        let mut ls: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
2061        ls.sort_unstable();
2062        expect![[r#"
2063            $host
2064            $port
2065            connect
2066            connect(host:, port:)"#]]
2067        .assert_eq(&ls.join("\n"));
2068    }
2069
2070    #[test]
2071    fn snapshot_arrow_completions_for_typed_var() {
2072        let src = "<?php\nclass Greeter { public function sayHello(): void {} public function sayBye(): void {} }\n$g = new Greeter();\n$g->";
2073        let d = doc(src);
2074        let pos = Position {
2075            line: 3,
2076            character: 4,
2077        };
2078        let items = filtered_completions_at(
2079            &d,
2080            &[],
2081            Some(">"),
2082            &CompletionCtx {
2083                source: Some(src),
2084                position: Some(pos),
2085                ..Default::default()
2086            },
2087        );
2088        let mut ls: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
2089        ls.sort_unstable();
2090        expect![[r#"
2091            sayBye
2092            sayHello"#]]
2093        .assert_eq(&ls.join("\n"));
2094    }
2095
2096    // ── Array destructuring variable suggestions ─────────────────────────────
2097
2098    #[test]
2099    fn array_destructuring_short_syntax_produces_variables() {
2100        // [$a, $b] = someFunction() — both variables should be suggested.
2101        let d = doc("<?php\n[$first, $second] = getSomething();");
2102        let items = symbol_completions(&d);
2103        let ls = labels(&items);
2104        assert!(
2105            ls.contains(&"$first"),
2106            "$first from array destructuring should be in completions"
2107        );
2108        assert!(
2109            ls.contains(&"$second"),
2110            "$second from array destructuring should be in completions"
2111        );
2112    }
2113
2114    #[test]
2115    fn array_destructuring_variables_have_variable_kind() {
2116        let d = doc("<?php\n[$x, $y, $z] = getData();");
2117        let items = symbol_completions(&d);
2118        for name in &["$x", "$y", "$z"] {
2119            let item = items.iter().find(|i| i.label.as_str() == *name);
2120            assert!(item.is_some(), "{name} should be in completions");
2121            assert_eq!(
2122                item.unwrap().kind,
2123                Some(CompletionItemKind::VARIABLE),
2124                "{name} should have VARIABLE kind"
2125            );
2126        }
2127    }
2128
2129    #[test]
2130    fn array_destructuring_respects_cursor_line_scope() {
2131        // Variables from array destructuring after the cursor line should not appear.
2132        let src = "<?php\n// cursor here\n[$early] = getA();\n[$late] = getB();";
2133        let d = doc(src);
2134        // cursor at line 1 (the comment line)
2135        let pos = Position {
2136            line: 1,
2137            character: 0,
2138        };
2139        let items = filtered_completions_at(
2140            &d,
2141            &[],
2142            None,
2143            &CompletionCtx {
2144                source: Some(src),
2145                position: Some(pos),
2146                ..Default::default()
2147            },
2148        );
2149        let ls = labels(&items);
2150        assert!(
2151            !ls.contains(&"$early"),
2152            "$early declared after cursor should not appear"
2153        );
2154        assert!(
2155            !ls.contains(&"$late"),
2156            "$late declared after cursor should not appear"
2157        );
2158    }
2159
2160    // ── Include/require path completions ────────────────────────────────────
2161
2162    #[test]
2163    fn include_path_prefix_returns_none_for_non_include_line() {
2164        let src = "<?php\n$x = 'some string';";
2165        let pos = Position {
2166            line: 1,
2167            character: 14,
2168        };
2169        assert!(
2170            include_path_prefix(src, pos).is_none(),
2171            "should not trigger on non-include line"
2172        );
2173    }
2174
2175    #[test]
2176    fn include_path_prefix_returns_none_for_absolute_path() {
2177        let src = "<?php\nrequire '/absolute/path/file.php';";
2178        let pos = Position {
2179            line: 1,
2180            character: 30,
2181        };
2182        assert!(
2183            include_path_prefix(src, pos).is_none(),
2184            "should not trigger for absolute paths"
2185        );
2186    }
2187
2188    #[test]
2189    fn include_path_prefix_returns_none_for_stream_wrapper() {
2190        let src = "<?php\nrequire 'phar://archive.phar/file.php';";
2191        let pos = Position {
2192            line: 1,
2193            character: 35,
2194        };
2195        assert!(
2196            include_path_prefix(src, pos).is_none(),
2197            "should not trigger for stream wrappers"
2198        );
2199    }
2200
2201    #[test]
2202    fn include_path_prefix_returns_relative_dot_slash() {
2203        let src = "<?php\nrequire './lib/Helper";
2204        let pos = Position {
2205            line: 1,
2206            character: 23,
2207        };
2208        let result = include_path_prefix(src, pos);
2209        assert_eq!(
2210            result.as_deref(),
2211            Some("./lib/Helper"),
2212            "should return the typed relative path prefix"
2213        );
2214    }
2215
2216    #[test]
2217    fn include_path_prefix_returns_double_dot_prefix() {
2218        let src = "<?php\ninclude '../utils/";
2219        let pos = Position {
2220            line: 1,
2221            character: 22,
2222        };
2223        let result = include_path_prefix(src, pos);
2224        assert_eq!(
2225            result.as_deref(),
2226            Some("../utils/"),
2227            "should return ../utils/ prefix"
2228        );
2229    }
2230
2231    #[test]
2232    fn include_path_prefix_returns_empty_for_bare_quote() {
2233        let src = "<?php\nrequire '";
2234        let pos = Position {
2235            line: 1,
2236            character: 10,
2237        };
2238        let result = include_path_prefix(src, pos);
2239        assert_eq!(
2240            result.as_deref(),
2241            Some(""),
2242            "bare quote should return empty prefix (list current dir)"
2243        );
2244    }
2245
2246    #[test]
2247    fn include_path_completions_lists_relative_directory() {
2248        use std::fs;
2249
2250        let tmp = tempfile::tempdir().expect("tmpdir");
2251        let subdir = tmp.path().join("lib");
2252        fs::create_dir_all(&subdir).expect("create lib dir");
2253        fs::write(subdir.join("Helper.php"), "<?php").expect("write Helper.php");
2254        fs::write(subdir.join("Utils.php"), "<?php").expect("write Utils.php");
2255        // Non-PHP file that should be excluded
2256        fs::write(subdir.join("README.md"), "# readme").expect("write README.md");
2257
2258        let doc_path = tmp.path().join("index.php");
2259        let doc_uri = Url::from_file_path(&doc_path).expect("doc uri");
2260
2261        // Prefix "./lib/" — should list the lib directory contents
2262        let items = include_path_completions(&doc_uri, "./lib/");
2263        let ls: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
2264        assert!(ls.contains(&"Helper.php"), "should list Helper.php");
2265        assert!(ls.contains(&"Utils.php"), "should list Utils.php");
2266        assert!(
2267            !ls.contains(&"README.md"),
2268            "non-PHP files should be excluded"
2269        );
2270    }
2271
2272    #[test]
2273    fn include_path_completions_insert_text_includes_directory_prefix() {
2274        use std::fs;
2275
2276        let tmp = tempfile::tempdir().expect("tmpdir");
2277        let subdir = tmp.path().join("src");
2278        fs::create_dir_all(&subdir).expect("create src dir");
2279        fs::write(subdir.join("Boot.php"), "<?php").expect("write Boot.php");
2280
2281        let doc_path = tmp.path().join("main.php");
2282        let doc_uri = Url::from_file_path(&doc_path).expect("doc uri");
2283
2284        let items = include_path_completions(&doc_uri, "./src/");
2285        let boot = items.iter().find(|i| i.label == "Boot.php");
2286        assert!(boot.is_some(), "Boot.php should be in completions");
2287        assert_eq!(
2288            boot.unwrap().insert_text.as_deref(),
2289            Some("./src/Boot.php"),
2290            "insert_text should include the directory prefix"
2291        );
2292    }
2293
2294    #[test]
2295    fn include_path_completions_is_empty_for_non_existent_directory() {
2296        let tmp = tempfile::tempdir().expect("tmpdir");
2297        let doc_path = tmp.path().join("index.php");
2298        let doc_uri = Url::from_file_path(&doc_path).expect("doc uri");
2299
2300        let items = include_path_completions(&doc_uri, "./nonexistent/");
2301        assert!(
2302            items.is_empty(),
2303            "should return empty list for non-existent directory"
2304        );
2305    }
2306
2307    #[test]
2308    fn include_path_completions_dir_entries_have_folder_kind() {
2309        use std::fs;
2310
2311        let tmp = tempfile::tempdir().expect("tmpdir");
2312        let subdir = tmp.path().join("modules");
2313        fs::create_dir_all(&subdir).expect("create modules dir");
2314
2315        let doc_path = tmp.path().join("index.php");
2316        let doc_uri = Url::from_file_path(&doc_path).expect("doc uri");
2317
2318        let items = include_path_completions(&doc_uri, "");
2319        let modules = items.iter().find(|i| i.label == "modules");
2320        assert!(modules.is_some(), "modules dir should be in completions");
2321        assert_eq!(
2322            modules.unwrap().kind,
2323            Some(CompletionItemKind::FOLDER),
2324            "directory should have FOLDER kind"
2325        );
2326        assert_eq!(
2327            modules.unwrap().insert_text.as_deref(),
2328            Some("modules/"),
2329            "directory insert_text should end with /"
2330        );
2331    }
2332}