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