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::ast::{ParsedDoc, SourceView};
18use crate::navigation::implementation::find_implementations;
19use crate::navigation::references::{SymbolKind, find_references};
20use crate::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
220// ── Lens constructors ─────────────────────────────────────────────────────────
221
222fn ref_count_lens(
223    range: tower_lsp::lsp_types::Range,
224    name: &str,
225    uri: &Url,
226    all_docs: &[(Url, Arc<ParsedDoc>)],
227    kind: Option<SymbolKind>,
228) -> CodeLens {
229    let locations = find_references(name, all_docs, false, kind);
230    let count = locations.len();
231    let label = match count {
232        0 => "0 references".to_string(),
233        1 => "1 reference".to_string(),
234        n => format!("{n} references"),
235    };
236    CodeLens {
237        range,
238        command: Some(Command {
239            title: label,
240            command: "editor.action.showReferences".to_string(),
241            arguments: Some(vec![json!(uri), json!(range.start), json!(locations)]),
242        }),
243        data: None,
244    }
245}
246
247fn impl_count_lens(
248    range: tower_lsp::lsp_types::Range,
249    uri: &Url,
250    locations: Vec<tower_lsp::lsp_types::Location>,
251) -> CodeLens {
252    let count = locations.len();
253    let label = match count {
254        0 => "0 implementations".to_string(),
255        1 => "1 implementation".to_string(),
256        n => format!("{n} implementations"),
257    };
258    CodeLens {
259        range,
260        command: Some(Command {
261            title: label,
262            command: "editor.action.showReferences".to_string(),
263            arguments: Some(vec![json!(uri), json!(range.start), json!(locations)]),
264        }),
265        data: None,
266    }
267}
268
269fn overrides_lens(
270    range: tower_lsp::lsp_types::Range,
271    uri: &Url,
272    parent_class: &str,
273    method_name: &str,
274    parent_location: tower_lsp::lsp_types::Location,
275) -> CodeLens {
276    CodeLens {
277        range,
278        command: Some(Command {
279            title: format!("overrides {}::{}", parent_class, method_name),
280            command: "editor.action.showReferences".to_string(),
281            arguments: Some(vec![
282                json!(uri),
283                json!(range.start),
284                json!(vec![parent_location]),
285            ]),
286        }),
287        data: None,
288    }
289}
290
291fn run_test_lens(
292    range: tower_lsp::lsp_types::Range,
293    uri: &Url,
294    class: &str,
295    method: &str,
296) -> CodeLens {
297    CodeLens {
298        range,
299        command: Some(Command {
300            title: "▶ Run test".to_string(),
301            command: "php-lsp.runTest".to_string(),
302            arguments: Some(vec![
303                serde_json::json!(uri.to_string()),
304                serde_json::json!(format!("{class}::{method}")),
305            ]),
306        }),
307        data: None,
308    }
309}
310
311// ── Helpers ───────────────────────────────────────────────────────────────────
312
313/// Count how many classes across `all_docs` use `trait_name` via a `use` statement.
314fn trait_usage_locations(
315    trait_name: &str,
316    all_docs: &[(Url, Arc<ParsedDoc>)],
317) -> Vec<tower_lsp::lsp_types::Location> {
318    let mut out = Vec::new();
319    for (uri, doc) in all_docs {
320        let sv = doc.view();
321        collect_trait_usages_in_stmts(trait_name, &doc.program().stmts, sv, uri, &mut out);
322    }
323    out
324}
325
326fn collect_trait_usages_in_stmts(
327    trait_name: &str,
328    stmts: &[php_ast::Stmt<'_, '_>],
329    sv: SourceView<'_>,
330    uri: &Url,
331    out: &mut Vec<tower_lsp::lsp_types::Location>,
332) {
333    for stmt in stmts {
334        match &stmt.kind {
335            StmtKind::Class(c) => {
336                let uses_trait = c.body.members.iter().any(|m| {
337                    if let ClassMemberKind::TraitUse(t) = &m.kind {
338                        t.traits
339                            .iter()
340                            .any(|name| name.to_string_repr().as_ref() == trait_name)
341                    } else {
342                        false
343                    }
344                });
345                if uses_trait && let Some(class_name) = c.name {
346                    out.push(tower_lsp::lsp_types::Location {
347                        uri: uri.clone(),
348                        range: sv.name_range_in_span(class_name.or_error(), stmt.span),
349                    });
350                }
351            }
352            StmtKind::Namespace(ns) => {
353                if let NamespaceBody::Braced(inner) = &ns.body {
354                    collect_trait_usages_in_stmts(trait_name, &inner.stmts, sv, uri, out);
355                }
356            }
357            _ => {}
358        }
359    }
360}
361
362/// Direct supertypes of `c` — the extended parent class (resolved to its
363/// canonical short name) plus every trait listed in `use` clauses. Order is
364/// stable: extends first, then traits in source order. Duplicates are removed.
365fn collect_direct_supertypes(
366    c: &php_ast::ClassDecl<'_, '_>,
367    all_docs: &[(Url, Arc<ParsedDoc>)],
368) -> Vec<String> {
369    let mut out: Vec<String> = Vec::new();
370    if let Some(extends) = &c.extends {
371        let parent_short = extends.to_string_repr().into_owned();
372        let resolved = all_docs
373            .iter()
374            .find_map(|(_, doc)| parent_class_name(doc, &parent_short))
375            .unwrap_or(parent_short);
376        out.push(resolved);
377    }
378    for member in c.body.members.iter() {
379        if let ClassMemberKind::TraitUse(t) = &member.kind {
380            for name in t.traits.iter() {
381                let s = name.to_string_repr().into_owned();
382                if !out.contains(&s) {
383                    out.push(s);
384                }
385            }
386        }
387    }
388    out
389}
390
391/// Find the declaration location of `method_name` on a class or trait named
392/// `parent_name`, if any.
393fn parent_method_location(
394    parent_name: &str,
395    method_name: &str,
396    all_docs: &[(Url, Arc<ParsedDoc>)],
397) -> Option<tower_lsp::lsp_types::Location> {
398    for (uri, doc) in all_docs {
399        let sv = doc.view();
400        if let Some(range) =
401            find_method_name_range(&doc.program().stmts, parent_name, method_name, sv)
402        {
403            return Some(tower_lsp::lsp_types::Location {
404                uri: uri.clone(),
405                range,
406            });
407        }
408    }
409    None
410}
411
412fn find_method_name_range(
413    stmts: &[php_ast::Stmt<'_, '_>],
414    parent_name: &str,
415    method_name: &str,
416    sv: SourceView<'_>,
417) -> Option<tower_lsp::lsp_types::Range> {
418    for stmt in stmts {
419        match &stmt.kind {
420            StmtKind::Class(c) if c.name.as_ref().and_then(|n| n.as_str()) == Some(parent_name) => {
421                for member in c.body.members.iter() {
422                    if let ClassMemberKind::Method(m) = &member.kind
423                        && m.name == method_name
424                    {
425                        return Some(sv.name_range(m.name.as_str().unwrap_or_default()));
426                    }
427                }
428            }
429            StmtKind::Trait(t) if t.name == parent_name => {
430                for member in t.body.members.iter() {
431                    if let ClassMemberKind::Method(m) = &member.kind
432                        && m.name == method_name
433                    {
434                        return Some(sv.name_range(m.name.as_str().unwrap_or_default()));
435                    }
436                }
437            }
438            StmtKind::Namespace(ns) => {
439                if let NamespaceBody::Braced(inner) = &ns.body
440                    && let Some(r) =
441                        find_method_name_range(&inner.stmts, parent_name, method_name, sv)
442                {
443                    return Some(r);
444                }
445            }
446            _ => {}
447        }
448    }
449    None
450}
451
452/// A method is a test if its name starts with `test` (PHPUnit convention),
453/// if its leading docblock contains `@test`, or if it carries a `#[Test]`
454/// or `#[PHPUnit\Framework\Attributes\Test]` PHP attribute.
455fn is_test_method(source: &str, m: &php_ast::MethodDecl<'_, '_>) -> bool {
456    if m.name
457        .as_str()
458        .map(|s| s.starts_with("test"))
459        .unwrap_or(false)
460    {
461        return true;
462    }
463    let has_test_attr = m.attributes.iter().any(|attr| {
464        let span = attr.name.span();
465        let attr_name = source
466            .get(span.start as usize..span.end as usize)
467            .unwrap_or("");
468        attr_name == "Test" || attr_name.ends_with("\\Test")
469    });
470    if has_test_attr {
471        return true;
472    }
473    m.doc_comment
474        .as_ref()
475        .is_some_and(|c| c.text.contains("@test"))
476}