Skip to main content

php_lsp/analysis/
code_lens.rs

1/// `textDocument/codeLens` — inline actionable annotations above declarations.
2///
3/// Four lens types are emitted:
4///   1. **Reference count** — above every function, class, and method declaration.
5///   2. **Run test** — above PHPUnit test methods (methods whose name starts with
6///      `test` or that carry a `/** @test */` docblock).
7///   3. **N implementations** — above abstract classes, interfaces, and traits,
8///      counting classes that extend/implement/use them.
9///   4. **overrides ClassName::method** — above methods that override a parent
10///      class method of the same name.
11///
12/// Counts and locations come from mir's inverted indexes (reference posting
13/// lists and subtype edges) plus the salsa workspace index — never from a
14/// per-request AST walk over the workspace.
15use std::collections::HashMap;
16
17use php_ast::{ClassMemberKind, EnumMemberKind, NamespaceBody, Stmt, StmtKind};
18use serde_json::json;
19use tower_lsp_server::ls_types::{CodeLens, Command, Location, Uri};
20
21use crate::document::ast::{ParsedDoc, SourceView};
22use crate::document::document_store::DocumentStore;
23use crate::navigation::moniker::resolve_fqn;
24use crate::navigation::references::{dedup_ref_locations, session_tuple_to_location};
25use crate::text::fqn_short_name;
26
27/// Build all code lenses for `uri`/`doc`, answering counts from the store's
28/// mir-backed indexes.
29///
30/// `cancel_rev` is threaded into mir's index queries so a concurrent edit
31/// aborts them at phase boundaries. `should_cancel` is polled between
32/// top-level declarations; when it returns `true` the function returns an
33/// empty `Vec` immediately. Pass `|| false` for requests that do not need
34/// cooperative cancellation.
35pub fn code_lenses(
36    uri: &Uri,
37    doc: &ParsedDoc,
38    store: &DocumentStore,
39    imports: &HashMap<String, String>,
40    cancel_rev: Option<u64>,
41    should_cancel: impl Fn() -> bool,
42) -> Vec<CodeLens> {
43    let env = LensEnv {
44        uri,
45        doc,
46        store,
47        imports,
48        cancel_rev,
49    };
50    let sv = doc.view();
51    let mut slots = Vec::new();
52    collect_lenses(
53        &doc.program().stmts,
54        sv,
55        "",
56        &env,
57        &mut slots,
58        &should_cancel,
59    );
60    if should_cancel() {
61        return Vec::new();
62    }
63
64    // Every `RefCount` slot's candidate scope is resolved together in one
65    // workspace pass (see `DocumentStore::batch_reference_candidate_files`)
66    // instead of one pass per declaration — a class with many methods/
67    // properties would otherwise re-scan the whole workspace once per
68    // member just to compute this request's reference counts.
69    let symbols: Vec<mir_analyzer::Name> = slots
70        .iter()
71        .filter_map(|slot| match slot {
72            LensSlot::RefCount { symbol, .. } => Some(symbol.clone()),
73            LensSlot::Ready(_) => None,
74        })
75        .collect();
76    let mut scopes = env
77        .store
78        .batch_reference_candidate_files(&symbols)
79        .into_iter();
80
81    let mut lenses = Vec::with_capacity(slots.len());
82    for slot in slots {
83        if should_cancel() {
84            return Vec::new();
85        }
86        lenses.push(match slot {
87            LensSlot::Ready(lens) => lens,
88            LensSlot::RefCount { range, symbol } => {
89                let files = scopes.next().expect("one scope per RefCount slot");
90                env.ref_count_lens(range, symbol, &files)
91            }
92        });
93    }
94    lenses
95}
96
97/// Shared lookup context for one code-lens request.
98struct LensEnv<'a> {
99    uri: &'a Uri,
100    doc: &'a ParsedDoc,
101    store: &'a DocumentStore,
102    imports: &'a HashMap<String, String>,
103    cancel_rev: Option<u64>,
104}
105
106/// A lens that still needs its reference count resolved, or one that's
107/// already fully built. Deferring `RefCount` lets [`code_lenses`] batch
108/// every declaration's scope narrowing into one workspace pass after the
109/// AST walk finishes, instead of narrowing per declaration during the walk.
110enum LensSlot {
111    Ready(CodeLens),
112    RefCount {
113        range: tower_lsp_server::ls_types::Range,
114        symbol: mir_analyzer::Name,
115    },
116}
117
118impl LensEnv<'_> {
119    fn ref_count_lens(
120        &self,
121        range: tower_lsp_server::ls_types::Range,
122        symbol: mir_analyzer::Name,
123        files: &[std::sync::Arc<str>],
124    ) -> CodeLens {
125        let mut locations: Vec<Location> = self
126            .store
127            .indexed_references(&symbol, files, false, self.cancel_rev)
128            .into_iter()
129            .filter_map(session_tuple_to_location)
130            .collect();
131        dedup_ref_locations(&mut locations);
132        let label = match locations.len() {
133            0 => "0 references".to_string(),
134            1 => "1 reference".to_string(),
135            n => format!("{n} references"),
136        };
137        lens(range, self.uri, label, locations)
138    }
139
140    fn impl_count_lens(
141        &self,
142        range: tower_lsp_server::ls_types::Range,
143        fqn: &str,
144        include_trait_users: bool,
145    ) -> CodeLens {
146        let locations: Vec<Location> = self
147            .store
148            .indexed_subtype_classes(fqn, include_trait_users)
149            .into_iter()
150            .filter_map(|site| subtype_site_to_location(&site.file, &site.range))
151            .collect();
152        let label = match locations.len() {
153            0 => "0 implementations".to_string(),
154            1 => "1 implementation".to_string(),
155            n => format!("{n} implementations"),
156        };
157        lens(range, self.uri, label, locations)
158    }
159
160    /// Declaration location of `method` on the class/trait `parent_fqn`,
161    /// from the salsa workspace index. Prefers the exact-FQN class entry;
162    /// falls back to any same-short-name class declaring the method.
163    /// Walks the full `extends` ancestor chain starting at `parent_fqn` (not
164    /// just the direct parent), so a method inherited unchanged from a
165    /// grandparent (or further) still gets an "overrides" lens — a class two
166    /// or more levels below the declaring ancestor previously got none at all.
167    /// Returns the FQN of the ancestor that actually declares the method
168    /// (which may differ from `parent_fqn` itself) alongside its location.
169    fn parent_method_location(&self, parent_fqn: &str, method: &str) -> Option<(String, Location)> {
170        let ws = self.store.get_workspace_index_salsa();
171        let mut current = parent_fqn.trim_start_matches('\\').to_string();
172        let mut seen = std::collections::HashSet::new();
173        loop {
174            if !seen.insert(current.clone()) {
175                return None;
176            }
177            let candidates = ws.classes_by_name.get(fqn_short_name(&current))?;
178            let class_loc = |r: &crate::db::workspace_index::ClassRef| {
179                let (uri, cls) = ws.at(*r)?;
180                Some((uri, cls))
181            };
182            let mut fallback = None;
183            let mut chosen = None;
184            for r in candidates {
185                if let Some((uri, cls)) = class_loc(r) {
186                    if cls.fqn.trim_start_matches('\\') == current {
187                        chosen = Some((uri, cls));
188                        break;
189                    }
190                    fallback.get_or_insert((uri, cls));
191                }
192            }
193            let (uri, cls) = chosen.or(fallback)?;
194            let declaring_fqn = cls.fqn.trim_start_matches('\\').to_string();
195            if let Some(m) = cls.methods.iter().find(|m| m.name.as_ref() == method) {
196                let start = tower_lsp_server::ls_types::Position {
197                    line: m.start_line,
198                    character: m.name_char,
199                };
200                let end = tower_lsp_server::ls_types::Position {
201                    line: m.start_line,
202                    character: m.name_char + m.name.encode_utf16().count() as u32,
203                };
204                return Some((
205                    declaring_fqn,
206                    Location {
207                        uri: uri.clone(),
208                        range: tower_lsp_server::ls_types::Range { start, end },
209                    },
210                ));
211            }
212            let parent = cls.parent.as_deref()?;
213            current = parent.trim_start_matches('\\').to_string();
214        }
215    }
216}
217
218fn collect_lenses(
219    stmts: &[Stmt<'_, '_>],
220    sv: SourceView<'_>,
221    enclosing_ns: &str,
222    env: &LensEnv<'_>,
223    out: &mut Vec<LensSlot>,
224    should_cancel: &impl Fn() -> bool,
225) {
226    // `namespace App;` (unbraced) applies to every following statement.
227    let mut ns: String = enclosing_ns.to_string();
228    for stmt in stmts {
229        if should_cancel() {
230            out.clear();
231            return;
232        }
233        match &stmt.kind {
234            StmtKind::Function(f) => {
235                let name = f.name.as_str().unwrap_or_default();
236                let range = sv.name_range(name);
237                out.push(LensSlot::RefCount {
238                    range,
239                    symbol: mir_analyzer::Name::function(fqn_in(&ns, name)),
240                });
241            }
242            StmtKind::Class(c) => {
243                if let Some(class_name) = c.name {
244                    let class_name_str = class_name.as_str().unwrap_or_default();
245                    let class_fqn = fqn_in(&ns, class_name_str);
246                    let class_range = sv.name_range(class_name_str);
247                    out.push(LensSlot::RefCount {
248                        range: class_range,
249                        symbol: mir_analyzer::Name::class(class_fqn.clone()),
250                    });
251
252                    // Implementations count for abstract classes (classes extending this).
253                    if c.modifiers.is_abstract {
254                        out.push(LensSlot::Ready(env.impl_count_lens(
255                            class_range,
256                            &class_fqn,
257                            false,
258                        )));
259                    }
260
261                    // Direct supertypes — extends parent + used traits — checked once
262                    // per class for overrides lookups on each method.
263                    let parents = collect_direct_supertypes(c, env.doc, env.imports);
264
265                    for member in c.body.members.iter() {
266                        match &member.kind {
267                            ClassMemberKind::Method(m) => {
268                                let method_name = m.name.as_str().unwrap_or_default();
269                                let method_range = sv.name_range(method_name);
270                                out.push(LensSlot::RefCount {
271                                    range: method_range,
272                                    symbol: mir_analyzer::Name::method(
273                                        class_fqn.as_str(),
274                                        method_name,
275                                    ),
276                                });
277
278                                if is_test_method(sv.source(), m) {
279                                    out.push(LensSlot::Ready(run_test_lens(
280                                        method_range,
281                                        env.uri,
282                                        class_name_str,
283                                        method_name,
284                                    )));
285                                }
286
287                                // Overrides lens: emit for each direct supertype (parent class
288                                // or used trait) that declares a method with the same name.
289                                for parent_fqn in &parents {
290                                    if let Some((declaring_fqn, parent_loc)) =
291                                        env.parent_method_location(parent_fqn, method_name)
292                                    {
293                                        out.push(LensSlot::Ready(overrides_lens(
294                                            method_range,
295                                            env.uri,
296                                            fqn_short_name(&declaring_fqn),
297                                            method_name,
298                                            parent_loc,
299                                        )));
300                                    }
301                                }
302
303                                // Constructor-promoted params: `public function __construct(public string $name)`.
304                                if m.name == "__construct" {
305                                    for p in m.params.iter() {
306                                        if p.visibility.is_some() {
307                                            let param_name = p.name.as_str().unwrap_or_default();
308                                            let prop_range = sv.name_range(param_name);
309                                            out.push(LensSlot::RefCount {
310                                                range: prop_range,
311                                                symbol: property_name(&class_fqn, param_name),
312                                            });
313                                        }
314                                    }
315                                }
316                            }
317                            ClassMemberKind::Property(p) => {
318                                let prop_name = p.name.as_str().unwrap_or_default();
319                                let prop_range = sv.name_range(prop_name);
320                                out.push(LensSlot::RefCount {
321                                    range: prop_range,
322                                    symbol: property_name(&class_fqn, prop_name),
323                                });
324                            }
325                            _ => {}
326                        }
327                    }
328                }
329            }
330            StmtKind::Interface(i) => {
331                let name = i.name.as_str().unwrap_or_default();
332                let fqn = fqn_in(&ns, name);
333                let range = sv.name_range(name);
334                out.push(LensSlot::RefCount {
335                    range,
336                    symbol: mir_analyzer::Name::class(fqn.clone()),
337                });
338                // Implementations count lens.
339                out.push(LensSlot::Ready(env.impl_count_lens(range, &fqn, false)));
340            }
341            StmtKind::Trait(t) => {
342                let trait_name = t.name.as_str().unwrap_or_default();
343                let trait_fqn = fqn_in(&ns, trait_name);
344                let range = sv.name_range(trait_name);
345                out.push(LensSlot::RefCount {
346                    range,
347                    symbol: mir_analyzer::Name::class(trait_fqn.clone()),
348                });
349                // Usages: classes that `use` this trait (trait edges included).
350                out.push(LensSlot::Ready(
351                    env.impl_count_lens(range, &trait_fqn, true),
352                ));
353                for member in t.body.members.iter() {
354                    match &member.kind {
355                        ClassMemberKind::Method(m) => {
356                            let method_name = m.name.as_str().unwrap_or_default();
357                            let method_range = sv.name_range(method_name);
358                            out.push(LensSlot::RefCount {
359                                range: method_range,
360                                symbol: mir_analyzer::Name::method(trait_fqn.as_str(), method_name),
361                            });
362                        }
363                        ClassMemberKind::Property(p) => {
364                            let prop_name = p.name.as_str().unwrap_or_default();
365                            let prop_range = sv.name_range(prop_name);
366                            out.push(LensSlot::RefCount {
367                                range: prop_range,
368                                symbol: property_name(&trait_fqn, prop_name),
369                            });
370                        }
371                        _ => {}
372                    }
373                }
374            }
375            StmtKind::Enum(e) => {
376                let enum_name = e.name.as_str().unwrap_or_default();
377                let enum_fqn = fqn_in(&ns, enum_name);
378                let range = sv.name_range(enum_name);
379                out.push(LensSlot::RefCount {
380                    range,
381                    symbol: mir_analyzer::Name::class(enum_fqn.clone()),
382                });
383                for member in e.body.members.iter() {
384                    match &member.kind {
385                        EnumMemberKind::Method(m) => {
386                            let method_name = m.name.as_str().unwrap_or_default();
387                            let method_range = sv.name_range(method_name);
388                            out.push(LensSlot::RefCount {
389                                range: method_range,
390                                symbol: mir_analyzer::Name::method(enum_fqn.as_str(), method_name),
391                            });
392                        }
393                        EnumMemberKind::Case(c) => {
394                            let case_name = c.name.as_str().unwrap_or_default();
395                            let case_range = sv.name_range(case_name);
396                            out.push(LensSlot::RefCount {
397                                range: case_range,
398                                symbol: mir_analyzer::Name::class_constant(
399                                    enum_fqn.as_str(),
400                                    case_name,
401                                ),
402                            });
403                        }
404                        _ => {}
405                    }
406                }
407            }
408            StmtKind::Namespace(nsd) => {
409                let ns_name = nsd
410                    .name
411                    .as_ref()
412                    .map(|n| n.to_string_repr().into_owned())
413                    .unwrap_or_default();
414                match &nsd.body {
415                    NamespaceBody::Braced(inner) => {
416                        collect_lenses(&inner.stmts, sv, &ns_name, env, out, should_cancel);
417                    }
418                    NamespaceBody::Simple => {
419                        ns = ns_name;
420                    }
421                }
422            }
423            _ => {}
424        }
425    }
426}
427
428fn fqn_in(ns: &str, name: &str) -> String {
429    if ns.is_empty() {
430        name.to_string()
431    } else {
432        format!("{ns}\\{name}")
433    }
434}
435
436fn property_name(class_fqn: &str, prop: &str) -> mir_analyzer::Name {
437    mir_analyzer::Name::property(class_fqn, prop)
438}
439
440fn subtype_site_to_location(file: &str, range: &mir_analyzer::Range) -> Option<Location> {
441    let uri = (file).parse::<Uri>().ok()?;
442    // mir uses 1-based lines; 0-based columns.
443    let line = range.start.line.saturating_sub(1);
444    Some(Location {
445        uri,
446        range: tower_lsp_server::ls_types::Range {
447            start: tower_lsp_server::ls_types::Position {
448                line,
449                character: range.start.column,
450            },
451            end: tower_lsp_server::ls_types::Position {
452                line,
453                character: range.end.column,
454            },
455        },
456    })
457}
458
459fn lens(
460    range: tower_lsp_server::ls_types::Range,
461    uri: &Uri,
462    title: String,
463    locations: Vec<Location>,
464) -> CodeLens {
465    CodeLens {
466        range,
467        command: Some(Command {
468            title,
469            command: "editor.action.showReferences".to_string(),
470            arguments: Some(vec![json!(uri), json!(range.start), json!(locations)]),
471        }),
472        data: None,
473    }
474}
475
476fn overrides_lens(
477    range: tower_lsp_server::ls_types::Range,
478    uri: &Uri,
479    parent_class: &str,
480    method_name: &str,
481    parent_location: Location,
482) -> CodeLens {
483    CodeLens {
484        range,
485        command: Some(Command {
486            title: format!("overrides {}::{}", parent_class, method_name),
487            command: "editor.action.showReferences".to_string(),
488            arguments: Some(vec![
489                json!(uri),
490                json!(range.start),
491                json!(vec![parent_location]),
492            ]),
493        }),
494        data: None,
495    }
496}
497
498fn run_test_lens(
499    range: tower_lsp_server::ls_types::Range,
500    uri: &Uri,
501    class: &str,
502    method: &str,
503) -> CodeLens {
504    CodeLens {
505        range,
506        command: Some(Command {
507            title: "▶ Run test".to_string(),
508            command: "php-lsp.runTest".to_string(),
509            arguments: Some(vec![
510                serde_json::json!(uri.to_string()),
511                serde_json::json!(format!("{class}::{method}")),
512            ]),
513        }),
514        data: None,
515    }
516}
517
518/// Direct supertypes of `c` as FQNs — the extended parent class plus every
519/// trait listed in `use` clauses, resolved through this file's imports and
520/// namespace. Order is stable: extends first, then traits in source order.
521/// Duplicates are removed.
522fn collect_direct_supertypes(
523    c: &php_ast::ClassDecl<'_, '_>,
524    doc: &ParsedDoc,
525    imports: &HashMap<String, String>,
526) -> Vec<String> {
527    let mut out: Vec<String> = Vec::new();
528    let mut resolve = |name: std::borrow::Cow<'_, str>| {
529        let fqn = resolve_fqn(doc, &name, imports)
530            .trim_start_matches('\\')
531            .to_string();
532        if !out.contains(&fqn) {
533            out.push(fqn);
534        }
535    };
536    if let Some(extends) = &c.extends {
537        resolve(extends.to_string_repr());
538    }
539    for member in c.body.members.iter() {
540        if let ClassMemberKind::TraitUse(t) = &member.kind {
541            for name in t.traits.iter() {
542                resolve(name.to_string_repr());
543            }
544        }
545    }
546    out
547}
548
549/// A method is a test if its name starts with `test` (PHPUnit convention),
550/// if its leading docblock contains `@test`, or if it carries a `#[Test]`
551/// or `#[PHPUnit\Framework\Attributes\Test]` PHP attribute.
552fn is_test_method(source: &str, m: &php_ast::MethodDecl<'_, '_>) -> bool {
553    if m.name
554        .as_str()
555        .map(|s| s.starts_with("test"))
556        .unwrap_or(false)
557    {
558        return true;
559    }
560    let has_test_attr = m.attributes.iter().any(|attr| {
561        let span = attr.name.span();
562        let attr_name = source
563            .get(span.start as usize..span.end as usize)
564            .unwrap_or("");
565        attr_name == "Test" || attr_name.ends_with("\\Test")
566    });
567    if has_test_attr {
568        return true;
569    }
570    m.doc_comment
571        .as_ref()
572        .is_some_and(|c| c.text.contains("@test"))
573}
574
575#[cfg(test)]
576mod tests {
577    use super::*;
578
579    /// `should_cancel = || true` must return an empty Vec immediately without
580    /// panicking, regardless of the document contents.
581    #[test]
582    fn code_lenses_cancelled_returns_empty() {
583        use crate::document::ast::ParsedDoc;
584
585        let source = "<?php\nclass Foo { public function bar(): void {} }\n";
586        let doc = std::sync::Arc::new(ParsedDoc::parse(source));
587        let uri = "file:///test.php".parse::<Uri>().unwrap();
588        let store = DocumentStore::new();
589
590        let lenses = code_lenses(&uri, &doc, &store, &HashMap::new(), None, || true);
591        assert!(lenses.is_empty(), "cancelled sweep must return empty");
592    }
593}