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