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