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