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