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