1use std::collections::HashMap;
16
17use php_ast::{ClassMemberKind, EnumMemberKind, NamespaceBody, Stmt, StmtKind};
18use serde_json::json;
19use tower_lsp_server::ls_types::{CodeLens, Command, Location, Uri};
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: &Uri,
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 slots = Vec::new();
52 collect_lenses(
53 &doc.program().stmts,
54 sv,
55 "",
56 &env,
57 &mut slots,
58 &should_cancel,
59 );
60 if should_cancel() {
61 return Vec::new();
62 }
63
64 let symbols: Vec<mir_analyzer::Name> = slots
70 .iter()
71 .filter_map(|slot| match slot {
72 LensSlot::RefCount { symbol, .. } => Some(symbol.clone()),
73 LensSlot::Ready(_) => None,
74 })
75 .collect();
76 let mut scopes = env
77 .store
78 .batch_reference_candidate_files(&symbols)
79 .into_iter();
80
81 let mut lenses = Vec::with_capacity(slots.len());
82 for slot in slots {
83 if should_cancel() {
84 return Vec::new();
85 }
86 lenses.push(match slot {
87 LensSlot::Ready(lens) => lens,
88 LensSlot::RefCount { range, symbol } => {
89 let files = scopes.next().expect("one scope per RefCount slot");
90 env.ref_count_lens(range, symbol, &files)
91 }
92 });
93 }
94 lenses
95}
96
97struct LensEnv<'a> {
99 uri: &'a Uri,
100 doc: &'a ParsedDoc,
101 store: &'a DocumentStore,
102 imports: &'a HashMap<String, String>,
103 cancel_rev: Option<u64>,
104}
105
106enum LensSlot {
111 Ready(CodeLens),
112 RefCount {
113 range: tower_lsp_server::ls_types::Range,
114 symbol: mir_analyzer::Name,
115 },
116}
117
118impl LensEnv<'_> {
119 fn ref_count_lens(
120 &self,
121 range: tower_lsp_server::ls_types::Range,
122 symbol: mir_analyzer::Name,
123 files: &[std::sync::Arc<str>],
124 ) -> CodeLens {
125 let mut locations: Vec<Location> = self
126 .store
127 .indexed_references(&symbol, files, false, self.cancel_rev)
128 .unwrap_or_default()
129 .into_iter()
130 .filter_map(session_tuple_to_location)
131 .collect();
132 dedup_ref_locations(&mut locations);
133 let label = match locations.len() {
134 0 => "0 references".to_string(),
135 1 => "1 reference".to_string(),
136 n => format!("{n} references"),
137 };
138 lens(range, self.uri, label, locations)
139 }
140
141 fn impl_count_lens(
142 &self,
143 range: tower_lsp_server::ls_types::Range,
144 fqn: &str,
145 include_trait_users: bool,
146 ) -> CodeLens {
147 let locations: Vec<Location> = self
148 .store
149 .indexed_subtype_classes(fqn, include_trait_users)
150 .unwrap_or_default()
151 .into_iter()
152 .filter_map(|site| subtype_site_to_location(&site.file, &site.range))
153 .collect();
154 let label = match locations.len() {
155 0 => "0 implementations".to_string(),
156 1 => "1 implementation".to_string(),
157 n => format!("{n} implementations"),
158 };
159 lens(range, self.uri, label, locations)
160 }
161
162 fn parent_method_location(&self, parent_fqn: &str, method: &str) -> Option<(String, Location)> {
172 let ws = self.store.get_workspace_index_salsa();
173 let mut current = parent_fqn.trim_start_matches('\\').to_string();
174 let mut seen = std::collections::HashSet::new();
175 loop {
176 if !seen.insert(current.clone()) {
177 return None;
178 }
179 let chosen = self.store.resolve_class_ref_by_fqn(&ws, ¤t)?;
180 let (uri, cls) = ws.at(chosen)?;
181 let declaring_fqn = cls.fqn.trim_start_matches('\\').to_string();
182 if let Some(m) = cls.methods.iter().find(|m| m.name.as_ref() == method) {
183 let start = tower_lsp_server::ls_types::Position {
184 line: m.start_line,
185 character: m.name_char,
186 };
187 let end = tower_lsp_server::ls_types::Position {
188 line: m.start_line,
189 character: m.name_char + m.name.encode_utf16().count() as u32,
190 };
191 return Some((
192 declaring_fqn,
193 Location {
194 uri: uri.clone(),
195 range: tower_lsp_server::ls_types::Range { start, end },
196 },
197 ));
198 }
199 let parent = cls.parent.as_deref()?;
200 current = parent.trim_start_matches('\\').to_string();
201 }
202 }
203}
204
205fn collect_lenses(
206 stmts: &[Stmt<'_, '_>],
207 sv: SourceView<'_>,
208 enclosing_ns: &str,
209 env: &LensEnv<'_>,
210 out: &mut Vec<LensSlot>,
211 should_cancel: &impl Fn() -> bool,
212) {
213 let mut ns: String = enclosing_ns.to_string();
215 for stmt in stmts {
216 if should_cancel() {
217 out.clear();
218 return;
219 }
220 match &stmt.kind {
221 StmtKind::Function(f) => {
222 let name = f.name.as_str().unwrap_or_default();
223 let range = sv.name_range(name);
224 out.push(LensSlot::RefCount {
225 range,
226 symbol: mir_analyzer::Name::function(fqn_in(&ns, name)),
227 });
228 }
229 StmtKind::Class(c) => {
230 if let Some(class_name) = c.name {
231 let class_name_str = class_name.as_str().unwrap_or_default();
232 let class_fqn = fqn_in(&ns, class_name_str);
233 let class_range = sv.name_range(class_name_str);
234 out.push(LensSlot::RefCount {
235 range: class_range,
236 symbol: mir_analyzer::Name::class(class_fqn.clone()),
237 });
238
239 if c.modifiers.is_abstract {
241 out.push(LensSlot::Ready(env.impl_count_lens(
242 class_range,
243 &class_fqn,
244 false,
245 )));
246 }
247
248 let parents = collect_direct_supertypes(c, env.doc, env.imports);
251
252 for member in c.body.members.iter() {
253 match &member.kind {
254 ClassMemberKind::Method(m) => {
255 let method_name = m.name.as_str().unwrap_or_default();
256 let method_range = sv.name_range(method_name);
257 out.push(LensSlot::RefCount {
258 range: method_range,
259 symbol: mir_analyzer::Name::method(
260 class_fqn.as_str(),
261 method_name,
262 ),
263 });
264
265 if is_test_method(sv.source(), m) {
266 out.push(LensSlot::Ready(run_test_lens(
267 method_range,
268 env.uri,
269 class_name_str,
270 method_name,
271 )));
272 }
273
274 for parent_fqn in &parents {
277 if let Some((declaring_fqn, parent_loc)) =
278 env.parent_method_location(parent_fqn, method_name)
279 {
280 out.push(LensSlot::Ready(overrides_lens(
281 method_range,
282 env.uri,
283 fqn_short_name(&declaring_fqn),
284 method_name,
285 parent_loc,
286 )));
287 }
288 }
289
290 if m.name == "__construct" {
292 for p in m.params.iter() {
293 if p.visibility.is_some() {
294 let param_name = p.name.as_str().unwrap_or_default();
295 let prop_range = sv.name_range(param_name);
296 out.push(LensSlot::RefCount {
297 range: prop_range,
298 symbol: property_name(&class_fqn, param_name),
299 });
300 }
301 }
302 }
303 }
304 ClassMemberKind::Property(p) => {
305 let prop_name = p.name.as_str().unwrap_or_default();
306 let prop_range = sv.name_range(prop_name);
307 out.push(LensSlot::RefCount {
308 range: prop_range,
309 symbol: property_name(&class_fqn, prop_name),
310 });
311 }
312 _ => {}
313 }
314 }
315 }
316 }
317 StmtKind::Interface(i) => {
318 let name = i.name.as_str().unwrap_or_default();
319 let fqn = fqn_in(&ns, name);
320 let range = sv.name_range(name);
321 out.push(LensSlot::RefCount {
322 range,
323 symbol: mir_analyzer::Name::class(fqn.clone()),
324 });
325 out.push(LensSlot::Ready(env.impl_count_lens(range, &fqn, false)));
327 }
328 StmtKind::Trait(t) => {
329 let trait_name = t.name.as_str().unwrap_or_default();
330 let trait_fqn = fqn_in(&ns, trait_name);
331 let range = sv.name_range(trait_name);
332 out.push(LensSlot::RefCount {
333 range,
334 symbol: mir_analyzer::Name::class(trait_fqn.clone()),
335 });
336 out.push(LensSlot::Ready(
338 env.impl_count_lens(range, &trait_fqn, true),
339 ));
340 for member in t.body.members.iter() {
341 match &member.kind {
342 ClassMemberKind::Method(m) => {
343 let method_name = m.name.as_str().unwrap_or_default();
344 let method_range = sv.name_range(method_name);
345 out.push(LensSlot::RefCount {
346 range: method_range,
347 symbol: mir_analyzer::Name::method(trait_fqn.as_str(), method_name),
348 });
349 }
350 ClassMemberKind::Property(p) => {
351 let prop_name = p.name.as_str().unwrap_or_default();
352 let prop_range = sv.name_range(prop_name);
353 out.push(LensSlot::RefCount {
354 range: prop_range,
355 symbol: property_name(&trait_fqn, prop_name),
356 });
357 }
358 _ => {}
359 }
360 }
361 }
362 StmtKind::Enum(e) => {
363 let enum_name = e.name.as_str().unwrap_or_default();
364 let enum_fqn = fqn_in(&ns, enum_name);
365 let range = sv.name_range(enum_name);
366 out.push(LensSlot::RefCount {
367 range,
368 symbol: mir_analyzer::Name::class(enum_fqn.clone()),
369 });
370 for member in e.body.members.iter() {
371 match &member.kind {
372 EnumMemberKind::Method(m) => {
373 let method_name = m.name.as_str().unwrap_or_default();
374 let method_range = sv.name_range(method_name);
375 out.push(LensSlot::RefCount {
376 range: method_range,
377 symbol: mir_analyzer::Name::method(enum_fqn.as_str(), method_name),
378 });
379 }
380 EnumMemberKind::Case(c) => {
381 let case_name = c.name.as_str().unwrap_or_default();
382 let case_range = sv.name_range(case_name);
383 out.push(LensSlot::RefCount {
384 range: case_range,
385 symbol: mir_analyzer::Name::class_constant(
386 enum_fqn.as_str(),
387 case_name,
388 ),
389 });
390 }
391 _ => {}
392 }
393 }
394 }
395 StmtKind::Namespace(nsd) => {
396 let ns_name = nsd
397 .name
398 .as_ref()
399 .map(|n| n.to_string_repr().into_owned())
400 .unwrap_or_default();
401 match &nsd.body {
402 NamespaceBody::Braced(inner) => {
403 collect_lenses(&inner.stmts, sv, &ns_name, env, out, should_cancel);
404 }
405 NamespaceBody::Simple => {
406 ns = ns_name;
407 }
408 }
409 }
410 _ => {}
411 }
412 }
413}
414
415fn fqn_in(ns: &str, name: &str) -> String {
416 if ns.is_empty() {
417 name.to_string()
418 } else {
419 format!("{ns}\\{name}")
420 }
421}
422
423fn property_name(class_fqn: &str, prop: &str) -> mir_analyzer::Name {
424 mir_analyzer::Name::property(class_fqn, prop)
425}
426
427pub(crate) fn subtype_site_to_location(
428 file: &str,
429 range: &mir_analyzer::Range,
430) -> Option<Location> {
431 let uri = (file).parse::<Uri>().ok()?;
432 let line = range.start.line.saturating_sub(1);
434 Some(Location {
435 uri,
436 range: tower_lsp_server::ls_types::Range {
437 start: tower_lsp_server::ls_types::Position {
438 line,
439 character: range.start.column,
440 },
441 end: tower_lsp_server::ls_types::Position {
442 line,
443 character: range.end.column,
444 },
445 },
446 })
447}
448
449fn lens(
450 range: tower_lsp_server::ls_types::Range,
451 uri: &Uri,
452 title: String,
453 locations: Vec<Location>,
454) -> CodeLens {
455 CodeLens {
456 range,
457 command: Some(Command {
458 title,
459 command: "editor.action.showReferences".to_string(),
460 arguments: Some(vec![json!(uri), json!(range.start), json!(locations)]),
461 }),
462 data: None,
463 }
464}
465
466fn overrides_lens(
467 range: tower_lsp_server::ls_types::Range,
468 uri: &Uri,
469 parent_class: &str,
470 method_name: &str,
471 parent_location: Location,
472) -> CodeLens {
473 CodeLens {
474 range,
475 command: Some(Command {
476 title: format!("overrides {}::{}", parent_class, method_name),
477 command: "editor.action.showReferences".to_string(),
478 arguments: Some(vec![
479 json!(uri),
480 json!(range.start),
481 json!(vec![parent_location]),
482 ]),
483 }),
484 data: None,
485 }
486}
487
488fn run_test_lens(
489 range: tower_lsp_server::ls_types::Range,
490 uri: &Uri,
491 class: &str,
492 method: &str,
493) -> CodeLens {
494 CodeLens {
495 range,
496 command: Some(Command {
497 title: "▶ Run test".to_string(),
498 command: "php-lsp.runTest".to_string(),
499 arguments: Some(vec![
500 serde_json::json!(uri.to_string()),
501 serde_json::json!(format!("{class}::{method}")),
502 ]),
503 }),
504 data: None,
505 }
506}
507
508fn collect_direct_supertypes(
513 c: &php_ast::ClassDecl<'_, '_>,
514 doc: &ParsedDoc,
515 imports: &HashMap<String, String>,
516) -> Vec<String> {
517 let mut out: Vec<String> = Vec::new();
518 let mut resolve = |name: std::borrow::Cow<'_, str>| {
519 let fqn = resolve_fqn(doc, &name, imports)
520 .trim_start_matches('\\')
521 .to_string();
522 if !out.contains(&fqn) {
523 out.push(fqn);
524 }
525 };
526 if let Some(extends) = &c.extends {
527 resolve(extends.to_string_repr());
528 }
529 for member in c.body.members.iter() {
530 if let ClassMemberKind::TraitUse(t) = &member.kind {
531 for name in t.traits.iter() {
532 resolve(name.to_string_repr());
533 }
534 }
535 }
536 out
537}
538
539fn is_test_method(source: &str, m: &php_ast::MethodDecl<'_, '_>) -> bool {
543 if m.name
544 .as_str()
545 .map(|s| s.starts_with("test"))
546 .unwrap_or(false)
547 {
548 return true;
549 }
550 let has_test_attr = m.attributes.iter().any(|attr| {
551 let span = attr.name.span();
552 let attr_name = source
553 .get(span.start as usize..span.end as usize)
554 .unwrap_or("");
555 attr_name == "Test" || attr_name.ends_with("\\Test")
556 });
557 if has_test_attr {
558 return true;
559 }
560 m.doc_comment
561 .as_ref()
562 .is_some_and(|c| c.text.contains("@test"))
563}
564
565#[cfg(test)]
566mod tests {
567 use super::*;
568
569 #[test]
572 fn code_lenses_cancelled_returns_empty() {
573 use crate::document::ast::ParsedDoc;
574
575 let source = "<?php\nclass Foo { public function bar(): void {} }\n";
576 let doc = std::sync::Arc::new(ParsedDoc::parse(source));
577 let uri = "file:///test.php".parse::<Uri>().unwrap();
578 let store = DocumentStore::new();
579
580 let lenses = code_lenses(&uri, &doc, &store, &HashMap::new(), None, || true);
581 assert!(lenses.is_empty(), "cancelled sweep must return empty");
582 }
583}