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