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