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