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.
11use std::sync::Arc;
12
13use php_ast::{ClassMemberKind, EnumMemberKind, NamespaceBody, Stmt, StmtKind};
14use serde_json::json;
15use tower_lsp::lsp_types::{CodeLens, Command, Url};
16
17use crate::document::ast::{ParsedDoc, SourceView};
18use crate::navigation::implementation::find_implementations;
19use crate::navigation::references::{SymbolKind, find_references};
20use crate::types::type_map::parent_class_name;
21
22/// Build all code lenses for `uri`/`doc`, using `all_docs` for reference counts.
23///
24/// `should_cancel` is polled between top-level declarations; when it returns
25/// `true` the function returns an empty `Vec` immediately. Pass `|| false` for
26/// requests that do not need cooperative cancellation.
27pub fn code_lenses(
28    uri: &Url,
29    doc: &ParsedDoc,
30    all_docs: &[(Url, Arc<ParsedDoc>)],
31    should_cancel: impl Fn() -> bool,
32) -> Vec<CodeLens> {
33    let sv = doc.view();
34    let mut lenses = Vec::new();
35    collect_lenses(
36        &doc.program().stmts,
37        sv,
38        uri,
39        all_docs,
40        &mut lenses,
41        &should_cancel,
42    );
43    lenses
44}
45
46fn collect_lenses(
47    stmts: &[Stmt<'_, '_>],
48    sv: SourceView<'_>,
49    uri: &Url,
50    all_docs: &[(Url, Arc<ParsedDoc>)],
51    out: &mut Vec<CodeLens>,
52    should_cancel: &impl Fn() -> bool,
53) {
54    for stmt in stmts {
55        if should_cancel() {
56            out.clear();
57            return;
58        }
59        match &stmt.kind {
60            StmtKind::Function(f) => {
61                let name = f.name.as_str().unwrap_or_default();
62                let range = sv.name_range(name);
63                out.push(ref_count_lens(range, name, uri, all_docs, None));
64            }
65            StmtKind::Class(c) => {
66                if let Some(class_name) = c.name {
67                    let class_name_str = class_name.as_str().unwrap_or_default();
68                    let class_range = sv.name_range(class_name_str);
69                    out.push(ref_count_lens(
70                        class_range,
71                        class_name_str,
72                        uri,
73                        all_docs,
74                        None,
75                    ));
76
77                    // Implementations count for abstract classes (classes extending this).
78                    if c.modifiers.is_abstract {
79                        let impls = find_implementations(class_name_str, None, all_docs);
80                        out.push(impl_count_lens(class_range, uri, impls));
81                    }
82
83                    // Direct supertypes — extends parent + used traits — checked once
84                    // per class for overrides lookups on each method.
85                    let parents = collect_direct_supertypes(c, all_docs);
86
87                    for member in c.body.members.iter() {
88                        match &member.kind {
89                            ClassMemberKind::Method(m) => {
90                                let method_name = m.name.as_str().unwrap_or_default();
91                                let method_range = sv.name_range(method_name);
92                                out.push(ref_count_lens(
93                                    method_range,
94                                    method_name,
95                                    uri,
96                                    all_docs,
97                                    None,
98                                ));
99
100                                if is_test_method(sv.source(), m) {
101                                    out.push(run_test_lens(
102                                        method_range,
103                                        uri,
104                                        class_name_str,
105                                        method_name,
106                                    ));
107                                }
108
109                                // Overrides lens: emit for each direct supertype (parent class
110                                // or used trait) that declares a method with the same name.
111                                for parent_name in &parents {
112                                    if let Some(parent_loc) =
113                                        parent_method_location(parent_name, method_name, all_docs)
114                                    {
115                                        out.push(overrides_lens(
116                                            method_range,
117                                            uri,
118                                            parent_name,
119                                            method_name,
120                                            parent_loc,
121                                        ));
122                                    }
123                                }
124
125                                // Constructor-promoted params: `public function __construct(public string $name)`.
126                                if m.name == "__construct" {
127                                    for p in m.params.iter() {
128                                        if p.visibility.is_some() {
129                                            let param_name = p.name.as_str().unwrap_or_default();
130                                            let prop_range = sv.name_range(param_name);
131                                            out.push(ref_count_lens(
132                                                prop_range,
133                                                param_name,
134                                                uri,
135                                                all_docs,
136                                                Some(SymbolKind::Property),
137                                            ));
138                                        }
139                                    }
140                                }
141                            }
142                            ClassMemberKind::Property(p) => {
143                                let prop_name = p.name.as_str().unwrap_or_default();
144                                let prop_range = sv.name_range(prop_name);
145                                out.push(ref_count_lens(
146                                    prop_range,
147                                    prop_name,
148                                    uri,
149                                    all_docs,
150                                    Some(SymbolKind::Property),
151                                ));
152                            }
153                            _ => {}
154                        }
155                    }
156                }
157            }
158            StmtKind::Interface(i) => {
159                let name = i.name.as_str().unwrap_or_default();
160                let range = sv.name_range(name);
161                out.push(ref_count_lens(range, name, uri, all_docs, None));
162                // Implementations count lens.
163                let impls = find_implementations(name, None, all_docs);
164                out.push(impl_count_lens(range, uri, impls));
165            }
166            StmtKind::Trait(t) => {
167                let trait_name = t.name.as_str().unwrap_or_default();
168                let range = sv.name_range(trait_name);
169                out.push(ref_count_lens(range, trait_name, uri, all_docs, None));
170                // Usages: classes that `use` this trait.
171                let usages = trait_usage_locations(trait_name, all_docs);
172                out.push(impl_count_lens(range, uri, usages));
173                for member in t.body.members.iter() {
174                    match &member.kind {
175                        ClassMemberKind::Method(m) => {
176                            let method_name = m.name.as_str().unwrap_or_default();
177                            let method_range = sv.name_range(method_name);
178                            out.push(ref_count_lens(
179                                method_range,
180                                method_name,
181                                uri,
182                                all_docs,
183                                None,
184                            ));
185                        }
186                        ClassMemberKind::Property(p) => {
187                            let prop_name = p.name.as_str().unwrap_or_default();
188                            let prop_range = sv.name_range(prop_name);
189                            out.push(ref_count_lens(
190                                prop_range,
191                                prop_name,
192                                uri,
193                                all_docs,
194                                Some(SymbolKind::Property),
195                            ));
196                        }
197                        _ => {}
198                    }
199                }
200            }
201            StmtKind::Enum(e) => {
202                let enum_name = e.name.as_str().unwrap_or_default();
203                let range = sv.name_range(enum_name);
204                out.push(ref_count_lens(range, enum_name, uri, all_docs, None));
205                for member in e.body.members.iter() {
206                    match &member.kind {
207                        EnumMemberKind::Method(m) => {
208                            let method_name = m.name.as_str().unwrap_or_default();
209                            let method_range = sv.name_range(method_name);
210                            out.push(ref_count_lens(
211                                method_range,
212                                method_name,
213                                uri,
214                                all_docs,
215                                None,
216                            ));
217                        }
218                        EnumMemberKind::Case(c) => {
219                            let case_name = c.name.as_str().unwrap_or_default();
220                            let case_range = sv.name_range(case_name);
221                            out.push(ref_count_lens(case_range, case_name, uri, all_docs, None));
222                        }
223                        _ => {}
224                    }
225                }
226            }
227            StmtKind::Namespace(ns) => {
228                if let NamespaceBody::Braced(inner) = &ns.body {
229                    collect_lenses(&inner.stmts, sv, uri, all_docs, out, should_cancel);
230                }
231            }
232            _ => {}
233        }
234    }
235}
236
237fn ref_count_lens(
238    range: tower_lsp::lsp_types::Range,
239    name: &str,
240    uri: &Url,
241    all_docs: &[(Url, Arc<ParsedDoc>)],
242    kind: Option<SymbolKind>,
243) -> CodeLens {
244    let locations = find_references(name, all_docs, false, kind);
245    let count = locations.len();
246    let label = match count {
247        0 => "0 references".to_string(),
248        1 => "1 reference".to_string(),
249        n => format!("{n} references"),
250    };
251    CodeLens {
252        range,
253        command: Some(Command {
254            title: label,
255            command: "editor.action.showReferences".to_string(),
256            arguments: Some(vec![json!(uri), json!(range.start), json!(locations)]),
257        }),
258        data: None,
259    }
260}
261
262fn impl_count_lens(
263    range: tower_lsp::lsp_types::Range,
264    uri: &Url,
265    locations: Vec<tower_lsp::lsp_types::Location>,
266) -> CodeLens {
267    let count = locations.len();
268    let label = match count {
269        0 => "0 implementations".to_string(),
270        1 => "1 implementation".to_string(),
271        n => format!("{n} implementations"),
272    };
273    CodeLens {
274        range,
275        command: Some(Command {
276            title: label,
277            command: "editor.action.showReferences".to_string(),
278            arguments: Some(vec![json!(uri), json!(range.start), json!(locations)]),
279        }),
280        data: None,
281    }
282}
283
284fn overrides_lens(
285    range: tower_lsp::lsp_types::Range,
286    uri: &Url,
287    parent_class: &str,
288    method_name: &str,
289    parent_location: tower_lsp::lsp_types::Location,
290) -> CodeLens {
291    CodeLens {
292        range,
293        command: Some(Command {
294            title: format!("overrides {}::{}", parent_class, method_name),
295            command: "editor.action.showReferences".to_string(),
296            arguments: Some(vec![
297                json!(uri),
298                json!(range.start),
299                json!(vec![parent_location]),
300            ]),
301        }),
302        data: None,
303    }
304}
305
306fn run_test_lens(
307    range: tower_lsp::lsp_types::Range,
308    uri: &Url,
309    class: &str,
310    method: &str,
311) -> CodeLens {
312    CodeLens {
313        range,
314        command: Some(Command {
315            title: "▶ Run test".to_string(),
316            command: "php-lsp.runTest".to_string(),
317            arguments: Some(vec![
318                serde_json::json!(uri.to_string()),
319                serde_json::json!(format!("{class}::{method}")),
320            ]),
321        }),
322        data: None,
323    }
324}
325
326/// Count how many classes across `all_docs` use `trait_name` via a `use` statement.
327fn trait_usage_locations(
328    trait_name: &str,
329    all_docs: &[(Url, Arc<ParsedDoc>)],
330) -> Vec<tower_lsp::lsp_types::Location> {
331    let mut out = Vec::new();
332    for (uri, doc) in all_docs {
333        let sv = doc.view();
334        collect_trait_usages_in_stmts(trait_name, &doc.program().stmts, sv, uri, &mut out);
335    }
336    out
337}
338
339fn collect_trait_usages_in_stmts(
340    trait_name: &str,
341    stmts: &[php_ast::Stmt<'_, '_>],
342    sv: SourceView<'_>,
343    uri: &Url,
344    out: &mut Vec<tower_lsp::lsp_types::Location>,
345) {
346    for stmt in stmts {
347        match &stmt.kind {
348            StmtKind::Class(c) => {
349                let uses_trait = c.body.members.iter().any(|m| {
350                    if let ClassMemberKind::TraitUse(t) = &m.kind {
351                        t.traits
352                            .iter()
353                            .any(|name| name.to_string_repr().as_ref() == trait_name)
354                    } else {
355                        false
356                    }
357                });
358                if uses_trait && let Some(class_name) = c.name {
359                    out.push(tower_lsp::lsp_types::Location {
360                        uri: uri.clone(),
361                        range: sv.name_range_in_span(class_name.or_error(), stmt.span),
362                    });
363                }
364            }
365            StmtKind::Namespace(ns) => {
366                if let NamespaceBody::Braced(inner) = &ns.body {
367                    collect_trait_usages_in_stmts(trait_name, &inner.stmts, sv, uri, out);
368                }
369            }
370            _ => {}
371        }
372    }
373}
374
375/// Direct supertypes of `c` — the extended parent class (resolved to its
376/// canonical short name) plus every trait listed in `use` clauses. Order is
377/// stable: extends first, then traits in source order. Duplicates are removed.
378fn collect_direct_supertypes(
379    c: &php_ast::ClassDecl<'_, '_>,
380    all_docs: &[(Url, Arc<ParsedDoc>)],
381) -> Vec<String> {
382    let mut out: Vec<String> = Vec::new();
383    if let Some(extends) = &c.extends {
384        let parent_short = extends.to_string_repr().into_owned();
385        let resolved = all_docs
386            .iter()
387            .find_map(|(_, doc)| parent_class_name(doc, &parent_short))
388            .unwrap_or(parent_short);
389        out.push(resolved);
390    }
391    for member in c.body.members.iter() {
392        if let ClassMemberKind::TraitUse(t) = &member.kind {
393            for name in t.traits.iter() {
394                let s = name.to_string_repr().into_owned();
395                if !out.contains(&s) {
396                    out.push(s);
397                }
398            }
399        }
400    }
401    out
402}
403
404/// Find the declaration location of `method_name` on a class or trait named
405/// `parent_name`, if any.
406fn parent_method_location(
407    parent_name: &str,
408    method_name: &str,
409    all_docs: &[(Url, Arc<ParsedDoc>)],
410) -> Option<tower_lsp::lsp_types::Location> {
411    for (uri, doc) in all_docs {
412        let sv = doc.view();
413        if let Some(range) =
414            find_method_name_range(&doc.program().stmts, parent_name, method_name, sv)
415        {
416            return Some(tower_lsp::lsp_types::Location {
417                uri: uri.clone(),
418                range,
419            });
420        }
421    }
422    None
423}
424
425fn find_method_name_range(
426    stmts: &[php_ast::Stmt<'_, '_>],
427    parent_name: &str,
428    method_name: &str,
429    sv: SourceView<'_>,
430) -> Option<tower_lsp::lsp_types::Range> {
431    for stmt in stmts {
432        match &stmt.kind {
433            StmtKind::Class(c) if c.name.as_ref().and_then(|n| n.as_str()) == Some(parent_name) => {
434                for member in c.body.members.iter() {
435                    if let ClassMemberKind::Method(m) = &member.kind
436                        && m.name == method_name
437                    {
438                        return Some(sv.name_range(m.name.as_str().unwrap_or_default()));
439                    }
440                }
441            }
442            StmtKind::Trait(t) if t.name == parent_name => {
443                for member in t.body.members.iter() {
444                    if let ClassMemberKind::Method(m) = &member.kind
445                        && m.name == method_name
446                    {
447                        return Some(sv.name_range(m.name.as_str().unwrap_or_default()));
448                    }
449                }
450            }
451            StmtKind::Namespace(ns) => {
452                if let NamespaceBody::Braced(inner) = &ns.body
453                    && let Some(r) =
454                        find_method_name_range(&inner.stmts, parent_name, method_name, sv)
455                {
456                    return Some(r);
457                }
458            }
459            _ => {}
460        }
461    }
462    None
463}
464
465/// A method is a test if its name starts with `test` (PHPUnit convention),
466/// if its leading docblock contains `@test`, or if it carries a `#[Test]`
467/// or `#[PHPUnit\Framework\Attributes\Test]` PHP attribute.
468fn is_test_method(source: &str, m: &php_ast::MethodDecl<'_, '_>) -> bool {
469    if m.name
470        .as_str()
471        .map(|s| s.starts_with("test"))
472        .unwrap_or(false)
473    {
474        return true;
475    }
476    let has_test_attr = m.attributes.iter().any(|attr| {
477        let span = attr.name.span();
478        let attr_name = source
479            .get(span.start as usize..span.end as usize)
480            .unwrap_or("");
481        attr_name == "Test" || attr_name.ends_with("\\Test")
482    });
483    if has_test_attr {
484        return true;
485    }
486    m.doc_comment
487        .as_ref()
488        .is_some_and(|c| c.text.contains("@test"))
489}
490
491#[cfg(test)]
492mod tests {
493    use super::*;
494
495    /// `should_cancel = || true` must return an empty Vec immediately without
496    /// panicking, regardless of the document contents.
497    #[test]
498    fn code_lenses_cancelled_returns_empty() {
499        use crate::document::ast::ParsedDoc;
500
501        let source = "<?php\nclass Foo { public function bar(): void {} }\n";
502        let doc = std::sync::Arc::new(ParsedDoc::parse(source));
503        let uri = tower_lsp::lsp_types::Url::parse("file:///test.php").unwrap();
504        let all_docs: Vec<(tower_lsp::lsp_types::Url, std::sync::Arc<ParsedDoc>)> = vec![];
505
506        let lenses = code_lenses(&uri, &doc, &all_docs, || true);
507        assert!(lenses.is_empty(), "cancelled sweep must return empty");
508    }
509}