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 chosen = self
178 .store
179 .resolve_class_ref_by_fqn_or_short_name_fallback(&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
427fn subtype_site_to_location(file: &str, range: &mir_analyzer::Range) -> Option<Location> {
428 let uri = (file).parse::<Uri>().ok()?;
429 let line = range.start.line.saturating_sub(1);
431 Some(Location {
432 uri,
433 range: tower_lsp_server::ls_types::Range {
434 start: tower_lsp_server::ls_types::Position {
435 line,
436 character: range.start.column,
437 },
438 end: tower_lsp_server::ls_types::Position {
439 line,
440 character: range.end.column,
441 },
442 },
443 })
444}
445
446fn lens(
447 range: tower_lsp_server::ls_types::Range,
448 uri: &Uri,
449 title: String,
450 locations: Vec<Location>,
451) -> CodeLens {
452 CodeLens {
453 range,
454 command: Some(Command {
455 title,
456 command: "editor.action.showReferences".to_string(),
457 arguments: Some(vec![json!(uri), json!(range.start), json!(locations)]),
458 }),
459 data: None,
460 }
461}
462
463fn overrides_lens(
464 range: tower_lsp_server::ls_types::Range,
465 uri: &Uri,
466 parent_class: &str,
467 method_name: &str,
468 parent_location: Location,
469) -> CodeLens {
470 CodeLens {
471 range,
472 command: Some(Command {
473 title: format!("overrides {}::{}", parent_class, method_name),
474 command: "editor.action.showReferences".to_string(),
475 arguments: Some(vec![
476 json!(uri),
477 json!(range.start),
478 json!(vec![parent_location]),
479 ]),
480 }),
481 data: None,
482 }
483}
484
485fn run_test_lens(
486 range: tower_lsp_server::ls_types::Range,
487 uri: &Uri,
488 class: &str,
489 method: &str,
490) -> CodeLens {
491 CodeLens {
492 range,
493 command: Some(Command {
494 title: "▶ Run test".to_string(),
495 command: "php-lsp.runTest".to_string(),
496 arguments: Some(vec![
497 serde_json::json!(uri.to_string()),
498 serde_json::json!(format!("{class}::{method}")),
499 ]),
500 }),
501 data: None,
502 }
503}
504
505fn collect_direct_supertypes(
510 c: &php_ast::ClassDecl<'_, '_>,
511 doc: &ParsedDoc,
512 imports: &HashMap<String, String>,
513) -> Vec<String> {
514 let mut out: Vec<String> = Vec::new();
515 let mut resolve = |name: std::borrow::Cow<'_, str>| {
516 let fqn = resolve_fqn(doc, &name, imports)
517 .trim_start_matches('\\')
518 .to_string();
519 if !out.contains(&fqn) {
520 out.push(fqn);
521 }
522 };
523 if let Some(extends) = &c.extends {
524 resolve(extends.to_string_repr());
525 }
526 for member in c.body.members.iter() {
527 if let ClassMemberKind::TraitUse(t) = &member.kind {
528 for name in t.traits.iter() {
529 resolve(name.to_string_repr());
530 }
531 }
532 }
533 out
534}
535
536fn is_test_method(source: &str, m: &php_ast::MethodDecl<'_, '_>) -> bool {
540 if m.name
541 .as_str()
542 .map(|s| s.starts_with("test"))
543 .unwrap_or(false)
544 {
545 return true;
546 }
547 let has_test_attr = m.attributes.iter().any(|attr| {
548 let span = attr.name.span();
549 let attr_name = source
550 .get(span.start as usize..span.end as usize)
551 .unwrap_or("");
552 attr_name == "Test" || attr_name.ends_with("\\Test")
553 });
554 if has_test_attr {
555 return true;
556 }
557 m.doc_comment
558 .as_ref()
559 .is_some_and(|c| c.text.contains("@test"))
560}
561
562#[cfg(test)]
563mod tests {
564 use super::*;
565
566 #[test]
569 fn code_lenses_cancelled_returns_empty() {
570 use crate::document::ast::ParsedDoc;
571
572 let source = "<?php\nclass Foo { public function bar(): void {} }\n";
573 let doc = std::sync::Arc::new(ParsedDoc::parse(source));
574 let uri = "file:///test.php".parse::<Uri>().unwrap();
575 let store = DocumentStore::new();
576
577 let lenses = code_lenses(&uri, &doc, &store, &HashMap::new(), None, || true);
578 assert!(lenses.is_empty(), "cancelled sweep must return empty");
579 }
580}