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::lsp_types::{CodeLens, Command, Location, Url};
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: &Url,
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 lenses = Vec::new();
52    collect_lenses(
53        &doc.program().stmts,
54        sv,
55        "",
56        &env,
57        &mut lenses,
58        &should_cancel,
59    );
60    lenses
61}
62
63/// Shared lookup context for one code-lens request.
64struct LensEnv<'a> {
65    uri: &'a Url,
66    doc: &'a ParsedDoc,
67    store: &'a DocumentStore,
68    imports: &'a HashMap<String, String>,
69    cancel_rev: Option<u64>,
70}
71
72impl LensEnv<'_> {
73    fn reference_locations(&self, symbol: &mir_analyzer::Name, word: &str) -> Vec<Location> {
74        let files = self.store.reference_candidate_files(symbol, word);
75        let mut locations: Vec<Location> = self
76            .store
77            .indexed_references(symbol, &files, false, self.cancel_rev)
78            .into_iter()
79            .filter_map(session_tuple_to_location)
80            .collect();
81        dedup_ref_locations(&mut locations);
82        locations
83    }
84
85    fn ref_count_lens(
86        &self,
87        range: tower_lsp::lsp_types::Range,
88        symbol: mir_analyzer::Name,
89        word: &str,
90    ) -> CodeLens {
91        let locations = self.reference_locations(&symbol, word);
92        let label = match locations.len() {
93            0 => "0 references".to_string(),
94            1 => "1 reference".to_string(),
95            n => format!("{n} references"),
96        };
97        lens(range, self.uri, label, locations)
98    }
99
100    fn impl_count_lens(
101        &self,
102        range: tower_lsp::lsp_types::Range,
103        fqn: &str,
104        include_trait_users: bool,
105    ) -> CodeLens {
106        let locations: Vec<Location> = self
107            .store
108            .indexed_subtype_classes(fqn, include_trait_users)
109            .into_iter()
110            .filter_map(|site| subtype_site_to_location(&site.file, &site.range))
111            .collect();
112        let label = match locations.len() {
113            0 => "0 implementations".to_string(),
114            1 => "1 implementation".to_string(),
115            n => format!("{n} implementations"),
116        };
117        lens(range, self.uri, label, locations)
118    }
119
120    /// Declaration location of `method` on the class/trait `parent_fqn`,
121    /// from the salsa workspace index. Prefers the exact-FQN class entry;
122    /// falls back to any same-short-name class declaring the method.
123    fn parent_method_location(&self, parent_fqn: &str, method: &str) -> Option<Location> {
124        let ws = self.store.get_workspace_index_salsa();
125        let parent_fqn = parent_fqn.trim_start_matches('\\');
126        let candidates = ws.classes_by_name.get(fqn_short_name(parent_fqn))?;
127        let method_loc = |r: &crate::db::workspace_index::ClassRef| {
128            let (uri, cls) = ws.at(*r)?;
129            let m = cls.methods.iter().find(|m| m.name.as_ref() == method)?;
130            let start = tower_lsp::lsp_types::Position {
131                line: m.start_line,
132                character: m.name_char,
133            };
134            let end = tower_lsp::lsp_types::Position {
135                line: m.start_line,
136                character: m.name_char + m.name.encode_utf16().count() as u32,
137            };
138            Some((
139                cls.fqn.trim_start_matches('\\').to_string(),
140                Location {
141                    uri: uri.clone(),
142                    range: tower_lsp::lsp_types::Range { start, end },
143                },
144            ))
145        };
146        let mut fallback = None;
147        for r in candidates {
148            if let Some((fqn, loc)) = method_loc(r) {
149                if fqn == parent_fqn {
150                    return Some(loc);
151                }
152                fallback.get_or_insert(loc);
153            }
154        }
155        fallback
156    }
157}
158
159fn collect_lenses(
160    stmts: &[Stmt<'_, '_>],
161    sv: SourceView<'_>,
162    enclosing_ns: &str,
163    env: &LensEnv<'_>,
164    out: &mut Vec<CodeLens>,
165    should_cancel: &impl Fn() -> bool,
166) {
167    // `namespace App;` (unbraced) applies to every following statement.
168    let mut ns: String = enclosing_ns.to_string();
169    for stmt in stmts {
170        if should_cancel() {
171            out.clear();
172            return;
173        }
174        match &stmt.kind {
175            StmtKind::Function(f) => {
176                let name = f.name.as_str().unwrap_or_default();
177                let range = sv.name_range(name);
178                out.push(env.ref_count_lens(
179                    range,
180                    mir_analyzer::Name::function(fqn_in(&ns, name)),
181                    name,
182                ));
183            }
184            StmtKind::Class(c) => {
185                if let Some(class_name) = c.name {
186                    let class_name_str = class_name.as_str().unwrap_or_default();
187                    let class_fqn = fqn_in(&ns, class_name_str);
188                    let class_range = sv.name_range(class_name_str);
189                    out.push(env.ref_count_lens(
190                        class_range,
191                        mir_analyzer::Name::class(class_fqn.clone()),
192                        class_name_str,
193                    ));
194
195                    // Implementations count for abstract classes (classes extending this).
196                    if c.modifiers.is_abstract {
197                        out.push(env.impl_count_lens(class_range, &class_fqn, false));
198                    }
199
200                    // Direct supertypes — extends parent + used traits — checked once
201                    // per class for overrides lookups on each method.
202                    let parents = collect_direct_supertypes(c, env.doc, env.imports);
203
204                    for member in c.body.members.iter() {
205                        match &member.kind {
206                            ClassMemberKind::Method(m) => {
207                                let method_name = m.name.as_str().unwrap_or_default();
208                                let method_range = sv.name_range(method_name);
209                                out.push(env.ref_count_lens(
210                                    method_range,
211                                    mir_analyzer::Name::method(class_fqn.as_str(), method_name),
212                                    method_name,
213                                ));
214
215                                if is_test_method(sv.source(), m) {
216                                    out.push(run_test_lens(
217                                        method_range,
218                                        env.uri,
219                                        class_name_str,
220                                        method_name,
221                                    ));
222                                }
223
224                                // Overrides lens: emit for each direct supertype (parent class
225                                // or used trait) that declares a method with the same name.
226                                for parent_fqn in &parents {
227                                    if let Some(parent_loc) =
228                                        env.parent_method_location(parent_fqn, method_name)
229                                    {
230                                        out.push(overrides_lens(
231                                            method_range,
232                                            env.uri,
233                                            fqn_short_name(parent_fqn),
234                                            method_name,
235                                            parent_loc,
236                                        ));
237                                    }
238                                }
239
240                                // Constructor-promoted params: `public function __construct(public string $name)`.
241                                if m.name == "__construct" {
242                                    for p in m.params.iter() {
243                                        if p.visibility.is_some() {
244                                            let param_name = p.name.as_str().unwrap_or_default();
245                                            let prop_range = sv.name_range(param_name);
246                                            out.push(env.ref_count_lens(
247                                                prop_range,
248                                                property_name(&class_fqn, param_name),
249                                                param_name,
250                                            ));
251                                        }
252                                    }
253                                }
254                            }
255                            ClassMemberKind::Property(p) => {
256                                let prop_name = p.name.as_str().unwrap_or_default();
257                                let prop_range = sv.name_range(prop_name);
258                                out.push(env.ref_count_lens(
259                                    prop_range,
260                                    property_name(&class_fqn, prop_name),
261                                    prop_name,
262                                ));
263                            }
264                            _ => {}
265                        }
266                    }
267                }
268            }
269            StmtKind::Interface(i) => {
270                let name = i.name.as_str().unwrap_or_default();
271                let fqn = fqn_in(&ns, name);
272                let range = sv.name_range(name);
273                out.push(env.ref_count_lens(range, mir_analyzer::Name::class(fqn.clone()), name));
274                // Implementations count lens.
275                out.push(env.impl_count_lens(range, &fqn, false));
276            }
277            StmtKind::Trait(t) => {
278                let trait_name = t.name.as_str().unwrap_or_default();
279                let trait_fqn = fqn_in(&ns, trait_name);
280                let range = sv.name_range(trait_name);
281                out.push(env.ref_count_lens(
282                    range,
283                    mir_analyzer::Name::class(trait_fqn.clone()),
284                    trait_name,
285                ));
286                // Usages: classes that `use` this trait (trait edges included).
287                out.push(env.impl_count_lens(range, &trait_fqn, true));
288                for member in t.body.members.iter() {
289                    match &member.kind {
290                        ClassMemberKind::Method(m) => {
291                            let method_name = m.name.as_str().unwrap_or_default();
292                            let method_range = sv.name_range(method_name);
293                            out.push(env.ref_count_lens(
294                                method_range,
295                                mir_analyzer::Name::method(trait_fqn.as_str(), method_name),
296                                method_name,
297                            ));
298                        }
299                        ClassMemberKind::Property(p) => {
300                            let prop_name = p.name.as_str().unwrap_or_default();
301                            let prop_range = sv.name_range(prop_name);
302                            out.push(env.ref_count_lens(
303                                prop_range,
304                                property_name(&trait_fqn, prop_name),
305                                prop_name,
306                            ));
307                        }
308                        _ => {}
309                    }
310                }
311            }
312            StmtKind::Enum(e) => {
313                let enum_name = e.name.as_str().unwrap_or_default();
314                let enum_fqn = fqn_in(&ns, enum_name);
315                let range = sv.name_range(enum_name);
316                out.push(env.ref_count_lens(
317                    range,
318                    mir_analyzer::Name::class(enum_fqn.clone()),
319                    enum_name,
320                ));
321                for member in e.body.members.iter() {
322                    match &member.kind {
323                        EnumMemberKind::Method(m) => {
324                            let method_name = m.name.as_str().unwrap_or_default();
325                            let method_range = sv.name_range(method_name);
326                            out.push(env.ref_count_lens(
327                                method_range,
328                                mir_analyzer::Name::method(enum_fqn.as_str(), method_name),
329                                method_name,
330                            ));
331                        }
332                        EnumMemberKind::Case(c) => {
333                            let case_name = c.name.as_str().unwrap_or_default();
334                            let case_range = sv.name_range(case_name);
335                            out.push(env.ref_count_lens(
336                                case_range,
337                                mir_analyzer::Name::class_constant(enum_fqn.as_str(), case_name),
338                                case_name,
339                            ));
340                        }
341                        _ => {}
342                    }
343                }
344            }
345            StmtKind::Namespace(nsd) => {
346                let ns_name = nsd
347                    .name
348                    .as_ref()
349                    .map(|n| n.to_string_repr().into_owned())
350                    .unwrap_or_default();
351                match &nsd.body {
352                    NamespaceBody::Braced(inner) => {
353                        collect_lenses(&inner.stmts, sv, &ns_name, env, out, should_cancel);
354                    }
355                    NamespaceBody::Simple => {
356                        ns = ns_name;
357                    }
358                }
359            }
360            _ => {}
361        }
362    }
363}
364
365fn fqn_in(ns: &str, name: &str) -> String {
366    if ns.is_empty() {
367        name.to_string()
368    } else {
369        format!("{ns}\\{name}")
370    }
371}
372
373fn property_name(class_fqn: &str, prop: &str) -> mir_analyzer::Name {
374    mir_analyzer::Name::property(class_fqn, prop)
375}
376
377fn subtype_site_to_location(file: &str, range: &mir_analyzer::Range) -> Option<Location> {
378    let uri = Url::parse(file).ok()?;
379    // mir uses 1-based lines; 0-based columns.
380    let line = range.start.line.saturating_sub(1);
381    Some(Location {
382        uri,
383        range: tower_lsp::lsp_types::Range {
384            start: tower_lsp::lsp_types::Position {
385                line,
386                character: range.start.column,
387            },
388            end: tower_lsp::lsp_types::Position {
389                line,
390                character: range.end.column,
391            },
392        },
393    })
394}
395
396fn lens(
397    range: tower_lsp::lsp_types::Range,
398    uri: &Url,
399    title: String,
400    locations: Vec<Location>,
401) -> CodeLens {
402    CodeLens {
403        range,
404        command: Some(Command {
405            title,
406            command: "editor.action.showReferences".to_string(),
407            arguments: Some(vec![json!(uri), json!(range.start), json!(locations)]),
408        }),
409        data: None,
410    }
411}
412
413fn overrides_lens(
414    range: tower_lsp::lsp_types::Range,
415    uri: &Url,
416    parent_class: &str,
417    method_name: &str,
418    parent_location: Location,
419) -> CodeLens {
420    CodeLens {
421        range,
422        command: Some(Command {
423            title: format!("overrides {}::{}", parent_class, method_name),
424            command: "editor.action.showReferences".to_string(),
425            arguments: Some(vec![
426                json!(uri),
427                json!(range.start),
428                json!(vec![parent_location]),
429            ]),
430        }),
431        data: None,
432    }
433}
434
435fn run_test_lens(
436    range: tower_lsp::lsp_types::Range,
437    uri: &Url,
438    class: &str,
439    method: &str,
440) -> CodeLens {
441    CodeLens {
442        range,
443        command: Some(Command {
444            title: "▶ Run test".to_string(),
445            command: "php-lsp.runTest".to_string(),
446            arguments: Some(vec![
447                serde_json::json!(uri.to_string()),
448                serde_json::json!(format!("{class}::{method}")),
449            ]),
450        }),
451        data: None,
452    }
453}
454
455/// Direct supertypes of `c` as FQNs — the extended parent class plus every
456/// trait listed in `use` clauses, resolved through this file's imports and
457/// namespace. Order is stable: extends first, then traits in source order.
458/// Duplicates are removed.
459fn collect_direct_supertypes(
460    c: &php_ast::ClassDecl<'_, '_>,
461    doc: &ParsedDoc,
462    imports: &HashMap<String, String>,
463) -> Vec<String> {
464    let mut out: Vec<String> = Vec::new();
465    let mut resolve = |name: std::borrow::Cow<'_, str>| {
466        let fqn = resolve_fqn(doc, &name, imports)
467            .trim_start_matches('\\')
468            .to_string();
469        if !out.contains(&fqn) {
470            out.push(fqn);
471        }
472    };
473    if let Some(extends) = &c.extends {
474        resolve(extends.to_string_repr());
475    }
476    for member in c.body.members.iter() {
477        if let ClassMemberKind::TraitUse(t) = &member.kind {
478            for name in t.traits.iter() {
479                resolve(name.to_string_repr());
480            }
481        }
482    }
483    out
484}
485
486/// A method is a test if its name starts with `test` (PHPUnit convention),
487/// if its leading docblock contains `@test`, or if it carries a `#[Test]`
488/// or `#[PHPUnit\Framework\Attributes\Test]` PHP attribute.
489fn is_test_method(source: &str, m: &php_ast::MethodDecl<'_, '_>) -> bool {
490    if m.name
491        .as_str()
492        .map(|s| s.starts_with("test"))
493        .unwrap_or(false)
494    {
495        return true;
496    }
497    let has_test_attr = m.attributes.iter().any(|attr| {
498        let span = attr.name.span();
499        let attr_name = source
500            .get(span.start as usize..span.end as usize)
501            .unwrap_or("");
502        attr_name == "Test" || attr_name.ends_with("\\Test")
503    });
504    if has_test_attr {
505        return true;
506    }
507    m.doc_comment
508        .as_ref()
509        .is_some_and(|c| c.text.contains("@test"))
510}
511
512#[cfg(test)]
513mod tests {
514    use super::*;
515
516    /// `should_cancel = || true` must return an empty Vec immediately without
517    /// panicking, regardless of the document contents.
518    #[test]
519    fn code_lenses_cancelled_returns_empty() {
520        use crate::document::ast::ParsedDoc;
521
522        let source = "<?php\nclass Foo { public function bar(): void {} }\n";
523        let doc = std::sync::Arc::new(ParsedDoc::parse(source));
524        let uri = tower_lsp::lsp_types::Url::parse("file:///test.php").unwrap();
525        let store = DocumentStore::new();
526
527        let lenses = code_lenses(&uri, &doc, &store, &HashMap::new(), None, || true);
528        assert!(lenses.is_empty(), "cancelled sweep must return empty");
529    }
530}