Skip to main content

php_lsp/backend/
helpers.rs

1use std::sync::Arc;
2
3use tower_lsp::Client;
4use tower_lsp::lsp_types::*;
5
6use php_ast::{
7    ClassMember, ClassMemberKind, EnumMember, EnumMemberKind, ExprKind, NamespaceBody, Stmt,
8    StmtKind,
9};
10
11use crate::ast::{ParsedDoc, str_offset};
12use crate::navigation::definition::find_declaration_range;
13use crate::navigation::references::SymbolKind;
14
15use crate::actions::generate_action::{
16    generate_constructor_actions, generate_getters_setters_actions,
17};
18use crate::actions::implement_action::implement_missing_actions;
19use crate::actions::phpdoc_action::phpdoc_actions;
20use crate::actions::promote_action::promote_constructor_actions;
21use crate::actions::type_action::add_return_type_actions;
22
23use super::Backend;
24
25pub(super) fn php_file_op() -> FileOperationRegistrationOptions {
26    FileOperationRegistrationOptions {
27        filters: vec![FileOperationFilter {
28            scheme: Some("file".to_string()),
29            pattern: FileOperationPattern {
30                glob: "**/*.php".to_string(),
31                matches: Some(FileOperationPatternKind::File),
32                options: None,
33            },
34        }],
35    }
36}
37
38/// Strip the `edit` from each `CodeAction` and attach a `data` payload so the
39/// client can request the edit lazily via `codeAction/resolve`.
40pub(super) fn defer_actions(
41    actions: Vec<CodeActionOrCommand>,
42    kind_tag: &str,
43    uri: &Url,
44    range: Range,
45) -> Vec<CodeActionOrCommand> {
46    actions
47        .into_iter()
48        .map(|a| match a {
49            CodeActionOrCommand::CodeAction(mut ca) => {
50                ca.edit = None;
51                ca.data = Some(serde_json::json!({
52                    "php_lsp_resolve": kind_tag,
53                    "uri": uri.to_string(),
54                    "range": range,
55                }));
56                CodeActionOrCommand::CodeAction(ca)
57            }
58            other => other,
59        })
60        .collect()
61}
62
63/// Returns `true` when the identifier at `position` is immediately preceded by `->`,
64/// indicating it is a property or method name in an instance access expression.
65pub(super) fn is_after_arrow(source: &str, position: Position) -> bool {
66    let line = match source.lines().nth(position.line as usize) {
67        Some(l) => l,
68        None => return false,
69    };
70    let chars: Vec<char> = line.chars().collect();
71    let col = position.character as usize;
72    // Find the char index of the cursor (UTF-16 → char index).
73    let mut utf16_col = 0usize;
74    let mut char_idx = 0usize;
75    for ch in &chars {
76        if utf16_col >= col {
77            break;
78        }
79        utf16_col += ch.len_utf16();
80        char_idx += 1;
81    }
82    // Walk left past word chars to the start of the identifier.
83    let is_word = |c: char| c.is_alphanumeric() || c == '_';
84    while char_idx > 0 && is_word(chars[char_idx - 1]) {
85        char_idx -= 1;
86    }
87    char_idx >= 2 && chars[char_idx - 1] == '>' && chars[char_idx - 2] == '-'
88}
89
90/// Classify the symbol at `position` so `find_references` can use the right walker.
91///
92/// Heuristics (in priority order):
93/// 1. Preceded by `->` or `?->` → `Method`
94/// 2. Preceded by `::` → `Method` (static)
95/// 3. Word starts with `$` → variable (returns `None`; variables are handled separately)
96/// 4. First character is uppercase AND not preceded by `->` or `::` → `Class`
97/// 5. Otherwise → `Function`
98///
99/// Falls back to `None` when the context cannot be determined.
100pub(super) fn symbol_kind_at(source: &str, position: Position, word: &str) -> Option<SymbolKind> {
101    if word.starts_with('$') {
102        return None; // variables handled elsewhere
103    }
104    let line = source.lines().nth(position.line as usize)?;
105    let chars: Vec<char> = line.chars().collect();
106
107    // Convert UTF-16 column to char index.
108    let col = position.character as usize;
109    let mut utf16_col = 0usize;
110    let mut char_idx = 0usize;
111    for ch in &chars {
112        if utf16_col >= col {
113            break;
114        }
115        utf16_col += ch.len_utf16();
116        char_idx += 1;
117    }
118
119    // Walk left past identifier characters to find the first character before the word.
120    let is_word_char = |c: char| c.is_alphanumeric() || c == '_';
121    while char_idx > 0 && is_word_char(chars[char_idx - 1]) {
122        char_idx -= 1;
123    }
124
125    // Look past the end of the word to distinguish `->method()` from `->prop`.
126    let word_end = {
127        let mut i = char_idx;
128        while i < chars.len() && is_word_char(chars[i]) {
129            i += 1;
130        }
131        // Skip spaces before the next token.
132        while i < chars.len() && chars[i] == ' ' {
133            i += 1;
134        }
135        i
136    };
137    let next_is_call = word_end < chars.len() && chars[word_end] == '(';
138
139    // Check for `->` or `?->`
140    if char_idx >= 2 && chars[char_idx - 1] == '>' && chars[char_idx - 2] == '-' {
141        return if next_is_call {
142            Some(SymbolKind::Method)
143        } else {
144            Some(SymbolKind::Property)
145        };
146    }
147    if char_idx >= 3
148        && chars[char_idx - 1] == '>'
149        && chars[char_idx - 2] == '-'
150        && chars[char_idx - 3] == '?'
151    {
152        return if next_is_call {
153            Some(SymbolKind::Method)
154        } else {
155            Some(SymbolKind::Property)
156        };
157    }
158
159    // Check for `::`
160    if char_idx >= 2 && chars[char_idx - 1] == ':' && chars[char_idx - 2] == ':' {
161        return Some(SymbolKind::Method);
162    }
163
164    // If the word starts with an uppercase letter it is likely a class/interface/enum name.
165    if word
166        .chars()
167        .next()
168        .map(|c| c.is_uppercase())
169        .unwrap_or(false)
170    {
171        return Some(SymbolKind::Class);
172    }
173
174    // Otherwise treat as a free function.
175    Some(SymbolKind::Function)
176}
177
178/// Convert an LSP `Position` to a byte offset within `source`.
179/// Returns `None` if the position is beyond the end of the source.
180/// Returns `true` when `inner` is fully contained inside `outer` (the LSP
181/// half-open `[start, end)` convention is irrelevant here — a range with
182/// the exact same bounds counts as contained).
183pub(super) fn range_within(inner: Range, outer: Range) -> bool {
184    let start_ok =
185        (inner.start.line, inner.start.character) >= (outer.start.line, outer.start.character);
186    let end_ok = (inner.end.line, inner.end.character) <= (outer.end.line, outer.end.character);
187    start_ok && end_ok
188}
189
190pub(super) fn position_to_byte_offset(source: &str, position: Position) -> Option<u32> {
191    let mut byte_offset = 0usize;
192    for (idx, line) in source.split('\n').enumerate() {
193        if idx as u32 == position.line {
194            // Strip trailing \r so CRLF lines don't affect column counting.
195            let line_content = line.trim_end_matches('\r');
196            let mut col = 0u32;
197            for (byte_idx, ch) in line_content.char_indices() {
198                if col >= position.character {
199                    return Some((byte_offset + byte_idx) as u32);
200                }
201                col += ch.len_utf16() as u32;
202            }
203            return Some((byte_offset + line_content.len()) as u32);
204        }
205        byte_offset += line.len() + 1; // +1 for the '\n'
206    }
207    None
208}
209
210/// Returns `true` if the cursor is positioned on a method name inside a class,
211/// interface, trait, or enum declaration in the AST.
212///
213/// This is a pre-pass used before the character-based `symbol_kind_at` heuristic
214/// so that method *declarations* (`public function add() {}`) are classified as
215/// `SymbolKind::Method` rather than falling through to `SymbolKind::Function`.
216pub(super) fn cursor_is_on_method_decl(
217    source: &str,
218    stmts: &[Stmt<'_, '_>],
219    position: Position,
220) -> bool {
221    let Some(cursor) = position_to_byte_offset(source, position) else {
222        return false;
223    };
224
225    // Locate `name` within `member_span` rather than searching the whole
226    // source — the global `str_offset` returns the first occurrence in the
227    // file, which causes a method named `status` to also match a property
228    // named `$status` (cursor on the `$status` declaration falsely tests
229    // positive for "on method decl").
230    fn name_offset_in_member(source: &str, member_span: php_ast::Span, name: &str) -> Option<u32> {
231        let s = member_span.start as usize;
232        let e = (member_span.end as usize).min(source.len());
233        source
234            .get(s..e)?
235            .find(name)
236            .map(|off| member_span.start + off as u32)
237    }
238    fn check(source: &str, stmts: &[Stmt<'_, '_>], cursor: u32) -> bool {
239        for stmt in stmts {
240            match &stmt.kind {
241                StmtKind::Class(c) => {
242                    for member in c.body.members.iter() {
243                        if let ClassMemberKind::Method(m) = &member.kind {
244                            let name = m.name.to_string();
245                            let start =
246                                name_offset_in_member(source, member.span, &name).unwrap_or(0);
247                            let end = start + name.len() as u32;
248                            if cursor >= start && cursor < end {
249                                return true;
250                            }
251                        }
252                    }
253                }
254                StmtKind::Interface(i) => {
255                    for member in i.body.members.iter() {
256                        if let ClassMemberKind::Method(m) = &member.kind {
257                            let name = m.name.to_string();
258                            let start =
259                                name_offset_in_member(source, member.span, &name).unwrap_or(0);
260                            let end = start + name.len() as u32;
261                            if cursor >= start && cursor < end {
262                                return true;
263                            }
264                        }
265                    }
266                }
267                StmtKind::Trait(t) => {
268                    for member in t.body.members.iter() {
269                        if let ClassMemberKind::Method(m) = &member.kind {
270                            let name = m.name.to_string();
271                            let start =
272                                name_offset_in_member(source, member.span, &name).unwrap_or(0);
273                            let end = start + name.len() as u32;
274                            if cursor >= start && cursor < end {
275                                return true;
276                            }
277                        }
278                    }
279                }
280                StmtKind::Enum(e) => {
281                    for member in e.body.members.iter() {
282                        if let EnumMemberKind::Method(m) = &member.kind {
283                            let name = m.name.to_string();
284                            let start =
285                                name_offset_in_member(source, member.span, &name).unwrap_or(0);
286                            let end = start + name.len() as u32;
287                            if cursor >= start && cursor < end {
288                                return true;
289                            }
290                        }
291                    }
292                }
293                StmtKind::Namespace(ns) => {
294                    if let NamespaceBody::Braced(inner) = &ns.body
295                        && check(source, &inner.stmts, cursor)
296                    {
297                        return true;
298                    }
299                }
300                _ => {}
301            }
302        }
303        false
304    }
305
306    check(source, stmts, cursor)
307}
308
309/// If the cursor is on a class or trait property *declaration* name (e.g.
310/// `public string $status`), return the property name without the leading `$`
311/// so the caller can search for `status` via `SymbolKind::Property`.  Returns
312/// `None` when the cursor is elsewhere.
313pub(super) fn cursor_is_on_property_decl(
314    source: &str,
315    stmts: &[Stmt<'_, '_>],
316    position: Position,
317) -> Option<String> {
318    let cursor = position_to_byte_offset(source, position)?;
319
320    fn name_offset_in_member(source: &str, member_span: php_ast::Span, name: &str) -> Option<u32> {
321        let s = member_span.start as usize;
322        let e = (member_span.end as usize).min(source.len());
323        source
324            .get(s..e)?
325            .find(name)
326            .map(|off| member_span.start + off as u32)
327    }
328    fn check(source: &str, stmts: &[Stmt<'_, '_>], cursor: u32) -> Option<String> {
329        for stmt in stmts {
330            match &stmt.kind {
331                StmtKind::Class(c) => {
332                    for member in c.body.members.iter() {
333                        if let ClassMemberKind::Property(p) = &member.kind {
334                            let name = p.name.to_string();
335                            let start =
336                                name_offset_in_member(source, member.span, &name).unwrap_or(0);
337                            let end = start + name.len() as u32;
338                            if cursor >= start && cursor < end {
339                                return Some(name);
340                            }
341                        }
342                    }
343                }
344                StmtKind::Trait(t) => {
345                    for member in t.body.members.iter() {
346                        if let ClassMemberKind::Property(p) = &member.kind {
347                            let name = p.name.to_string();
348                            let start =
349                                name_offset_in_member(source, member.span, &name).unwrap_or(0);
350                            let end = start + name.len() as u32;
351                            if cursor >= start && cursor < end {
352                                return Some(name);
353                            }
354                        }
355                    }
356                }
357                StmtKind::Namespace(ns) => {
358                    if let NamespaceBody::Braced(inner) = &ns.body
359                        && let Some(name) = check(source, &inner.stmts, cursor)
360                    {
361                        return Some(name);
362                    }
363                }
364                _ => {}
365            }
366        }
367        None
368    }
369
370    check(source, stmts, cursor)
371}
372
373/// When the cursor sits on a class / interface / trait / enum constant
374/// declaration (`const NAME = ...`), return `(const_name, owning_class_short_name)`.
375/// `owning_class_short_name` is the short name of the declaring type; it is used
376/// as a class filter when searching for references so that same-named constants
377/// in different classes don't cross-match.
378pub(super) fn cursor_is_on_constant_decl(
379    source: &str,
380    stmts: &[Stmt<'_, '_>],
381    position: Position,
382) -> Option<(String, Option<String>)> {
383    let cursor = position_to_byte_offset(source, position)?;
384
385    fn name_offset_in_member(source: &str, member_span: php_ast::Span, name: &str) -> Option<u32> {
386        let s = member_span.start as usize;
387        let e = (member_span.end as usize).min(source.len());
388        source
389            .get(s..e)?
390            .find(name)
391            .map(|off| member_span.start + off as u32)
392    }
393
394    fn check_members(source: &str, members: &[ClassMember<'_, '_>], cursor: u32) -> Option<String> {
395        for member in members {
396            if let ClassMemberKind::ClassConst(c) = &member.kind {
397                let name = c.name.to_string();
398                let start = name_offset_in_member(source, member.span, &name).unwrap_or(0);
399                let end = start + name.len() as u32;
400                if cursor >= start && cursor < end {
401                    return Some(name);
402                }
403            }
404        }
405        None
406    }
407
408    fn check_enum_members(
409        source: &str,
410        members: &[EnumMember<'_, '_>],
411        cursor: u32,
412    ) -> Option<String> {
413        for member in members {
414            if let EnumMemberKind::ClassConst(c) = &member.kind {
415                let name = c.name.to_string();
416                let start = name_offset_in_member(source, member.span, &name).unwrap_or(0);
417                let end = start + name.len() as u32;
418                if cursor >= start && cursor < end {
419                    return Some(name);
420                }
421            }
422        }
423        None
424    }
425
426    fn check(
427        source: &str,
428        stmts: &[Stmt<'_, '_>],
429        cursor: u32,
430    ) -> Option<(String, Option<String>)> {
431        for stmt in stmts {
432            match &stmt.kind {
433                StmtKind::Class(c) => {
434                    if let Some(const_name) = check_members(source, &c.body.members, cursor) {
435                        let owner = c.name.map(|n| n.to_string());
436                        return Some((const_name, owner));
437                    }
438                }
439                StmtKind::Interface(i) => {
440                    if let Some(const_name) = check_members(source, &i.body.members, cursor) {
441                        return Some((const_name, Some(i.name.to_string())));
442                    }
443                }
444                StmtKind::Trait(t) => {
445                    if let Some(const_name) = check_members(source, &t.body.members, cursor) {
446                        return Some((const_name, Some(t.name.to_string())));
447                    }
448                }
449                StmtKind::Enum(e) => {
450                    if let Some(const_name) = check_enum_members(source, &e.body.members, cursor) {
451                        return Some((const_name, Some(e.name.to_string())));
452                    }
453                }
454                StmtKind::Const(items) => {
455                    for item in items.iter() {
456                        let name = item.name.to_string();
457                        let s = item.span.start as usize;
458                        let e = (item.span.end as usize).min(source.len());
459                        if let Some(off) = source.get(s..e).and_then(|sl| sl.find(&name)) {
460                            let start = item.span.start + off as u32;
461                            let end = start + name.len() as u32;
462                            if cursor >= start && cursor < end {
463                                return Some((name, None));
464                            }
465                        }
466                    }
467                }
468                StmtKind::Expression(expr) => {
469                    // Detect cursor inside `define('NAME', value)` string literal.
470                    if let ExprKind::FunctionCall(f) = &expr.kind
471                        && let ExprKind::Identifier(id) = &f.name.kind
472                        && id.as_str() == "define"
473                        && let Some(first_arg) = f.args.first()
474                        && let ExprKind::String(s) = &first_arg.value.kind
475                    {
476                        // String content starts one byte after the opening quote.
477                        let start = first_arg.value.span.start + 1;
478                        let end = start + s.len() as u32;
479                        if cursor >= start && cursor < end {
480                            return Some((s.to_string(), None));
481                        }
482                    }
483                }
484                StmtKind::Namespace(ns) => {
485                    if let NamespaceBody::Braced(inner) = &ns.body
486                        && let Some(result) = check(source, &inner.stmts, cursor)
487                    {
488                        return Some(result);
489                    }
490                }
491                _ => {}
492            }
493        }
494        None
495    }
496
497    check(source, stmts, cursor)
498}
499
500/// When the cursor sits on a `__construct` method name declaration, return
501/// the owning class FQN (namespace-qualified when inside a namespace). Returns
502/// `None` otherwise (including when the cursor is on a non-constructor method,
503/// inside a trait/interface, or inside a namespaced enum — constructors on
504/// those don't drive class instantiation call sites the way class constructors
505/// do).
506pub(super) fn class_name_at_construct_decl(
507    source: &str,
508    stmts: &[Stmt<'_, '_>],
509    position: Position,
510) -> Option<String> {
511    let cursor = position_to_byte_offset(source, position)?;
512
513    fn name_offset_in_member(source: &str, member_span: php_ast::Span, name: &str) -> Option<u32> {
514        let s = member_span.start as usize;
515        let e = (member_span.end as usize).min(source.len());
516        source
517            .get(s..e)?
518            .find(name)
519            .map(|off| member_span.start + off as u32)
520    }
521    fn check(source: &str, stmts: &[Stmt<'_, '_>], cursor: u32, ns_prefix: &str) -> Option<String> {
522        let mut current_ns = ns_prefix.to_owned();
523        for stmt in stmts {
524            match &stmt.kind {
525                StmtKind::Class(c) => {
526                    for member in c.body.members.iter() {
527                        if let ClassMemberKind::Method(m) = &member.kind
528                            && m.name == "__construct"
529                        {
530                            // Scope the name search to this member's own span:
531                            // a global `str_offset` returns the FIRST
532                            // `__construct` in the file, so when two classes
533                            // both define `__construct` every cursor lands on
534                            // the first one regardless of which class the
535                            // cursor is actually inside.
536                            let name = m.name.to_string();
537                            let start =
538                                name_offset_in_member(source, member.span, &name).unwrap_or(0);
539                            let end = start + name.len() as u32;
540                            if cursor >= start && cursor < end {
541                                let short = c.name?;
542                                return Some(if current_ns.is_empty() {
543                                    short.to_string()
544                                } else {
545                                    format!("{}\\{}", current_ns, short)
546                                });
547                            }
548                        }
549                    }
550                }
551                StmtKind::Namespace(ns) => {
552                    let ns_name = ns
553                        .name
554                        .as_ref()
555                        .map(|n| n.to_string_repr().to_string())
556                        .unwrap_or_default();
557                    match &ns.body {
558                        NamespaceBody::Braced(inner) => {
559                            if let Some(name) = check(source, &inner.stmts, cursor, &ns_name) {
560                                return Some(name);
561                            }
562                        }
563                        NamespaceBody::Simple => {
564                            current_ns = ns_name;
565                        }
566                    }
567                }
568                _ => {}
569            }
570        }
571        None
572    }
573
574    check(source, stmts, cursor, "")
575}
576
577/// If the cursor sits on a promoted constructor property parameter (one that
578/// has a visibility modifier like `public`/`protected`/`private`), return the
579/// property name without the leading `$` so the caller can search for
580/// `->name` property accesses (`SymbolKind::Property`).
581///
582/// Returns `None` for regular (non-promoted) params and for any cursor position
583/// not on a constructor param name.
584pub(super) fn promoted_property_at_cursor(
585    source: &str,
586    stmts: &[Stmt<'_, '_>],
587    position: Position,
588) -> Option<String> {
589    let cursor = position_to_byte_offset(source, position)?;
590
591    fn check(source: &str, stmts: &[Stmt<'_, '_>], cursor: u32) -> Option<String> {
592        for stmt in stmts {
593            match &stmt.kind {
594                StmtKind::Class(c) => {
595                    for member in c.body.members.iter() {
596                        if let ClassMemberKind::Method(m) = &member.kind
597                            && m.name == "__construct"
598                        {
599                            for param in m.params.iter() {
600                                if param.visibility.is_none() {
601                                    continue;
602                                }
603                                let name_start =
604                                    str_offset(source, &param.name.to_string()).unwrap_or(0);
605                                let name_end = name_start + param.name.to_string().len() as u32;
606                                if cursor >= name_start && cursor < name_end {
607                                    return Some(
608                                        param.name.to_string().trim_start_matches('$').to_string(),
609                                    );
610                                }
611                            }
612                        }
613                    }
614                }
615                StmtKind::Namespace(ns) => {
616                    if let NamespaceBody::Braced(inner) = &ns.body
617                        && let Some(name) = check(source, &inner.stmts, cursor)
618                    {
619                        return Some(name);
620                    }
621                }
622                _ => {}
623            }
624        }
625        None
626    }
627
628    check(source, stmts, cursor)
629}
630
631/// Tags for deferred code actions (resolved lazily via `codeAction/resolve`).
632/// Iteration order controls the order items appear in the client menu.
633pub(super) const DEFERRED_ACTION_TAGS: &[&str] = &[
634    "phpdoc",
635    "implement",
636    "constructor",
637    "getters_setters",
638    "return_type",
639    "promote",
640];
641
642impl Backend {
643    /// Run [`crate::document_store::DocumentStore::cached_analysis`] without
644    /// blocking the async executor. The warm path (cache entry current for the
645    /// file's text) resolves synchronously; the cold path — mir Pass 1 + Pass 2,
646    /// which can take hundreds of ms on large files and is hit after every
647    /// keystroke because edits clear the analysis cache — runs on the blocking
648    /// pool so it doesn't stall other in-flight requests.
649    pub(super) async fn cached_analysis_async(
650        &self,
651        uri: &Url,
652    ) -> Option<Arc<mir_analyzer::FileAnalysis>> {
653        if let Some(hit) = self.docs.cached_analysis_if_fresh(uri) {
654            return Some(hit);
655        }
656        let docs = Arc::clone(&self.docs);
657        let uri = uri.clone();
658        tokio::task::spawn_blocking(move || docs.cached_analysis(&uri))
659            .await
660            .unwrap_or(None)
661    }
662
663    /// Fetch the salsa-memoized workspace aggregate without blocking the async
664    /// executor. A warm memo returns quickly, but the cold rebuild after any
665    /// file change walks every `FileIndex` in the workspace — run it on the
666    /// blocking pool.
667    pub(super) async fn workspace_index_async(
668        &self,
669    ) -> Arc<crate::db::workspace_index::WorkspaceIndexData> {
670        let docs = Arc::clone(&self.docs);
671        match tokio::task::spawn_blocking(move || docs.get_workspace_index_salsa()).await {
672            Ok(wi) => wi,
673            // JoinError (panicked/cancelled blocking task): retry inline so a
674            // panic surfaces through the caller's panic guard.
675            Err(_) => self.docs.get_workspace_index_salsa(),
676        }
677    }
678
679    /// Tag → generator mapping for deferred code actions.
680    pub(super) fn generate_deferred_actions(
681        &self,
682        tag: &str,
683        source: &str,
684        doc: &Arc<ParsedDoc>,
685        range: Range,
686        uri: &Url,
687    ) -> Vec<CodeActionOrCommand> {
688        match tag {
689            "phpdoc" => phpdoc_actions(uri, doc, source, range),
690            "implement" => {
691                let imports = self.file_imports(uri);
692                implement_missing_actions(
693                    source,
694                    doc,
695                    &self
696                        .docs
697                        .doc_with_others(uri, Arc::clone(doc), &self.open_urls()),
698                    range,
699                    uri,
700                    &imports,
701                )
702            }
703            "constructor" => generate_constructor_actions(source, doc, range, uri),
704            "getters_setters" => generate_getters_setters_actions(source, doc, range, uri),
705            "return_type" => add_return_type_actions(source, doc, range, uri),
706            "promote" => promote_constructor_actions(source, doc, range, uri),
707            _ => Vec::new(),
708        }
709    }
710
711    /// Try to resolve a fully-qualified name via the PSR-4 map.
712    /// Indexes the file on-demand if it is not already in the document store.
713    pub(super) async fn psr4_goto(&self, fqn: &str) -> Option<Location> {
714        let path = self.psr4.load().resolve(fqn)?;
715
716        let file_uri = Url::from_file_path(&path).ok()?;
717
718        // Index on-demand if the file was not picked up by the workspace scan.
719        // Use `get_doc_salsa_any` (ignores open-file gating): after `index()`
720        // the file is mirrored but background-only, and the call site needs
721        // the AST regardless of whether the editor has the file open.
722        if self.docs.get_doc_salsa(&file_uri).is_none() {
723            let text = tokio::fs::read_to_string(&path).await.ok()?;
724            self.index_if_not_open(file_uri.clone(), &text);
725        }
726
727        let doc = self.docs.get_doc_salsa(&file_uri)?;
728
729        // Classes are declared by their short (unqualified) name, e.g. `class Foo`
730        // not `class App\Services\Foo`.
731        let short_name = fqn.split('\\').next_back()?;
732        let range = find_declaration_range(doc.source(), &doc, short_name)?;
733
734        Some(Location {
735            uri: file_uri,
736            range,
737        })
738    }
739
740    /// Request the client to apply a workspace edit.
741    /// Returns true if the edit was successfully applied, false otherwise.
742    pub async fn apply_workspace_edit(&self, edit: WorkspaceEdit) -> bool {
743        self.client
744            .apply_edit(edit)
745            .await
746            .ok()
747            .map(|result| result.applied)
748            .unwrap_or(false)
749    }
750}
751
752/// Run `vendor/bin/phpunit --filter <filter>` and show the result via
753/// `window/showMessageRequest`.  Offers "Run Again" on both success and
754/// failure, and additionally "Open File" on failure so the user can jump
755/// straight to the test source.  Selecting "Run Again" re-executes the test
756/// in the same task without returning to the client first.
757pub(super) async fn run_phpunit(
758    client: &Client,
759    filter: &str,
760    root: Option<&std::path::Path>,
761    file_uri: Option<&Url>,
762) {
763    let output = tokio::process::Command::new("vendor/bin/phpunit")
764        .arg("--filter")
765        .arg(filter)
766        .current_dir(root.unwrap_or(std::path::Path::new(".")))
767        .output()
768        .await;
769
770    let (success, message) = match output {
771        Ok(out) => {
772            let text = String::from_utf8_lossy(&out.stdout).into_owned()
773                + &String::from_utf8_lossy(&out.stderr);
774            let last_line = text
775                .lines()
776                .rev()
777                .find(|l| !l.trim().is_empty())
778                .unwrap_or("(no output)")
779                .to_string();
780            let ok = out.status.success();
781            let msg = if ok {
782                format!("✓ {filter}: {last_line}")
783            } else {
784                format!("✗ {filter}: {last_line}")
785            };
786            (ok, msg)
787        }
788        Err(e) => (
789            false,
790            format!("php-lsp.runTest: failed to spawn phpunit — {e}"),
791        ),
792    };
793
794    let msg_type = if success {
795        MessageType::INFO
796    } else {
797        MessageType::ERROR
798    };
799    let mut actions = vec![MessageActionItem {
800        title: "Run Again".to_string(),
801        properties: Default::default(),
802    }];
803    if !success && file_uri.is_some() {
804        actions.push(MessageActionItem {
805            title: "Open File".to_string(),
806            properties: Default::default(),
807        });
808    }
809
810    let chosen = client
811        .show_message_request(msg_type, message, Some(actions))
812        .await;
813
814    match chosen {
815        Ok(Some(ref action)) if action.title == "Run Again" => {
816            // Re-run once; result shown as a plain message to avoid infinite recursion.
817            let output2 = tokio::process::Command::new("vendor/bin/phpunit")
818                .arg("--filter")
819                .arg(filter)
820                .current_dir(root.unwrap_or(std::path::Path::new(".")))
821                .output()
822                .await;
823            let msg2 = match output2 {
824                Ok(out) => {
825                    let text = String::from_utf8_lossy(&out.stdout).into_owned()
826                        + &String::from_utf8_lossy(&out.stderr);
827                    let last_line = text
828                        .lines()
829                        .rev()
830                        .find(|l| !l.trim().is_empty())
831                        .unwrap_or("(no output)")
832                        .to_string();
833                    if out.status.success() {
834                        format!("✓ {filter}: {last_line}")
835                    } else {
836                        format!("✗ {filter}: {last_line}")
837                    }
838                }
839                Err(e) => format!("php-lsp.runTest: failed to spawn phpunit — {e}"),
840            };
841            client.show_message(MessageType::INFO, msg2).await;
842        }
843        Ok(Some(ref action)) if action.title == "Open File" => {
844            if let Some(uri) = file_uri {
845                client
846                    .show_document(ShowDocumentParams {
847                        uri: uri.clone(),
848                        external: Some(false),
849                        take_focus: Some(true),
850                        selection: None,
851                    })
852                    .await
853                    .ok();
854            }
855        }
856        _ => {}
857    }
858}