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