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