1use std::collections::{HashMap, VecDeque};
19use std::sync::{Arc, Mutex, OnceLock};
20
21use php_ast::{ClassMemberKind, EnumMemberKind, NamespaceBody, Stmt, StmtKind};
22use serde_json::{Value, json};
23use tower_lsp_server::ls_types::{CodeLens, Command, Location, Uri};
24
25use crate::document::ast::{ParsedDoc, SourceView};
26use crate::document::document_store::{ContentModified, DocumentStore};
27use crate::navigation::moniker::resolve_fqn;
28use crate::navigation::references::{dedup_ref_locations, session_tuple_to_location};
29use crate::text::fqn_short_name;
30
31pub fn code_lenses(
39 uri: &Uri,
40 doc: &ParsedDoc,
41 store: &DocumentStore,
42 imports: &HashMap<String, String>,
43 scopes: &CodeLensScopeCache,
44 should_cancel: impl Fn() -> bool,
45) -> Vec<CodeLens> {
46 let env = LensEnv {
47 uri,
48 doc,
49 store,
50 imports,
51 };
52 let sv = doc.view();
53 let mut slots = Vec::new();
54 collect_lenses(
55 &doc.program().stmts,
56 sv,
57 "",
58 &env,
59 &mut slots,
60 &should_cancel,
61 );
62 if should_cancel() {
63 return Vec::new();
64 }
65
66 let mut symbols = Vec::new();
67 let mut lenses = Vec::with_capacity(slots.len());
68 for slot in slots {
69 lenses.push(match slot {
70 LensSlot::Ready(lens) => lens,
71 LensSlot::RefCount { range, symbol } => {
72 let lens = CodeLens {
73 range,
74 command: None,
75 data: Some(ref_count_data(uri, &symbol)),
76 };
77 symbols.push(symbol);
78 lens
79 }
80 });
81 }
82 scopes.record(uri, symbols);
83 lenses
84}
85
86pub fn resolve_code_lens(
90 unresolved: CodeLens,
91 store: &DocumentStore,
92 scopes: &CodeLensScopeCache,
93 rev: u64,
94) -> Result<CodeLens, ContentModified> {
95 if unresolved.command.is_some() {
96 return Ok(unresolved);
97 }
98 let Some((uri, symbol)) = unresolved.data.as_ref().and_then(decode_ref_count_data) else {
99 return Ok(unresolved);
100 };
101 let files = scopes.candidate_files(store, &uri, rev, &symbol);
102 let mut locations: Vec<Location> = store
103 .indexed_references(&symbol, &files, false, Some(rev))?
104 .into_iter()
105 .filter_map(session_tuple_to_location)
106 .collect();
107 dedup_ref_locations(&mut locations);
108 let label = match locations.len() {
109 0 => "0 references".to_string(),
110 1 => "1 reference".to_string(),
111 n => format!("{n} references"),
112 };
113 Ok(lens(unresolved.range, &uri, label, locations))
114}
115
116const SCOPE_CACHE_FILES: usize = 32;
119
120type ScopeMap = HashMap<mir_analyzer::Name, Arc<Vec<Arc<str>>>>;
121type FileScopeCell = (Arc<[mir_analyzer::Name]>, Arc<OnceLock<ScopeMap>>);
122
123#[derive(Default)]
129pub struct CodeLensScopeCache {
130 files: Mutex<VecDeque<(Uri, FileScopes)>>,
131}
132
133struct FileScopes {
134 symbols: Arc<[mir_analyzer::Name]>,
135 at_rev: Option<(u64, Arc<OnceLock<ScopeMap>>)>,
136}
137
138impl CodeLensScopeCache {
139 fn record(&self, uri: &Uri, symbols: Vec<mir_analyzer::Name>) {
140 let mut files = self.files.lock().unwrap();
141 files.retain(|(u, _)| u != uri);
142 if files.len() >= SCOPE_CACHE_FILES {
143 files.pop_front();
144 }
145 files.push_back((
146 uri.clone(),
147 FileScopes {
148 symbols: symbols.into(),
149 at_rev: None,
150 },
151 ));
152 }
153
154 fn scopes_at(&self, uri: &Uri, rev: u64) -> Option<FileScopeCell> {
155 let mut files = self.files.lock().unwrap();
156 let (_, file) = files.iter_mut().find(|(u, _)| u == uri)?;
157 let cell = match &file.at_rev {
158 Some((at, cell)) if *at == rev => Arc::clone(cell),
159 _ => {
160 let cell = Arc::new(OnceLock::new());
161 file.at_rev = Some((rev, Arc::clone(&cell)));
162 cell
163 }
164 };
165 Some((Arc::clone(&file.symbols), cell))
166 }
167
168 fn candidate_files(
169 &self,
170 store: &DocumentStore,
171 uri: &Uri,
172 rev: u64,
173 symbol: &mir_analyzer::Name,
174 ) -> Arc<Vec<Arc<str>>> {
175 if let Some((symbols, cell)) = self.scopes_at(uri, rev) {
176 let map = cell.get_or_init(|| {
179 symbols
180 .iter()
181 .cloned()
182 .zip(store.batch_reference_candidate_files(&symbols))
183 .collect()
184 });
185 if let Some(files) = map.get(symbol) {
186 return Arc::clone(files);
187 }
188 }
189 store
190 .batch_reference_candidate_files(std::slice::from_ref(symbol))
191 .pop()
192 .unwrap_or_default()
193 }
194}
195
196fn ref_count_data(uri: &Uri, symbol: &mir_analyzer::Name) -> Value {
197 use mir_analyzer::Name;
198 let refs = match symbol {
199 Name::Class(n) => json!(["class", &**n]),
200 Name::Function(n) => json!(["function", &**n]),
201 Name::GlobalConstant(n) => json!(["constant", &**n]),
202 Name::Method { class, name } => json!(["method", &**class, &**name]),
203 Name::Property { class, name } => json!(["property", &**class, &**name]),
204 Name::ClassConstant { class, name } => json!(["class_constant", &**class, &**name]),
205 };
206 json!({ "uri": uri, "refs": refs })
207}
208
209fn decode_ref_count_data(data: &Value) -> Option<(Uri, mir_analyzer::Name)> {
210 use mir_analyzer::Name;
211 let uri = data.get("uri")?.as_str()?.parse::<Uri>().ok()?;
212 let parts = data.get("refs")?.as_array()?;
213 let part = |i: usize| parts.get(i)?.as_str().map(Arc::<str>::from);
214 let symbol = match parts.first()?.as_str()? {
215 "class" => Name::Class(part(1)?),
216 "function" => Name::Function(part(1)?),
217 "constant" => Name::GlobalConstant(part(1)?),
218 "method" => Name::Method {
219 class: part(1)?,
220 name: part(2)?,
221 },
222 "property" => Name::Property {
223 class: part(1)?,
224 name: part(2)?,
225 },
226 "class_constant" => Name::ClassConstant {
227 class: part(1)?,
228 name: part(2)?,
229 },
230 _ => return None,
231 };
232 Some((uri, symbol))
233}
234
235struct LensEnv<'a> {
237 uri: &'a Uri,
238 doc: &'a ParsedDoc,
239 store: &'a DocumentStore,
240 imports: &'a HashMap<String, String>,
241}
242
243enum LensSlot {
245 Ready(CodeLens),
246 RefCount {
247 range: tower_lsp_server::ls_types::Range,
248 symbol: mir_analyzer::Name,
249 },
250}
251
252impl LensEnv<'_> {
253 fn impl_count_lens(
254 &self,
255 range: tower_lsp_server::ls_types::Range,
256 fqn: &str,
257 include_trait_users: bool,
258 ) -> CodeLens {
259 let locations: Vec<Location> = self
260 .store
261 .indexed_subtype_classes(fqn, include_trait_users)
262 .unwrap_or_default()
263 .into_iter()
264 .filter_map(|site| subtype_site_to_location(&site.file, &site.range))
265 .collect();
266 let label = match locations.len() {
267 0 => "0 implementations".to_string(),
268 1 => "1 implementation".to_string(),
269 n => format!("{n} implementations"),
270 };
271 lens(range, self.uri, label, locations)
272 }
273
274 fn parent_method_location(&self, parent_fqn: &str, method: &str) -> Option<(String, Location)> {
284 let ws = self.store.get_workspace_index_salsa();
285 let mut current = parent_fqn.trim_start_matches('\\').to_string();
286 let mut seen = std::collections::HashSet::new();
287 loop {
288 if !seen.insert(current.clone()) {
289 return None;
290 }
291 let chosen = self.store.resolve_class_ref_by_fqn(&ws, ¤t)?;
292 let (uri, cls) = ws.at(chosen)?;
293 let declaring_fqn = cls.fqn.trim_start_matches('\\').to_string();
294 if let Some(m) = cls.methods.iter().find(|m| m.name.as_ref() == method) {
295 let start = tower_lsp_server::ls_types::Position {
296 line: m.start_line,
297 character: m.name_char,
298 };
299 let end = tower_lsp_server::ls_types::Position {
300 line: m.start_line,
301 character: m.name_char + m.name.encode_utf16().count() as u32,
302 };
303 return Some((
304 declaring_fqn,
305 Location {
306 uri: uri.clone(),
307 range: tower_lsp_server::ls_types::Range { start, end },
308 },
309 ));
310 }
311 let parent = cls.parent.as_deref()?;
312 current = parent.trim_start_matches('\\').to_string();
313 }
314 }
315}
316
317fn collect_lenses(
318 stmts: &[Stmt<'_, '_>],
319 sv: SourceView<'_>,
320 enclosing_ns: &str,
321 env: &LensEnv<'_>,
322 out: &mut Vec<LensSlot>,
323 should_cancel: &impl Fn() -> bool,
324) {
325 let mut ns: String = enclosing_ns.to_string();
327 for stmt in stmts {
328 if should_cancel() {
329 out.clear();
330 return;
331 }
332 match &stmt.kind {
333 StmtKind::Function(f) => {
334 let name = f.name.as_str().unwrap_or_default();
335 let range = sv.name_range(name);
336 out.push(LensSlot::RefCount {
337 range,
338 symbol: mir_analyzer::Name::function(fqn_in(&ns, name)),
339 });
340 }
341 StmtKind::Class(c) => {
342 if let Some(class_name) = c.name {
343 let class_name_str = class_name.as_str().unwrap_or_default();
344 let class_fqn = fqn_in(&ns, class_name_str);
345 let class_range = sv.name_range(class_name_str);
346 out.push(LensSlot::RefCount {
347 range: class_range,
348 symbol: mir_analyzer::Name::class(class_fqn.clone()),
349 });
350
351 if c.modifiers.is_abstract {
353 out.push(LensSlot::Ready(env.impl_count_lens(
354 class_range,
355 &class_fqn,
356 false,
357 )));
358 }
359
360 let parents = collect_direct_supertypes(c, env.doc, env.imports);
363
364 for member in c.body.members.iter() {
365 match &member.kind {
366 ClassMemberKind::Method(m) => {
367 let method_name = m.name.as_str().unwrap_or_default();
368 let method_range = sv.name_range(method_name);
369 out.push(LensSlot::RefCount {
370 range: method_range,
371 symbol: mir_analyzer::Name::method(
372 class_fqn.as_str(),
373 method_name,
374 ),
375 });
376
377 if is_test_method(sv.source(), m) {
378 out.push(LensSlot::Ready(run_test_lens(
379 method_range,
380 env.uri,
381 class_name_str,
382 method_name,
383 )));
384 }
385
386 for parent_fqn in &parents {
389 if let Some((declaring_fqn, parent_loc)) =
390 env.parent_method_location(parent_fqn, method_name)
391 {
392 out.push(LensSlot::Ready(overrides_lens(
393 method_range,
394 env.uri,
395 fqn_short_name(&declaring_fqn),
396 method_name,
397 parent_loc,
398 )));
399 }
400 }
401
402 if m.name == "__construct" {
404 for p in m.params.iter() {
405 if p.visibility.is_some() {
406 let param_name = p.name.as_str().unwrap_or_default();
407 let prop_range = sv.name_range(param_name);
408 out.push(LensSlot::RefCount {
409 range: prop_range,
410 symbol: property_name(&class_fqn, param_name),
411 });
412 }
413 }
414 }
415 }
416 ClassMemberKind::Property(p) => {
417 let prop_name = p.name.as_str().unwrap_or_default();
418 let prop_range = sv.name_range(prop_name);
419 out.push(LensSlot::RefCount {
420 range: prop_range,
421 symbol: property_name(&class_fqn, prop_name),
422 });
423 }
424 _ => {}
425 }
426 }
427 }
428 }
429 StmtKind::Interface(i) => {
430 let name = i.name.as_str().unwrap_or_default();
431 let fqn = fqn_in(&ns, name);
432 let range = sv.name_range(name);
433 out.push(LensSlot::RefCount {
434 range,
435 symbol: mir_analyzer::Name::class(fqn.clone()),
436 });
437 out.push(LensSlot::Ready(env.impl_count_lens(range, &fqn, false)));
439 }
440 StmtKind::Trait(t) => {
441 let trait_name = t.name.as_str().unwrap_or_default();
442 let trait_fqn = fqn_in(&ns, trait_name);
443 let range = sv.name_range(trait_name);
444 out.push(LensSlot::RefCount {
445 range,
446 symbol: mir_analyzer::Name::class(trait_fqn.clone()),
447 });
448 out.push(LensSlot::Ready(
450 env.impl_count_lens(range, &trait_fqn, true),
451 ));
452 for member in t.body.members.iter() {
453 match &member.kind {
454 ClassMemberKind::Method(m) => {
455 let method_name = m.name.as_str().unwrap_or_default();
456 let method_range = sv.name_range(method_name);
457 out.push(LensSlot::RefCount {
458 range: method_range,
459 symbol: mir_analyzer::Name::method(trait_fqn.as_str(), method_name),
460 });
461 }
462 ClassMemberKind::Property(p) => {
463 let prop_name = p.name.as_str().unwrap_or_default();
464 let prop_range = sv.name_range(prop_name);
465 out.push(LensSlot::RefCount {
466 range: prop_range,
467 symbol: property_name(&trait_fqn, prop_name),
468 });
469 }
470 _ => {}
471 }
472 }
473 }
474 StmtKind::Enum(e) => {
475 let enum_name = e.name.as_str().unwrap_or_default();
476 let enum_fqn = fqn_in(&ns, enum_name);
477 let range = sv.name_range(enum_name);
478 out.push(LensSlot::RefCount {
479 range,
480 symbol: mir_analyzer::Name::class(enum_fqn.clone()),
481 });
482 for member in e.body.members.iter() {
483 match &member.kind {
484 EnumMemberKind::Method(m) => {
485 let method_name = m.name.as_str().unwrap_or_default();
486 let method_range = sv.name_range(method_name);
487 out.push(LensSlot::RefCount {
488 range: method_range,
489 symbol: mir_analyzer::Name::method(enum_fqn.as_str(), method_name),
490 });
491 }
492 EnumMemberKind::Case(c) => {
493 let case_name = c.name.as_str().unwrap_or_default();
494 let case_range = sv.name_range(case_name);
495 out.push(LensSlot::RefCount {
496 range: case_range,
497 symbol: mir_analyzer::Name::class_constant(
498 enum_fqn.as_str(),
499 case_name,
500 ),
501 });
502 }
503 _ => {}
504 }
505 }
506 }
507 StmtKind::Namespace(nsd) => {
508 let ns_name = nsd
509 .name
510 .as_ref()
511 .map(|n| n.to_string_repr().into_owned())
512 .unwrap_or_default();
513 match &nsd.body {
514 NamespaceBody::Braced(inner) => {
515 collect_lenses(&inner.stmts, sv, &ns_name, env, out, should_cancel);
516 }
517 NamespaceBody::Simple => {
518 ns = ns_name;
519 }
520 }
521 }
522 _ => {}
523 }
524 }
525}
526
527fn fqn_in(ns: &str, name: &str) -> String {
528 if ns.is_empty() {
529 name.to_string()
530 } else {
531 format!("{ns}\\{name}")
532 }
533}
534
535fn property_name(class_fqn: &str, prop: &str) -> mir_analyzer::Name {
536 mir_analyzer::Name::property(class_fqn, prop)
537}
538
539pub(crate) fn subtype_site_to_location(
540 file: &str,
541 range: &mir_analyzer::Range,
542) -> Option<Location> {
543 let uri = (file).parse::<Uri>().ok()?;
544 let line = range.start.line.saturating_sub(1);
546 Some(Location {
547 uri,
548 range: tower_lsp_server::ls_types::Range {
549 start: tower_lsp_server::ls_types::Position {
550 line,
551 character: range.start.column,
552 },
553 end: tower_lsp_server::ls_types::Position {
554 line,
555 character: range.end.column,
556 },
557 },
558 })
559}
560
561fn lens(
562 range: tower_lsp_server::ls_types::Range,
563 uri: &Uri,
564 title: String,
565 locations: Vec<Location>,
566) -> CodeLens {
567 CodeLens {
568 range,
569 command: Some(Command {
570 title,
571 command: "editor.action.showReferences".to_string(),
572 arguments: Some(vec![json!(uri), json!(range.start), json!(locations)]),
573 }),
574 data: None,
575 }
576}
577
578fn overrides_lens(
579 range: tower_lsp_server::ls_types::Range,
580 uri: &Uri,
581 parent_class: &str,
582 method_name: &str,
583 parent_location: Location,
584) -> CodeLens {
585 CodeLens {
586 range,
587 command: Some(Command {
588 title: format!("overrides {}::{}", parent_class, method_name),
589 command: "editor.action.showReferences".to_string(),
590 arguments: Some(vec![
591 json!(uri),
592 json!(range.start),
593 json!(vec![parent_location]),
594 ]),
595 }),
596 data: None,
597 }
598}
599
600fn run_test_lens(
601 range: tower_lsp_server::ls_types::Range,
602 uri: &Uri,
603 class: &str,
604 method: &str,
605) -> CodeLens {
606 CodeLens {
607 range,
608 command: Some(Command {
609 title: "▶ Run test".to_string(),
610 command: "php-lsp.runTest".to_string(),
611 arguments: Some(vec![
612 serde_json::json!(uri.to_string()),
613 serde_json::json!(format!("{class}::{method}")),
614 ]),
615 }),
616 data: None,
617 }
618}
619
620fn collect_direct_supertypes(
625 c: &php_ast::ClassDecl<'_, '_>,
626 doc: &ParsedDoc,
627 imports: &HashMap<String, String>,
628) -> Vec<String> {
629 let mut out: Vec<String> = Vec::new();
630 let mut resolve = |name: std::borrow::Cow<'_, str>| {
631 let fqn = resolve_fqn(doc, &name, imports)
632 .trim_start_matches('\\')
633 .to_string();
634 if !out.contains(&fqn) {
635 out.push(fqn);
636 }
637 };
638 if let Some(extends) = &c.extends {
639 resolve(extends.to_string_repr());
640 }
641 for member in c.body.members.iter() {
642 if let ClassMemberKind::TraitUse(t) = &member.kind {
643 for name in t.traits.iter() {
644 resolve(name.to_string_repr());
645 }
646 }
647 }
648 out
649}
650
651fn is_test_method(source: &str, m: &php_ast::MethodDecl<'_, '_>) -> bool {
655 if m.name
656 .as_str()
657 .map(|s| s.starts_with("test"))
658 .unwrap_or(false)
659 {
660 return true;
661 }
662 let has_test_attr = m.attributes.iter().any(|attr| {
663 let span = attr.name.span();
664 let attr_name = source
665 .get(span.start as usize..span.end as usize)
666 .unwrap_or("");
667 attr_name == "Test" || attr_name.ends_with("\\Test")
668 });
669 if has_test_attr {
670 return true;
671 }
672 m.doc_comment
673 .as_ref()
674 .is_some_and(|c| c.text.contains("@test"))
675}
676
677#[cfg(test)]
678mod tests {
679 use super::*;
680
681 #[test]
684 fn code_lenses_cancelled_returns_empty() {
685 use crate::document::ast::ParsedDoc;
686
687 let source = "<?php\nclass Foo { public function bar(): void {} }\n";
688 let doc = std::sync::Arc::new(ParsedDoc::parse(source));
689 let uri = "file:///test.php".parse::<Uri>().unwrap();
690 let store = DocumentStore::new();
691
692 let scopes = CodeLensScopeCache::default();
693 let lenses = code_lenses(&uri, &doc, &store, &HashMap::new(), &scopes, || true);
694 assert!(lenses.is_empty(), "cancelled sweep must return empty");
695 }
696
697 #[test]
698 fn ref_count_data_roundtrips_every_symbol_kind() {
699 use mir_analyzer::Name;
700 let uri = "file:///a.php".parse::<Uri>().unwrap();
701 let symbols = [
702 Name::Class(Arc::from("App\\Foo")),
703 Name::Function(Arc::from("App\\run")),
704 Name::GlobalConstant(Arc::from("LIMIT")),
705 Name::method("App\\Foo", "Bar"),
706 Name::property("App\\Foo", "baz"),
707 Name::class_constant("App\\Foo", "Case"),
708 ];
709 for symbol in symbols {
710 let decoded = decode_ref_count_data(&ref_count_data(&uri, &symbol));
711 assert_eq!(decoded, Some((uri.clone(), symbol)));
712 }
713 }
714}