From 92e0dc18e6f353787e50d3ab71221eed3609a8f2 Mon Sep 17 00:00:00 2001 From: sidux Date: Thu, 27 Aug 2026 00:11:28 +0200 Subject: [PATCH 01/17] feat(php): Add scalable reference CodeLens Use the coarse reference index for conclusive zero counts and cache bounded exact member locations for non-zero lenses. Refresh-capable clients avoid eager resolve storms while older clients retain lazy resolution. --- docs/ARCHITECTURE.md | 4 +- docs/CHANGELOG.md | 1 + src/code_lens.rs | 325 +++++++++++++++++++++- src/lib.rs | 11 +- src/mem_audit.rs | 23 +- src/reference_counts.rs | 475 +++++++++++++++++++++++++++++---- src/reference_index.rs | 27 ++ src/references/members.rs | 7 +- src/server.rs | 44 ++- tests/integration/code_lens.rs | 339 +++++++++++++++++++++++ 10 files changed, 1195 insertions(+), 61 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5cc37501a..3907b37de 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -63,7 +63,7 @@ src/ ├── composer.rs # composer.json / PSR-4 autoload parsing ├── names.rs # Name resolution (FQN, use-map, namespace) ├── reference_index.rs # Workspace-wide reference index for find-references / rename -├── reference_counts.rs # Background-computed member reference counts for the declaration inlay hints +├── reference_counts.rs # Bounded exact member-reference cache for declaration hints and lenses │ │ # Class & type resolution ├── resolution.rs # Multi-phase class/function lookup across files (find_or_load_class) @@ -883,6 +883,8 @@ Both phases parse files in parallel using `std::thread::scope`. The work is spli Parsed files stay cached in `uri_classes_index`, `symbol_maps`, `file_imports`, and `file_namespaces` after the scan completes. There is no post-scan eviction; keeping the entries means subsequent operations (a second find-references call, go-to-definition on a cross-file symbol) benefit from the work already done. +The workspace reference index remains deliberately coarse: it stores candidate URIs and occurrence counts, not a second copy of every source position. CodeLens can therefore answer a conclusive zero without a semantic scan. Nonzero member references are resolved with the same hierarchy-aware search as Find References and cached as exact locations behind a 50,000-location bound. URI strings are shared inside that cache. Refresh-capable clients receive the lens after the background result is ready; other clients retain lazy `codeLens/resolve` as a compatibility path. + ### Cross-file scanning The `user_file_symbol_maps()` helper snapshots all symbol maps whose URI does not fall under the vendor directory or the internal stub scheme. With `Arc`, the snapshot is a vector of cheap reference-count increments rather than deep clones. Four scanners use this snapshot: diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index e330c52c1..90d336919 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Reference CodeLens.** PHP declarations show clickable exact reference counts. Declarations with no indexed uses are answered immediately, while semantic member locations are cached in a bounded background index so opening a large file does not fan out into an expensive resolve request per lens. Clients that support CodeLens refresh receive only ready, fully resolved member lenses. Contributed by @sidux. - **`analyze` takes more than one path.** `phpantom_lsp analyze app/ lib/Helper.php tests/` scans the union of everything named, mixing directories and single files freely, so a pre-commit hook or a CI step can hand it exactly the paths that changed instead of running the whole project or invoking the binary once per path. Overlapping arguments are reported once, and a path that does not exist still stops the run with exit code 2. Naming no path scans the entire project, as before. ### Changed diff --git a/src/code_lens.rs b/src/code_lens.rs index 9061c1d85..440382f96 100644 --- a/src/code_lens.rs +++ b/src/code_lens.rs @@ -1,14 +1,16 @@ //! Code Lens (`textDocument/codeLens`) support. //! -//! Shows override/implement annotations linking to the prototype declaration. +//! Shows reference counts plus override/implement annotations. use tower_lsp::lsp_types::*; use crate::Backend; use crate::atom::Atom; use crate::definition::member::MemberKind; +use crate::reference_index::ReferenceIndexKey; +use crate::symbol_map::SymbolKind; use crate::text_position::offset_to_position; -use crate::types::{ClassInfo, ClassLikeKind, MAX_INHERITANCE_DEPTH}; +use crate::types::{ClassInfo, ClassLikeKind, MAX_INHERITANCE_DEPTH, Visibility}; fn line_indent(content: &str, byte_offset: usize) -> u32 { let line_start = content[..byte_offset] @@ -36,12 +38,12 @@ struct Prototype { impl Backend { /// Handle a `textDocument/codeLens` request. /// - /// Returns a code lens for each method in the file that overrides - /// a parent class method or implements an interface method. + /// Returns reference lenses for PHP declarations and navigation lenses + /// for methods that override or implement an ancestor declaration. pub fn handle_code_lens(&self, uri: &str, content: &str) -> Option> { let classes = { let map = self.symbols.uri_classes_index.read(); - map.get(uri)?.clone() + map.get(uri).cloned().unwrap_or_default() }; let mut lenses = Vec::new(); @@ -49,6 +51,15 @@ impl Backend { for class in &classes { let class_fqn = class.fqn(); + if let Some(lens) = self.build_declaration_reference_lens( + uri, + content, + self.class_declaration_name_offset(uri, class), + &ReferenceIndexKey::class(&class_fqn), + ) { + lenses.push(lens); + } + if let Some(lens) = self.build_covers_lens(class, uri, content) { lenses.push(lens); } @@ -75,6 +86,19 @@ impl Backend { }; let proto = self.find_prototype(class, &class_fqn, &method.name, uri, content); + if !method.name.starts_with("__") + && proto.is_none() + && let Some(lens) = self.build_member_reference_lens( + uri, + content, + method.name_offset, + class_fqn, + method.name, + method.is_static, + ) + { + lenses.push(lens); + } if let Some(proto) = proto { let icon = if proto.is_interface { "◆" } else { "↑" }; let title = format!("{} {}::{}", icon, proto.ancestor_name, method.name); @@ -93,6 +117,64 @@ impl Backend { }); } } + + for property in &class.properties { + if property.name_offset == 0 + || property.is_virtual + || property.visibility == Visibility::Private + { + continue; + } + let member_name = property.name.strip_prefix('$').unwrap_or(&property.name); + if let Some(lens) = self.build_member_reference_lens( + uri, + content, + property.name_offset, + class_fqn, + crate::atom::atom(member_name), + property.is_static, + ) { + lenses.push(lens); + } + } + + for constant in &class.constants { + if constant.name_offset == 0 || constant.visibility == Visibility::Private { + continue; + } + if let Some(lens) = self.build_member_reference_lens( + uri, + content, + constant.name_offset, + class_fqn, + constant.name, + true, + ) { + lenses.push(lens); + } + } + } + + if let Some(symbol_map) = self.symbol_maps.read().get(uri).cloned() { + for span in &symbol_map.spans { + let key = match &span.kind { + SymbolKind::FunctionCall { + name, + is_definition: true, + .. + } => self.function_reference_key(uri, span.start, name), + SymbolKind::ConstantReference { + name, + is_definition: true, + } => ReferenceIndexKey::Constant(self.constant_fqn_at(uri, span.start, name)), + _ => continue, + }; + if let Some(lens) = + self.build_declaration_reference_lens(uri, content, span.start, &key) + { + lenses.push(lens); + } + } } if lenses.is_empty() { @@ -102,6 +184,239 @@ impl Backend { } } + /// Build a declaration reference lens from the candidate index. + /// + /// A zero count is returned fully resolved because semantic filtering can + /// only remove candidates. Non-zero declarations take the LSP's lazy + /// resolve path, which computes exact locations only when the client asks. + fn build_declaration_reference_lens( + &self, + origin_uri: &str, + content: &str, + declaration_offset: u32, + key: &ReferenceIndexKey, + ) -> Option { + if declaration_offset == 0 { + return None; + } + + let candidate_count = self.indexed_reference_count(key)?; + let origin_url = Url::parse(origin_uri).ok()?; + let position = offset_to_position(content, declaration_offset as usize); + let range = Range::new( + Position::new(position.line, 0), + Position::new(position.line, 0), + ); + if candidate_count == 0 { + return Some(CodeLens { + range, + command: Some(Self::reference_lens_command( + origin_url, + position, + Vec::new(), + )), + data: None, + }); + } + + Some(CodeLens { + range, + command: None, + data: Some(serde_json::json!({ + "kind": "phpReferences", + "uri": origin_uri, + "position": position, + })), + }) + } + + fn build_member_reference_lens( + &self, + origin_uri: &str, + content: &str, + declaration_offset: u32, + class_fqn: Atom, + member: Atom, + is_static: bool, + ) -> Option { + if declaration_offset == 0 { + return None; + } + let key = ReferenceIndexKey::Member { + name: member.to_string(), + is_static, + }; + let candidate_count = self.indexed_reference_count(&key)?; + let origin_url = Url::parse(origin_uri).ok()?; + let position = offset_to_position(content, declaration_offset as usize); + let range = Range::new( + Position::new(position.line, 0), + Position::new(position.line, 0), + ); + if candidate_count == 0 { + return Some(CodeLens { + range, + command: Some(Self::reference_lens_command( + origin_url, + position, + Vec::new(), + )), + data: None, + }); + } + + if let Some(locations) = self.member_ref_locations_cached( + origin_uri, + declaration_offset, + class_fqn, + member, + is_static, + ) { + return Some(CodeLens { + range, + command: Some(Self::reference_lens_command( + origin_url, position, locations, + )), + data: None, + }); + } + + // Clients with refresh support can re-pull once the shared background + // worker fills the exact cache. Omitting the cold lens avoids an + // eager resolve burst merely to obtain titles for the viewport. + if self + .supports_code_lens_refresh + .load(std::sync::atomic::Ordering::Acquire) + { + return None; + } + + Some(CodeLens { + range, + command: None, + data: Some(serde_json::json!({ + "kind": "phpMemberReferences", + "uri": origin_uri, + "position": position, + "offset": declaration_offset, + "classFqn": class_fqn.as_str(), + "member": member.as_str(), + "isStatic": is_static, + })), + }) + } + + fn class_declaration_name_offset(&self, uri: &str, class: &ClassInfo) -> u32 { + let maps = self.symbol_maps.read(); + let Some(map) = maps.get(uri) else { + return class.keyword_offset; + }; + map.spans + .iter() + .find(|span| { + matches!( + &span.kind, + SymbolKind::ClassDeclaration { name } if *name == class.name + ) && span.start >= class.decl_start_offset + && span.start <= class.start_offset + }) + .map(|span| span.start) + .unwrap_or(class.keyword_offset) + } + + fn reference_lens_command( + origin_uri: Url, + origin_position: Position, + locations: Vec, + ) -> Command { + let count = locations.len(); + Command { + title: format!( + "{count} {}", + if count == 1 { + "reference" + } else { + "references" + } + ), + command: "editor.action.showReferences".to_string(), + arguments: Some(vec![ + serde_json::json!(origin_uri), + serde_json::json!(origin_position), + serde_json::json!(locations), + ]), + } + } + + pub(crate) fn resolve_code_lens_item(&self, mut lens: CodeLens) -> CodeLens { + if lens.command.is_some() { + return lens; + } + let Some(data) = lens.data.as_ref() else { + return lens; + }; + let Some(kind) = data.get("kind").and_then(serde_json::Value::as_str) else { + return lens; + }; + let Some(uri) = data.get("uri").and_then(serde_json::Value::as_str) else { + return lens; + }; + let Some(position) = data + .get("position") + .cloned() + .and_then(|value| serde_json::from_value::(value).ok()) + else { + return lens; + }; + let locations = match kind { + "phpReferences" => { + let Some(content) = self.get_file_content(uri) else { + return lens; + }; + let Some(locations) = self.find_references(uri, &content, position, false) else { + return lens; + }; + locations + } + "phpMemberReferences" => { + let Some(offset) = data + .get("offset") + .and_then(serde_json::Value::as_u64) + .and_then(|offset| u32::try_from(offset).ok()) + else { + return lens; + }; + let Some(class_fqn) = data.get("classFqn").and_then(serde_json::Value::as_str) + else { + return lens; + }; + let Some(member) = data.get("member").and_then(serde_json::Value::as_str) else { + return lens; + }; + let Some(is_static) = data.get("isStatic").and_then(serde_json::Value::as_bool) + else { + return lens; + }; + self.resolve_member_ref_locations( + uri, + offset, + crate::atom::atom(class_fqn), + crate::atom::atom(member), + is_static, + ) + } + _ => return lens, + }; + let Ok(origin_uri) = Url::parse(uri) else { + return lens; + }; + + lens.command = Some(Self::reference_lens_command( + origin_uri, position, locations, + )); + lens + } + /// Build the "which tests cover this class" lens for a class /// declaration, from the test classes whose PHPUnit coverage metadata /// (`@covers` / `@uses` / `#[CoversClass]` and friends) names it. diff --git a/src/lib.rs b/src/lib.rs index 3c6bbf1a6..aa182a1bf 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -879,6 +879,12 @@ pub struct Backend { /// this, editors keep showing tokens computed from the pre-edit /// symbol map until the next unrelated request. pub(crate) supports_semantic_tokens_refresh: Arc, + /// Whether the client supports `workspace/codeLens/refresh`. + /// + /// Exact member-reference locations are computed outside the CodeLens + /// request. Supporting clients re-pull once that bounded cache is warm, + /// avoiding a burst of lazy resolve requests for every declaration. + pub(crate) supports_code_lens_refresh: Arc, /// Whether the client supports `workspace/inlayHint/refresh`. /// /// Set during `initialize` from the client's @@ -887,7 +893,7 @@ pub struct Backend { /// without a refresh the editor keeps the hints it pulled before they /// were ready. pub(crate) supports_inlay_hint_refresh: Arc, - /// Reference counts for member declarations, feeding the inlay hints. + /// Exact member references shared by declaration inlay hints and lenses. pub(crate) member_ref_counts: Arc, /// Set to `true` once `initialized` finishes indexing (PSR-4, /// classmap, stubs, vendor). Background workers and the pull @@ -1147,6 +1153,7 @@ impl Backend { ), supports_show_document: Arc::new(std::sync::atomic::AtomicBool::new(false)), supports_semantic_tokens_refresh: Arc::new(std::sync::atomic::AtomicBool::new(false)), + supports_code_lens_refresh: Arc::new(std::sync::atomic::AtomicBool::new(false)), supports_inlay_hint_refresh: Arc::new(std::sync::atomic::AtomicBool::new(false)), member_ref_counts: reference_counts::new_member_ref_counts(), init_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), @@ -1254,6 +1261,7 @@ impl Backend { ), supports_show_document: Arc::new(std::sync::atomic::AtomicBool::new(false)), supports_semantic_tokens_refresh: Arc::new(std::sync::atomic::AtomicBool::new(false)), + supports_code_lens_refresh: Arc::new(std::sync::atomic::AtomicBool::new(false)), supports_inlay_hint_refresh: Arc::new(std::sync::atomic::AtomicBool::new(false)), member_ref_counts: reference_counts::new_member_ref_counts(), init_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), @@ -1893,6 +1901,7 @@ impl Backend { ), supports_show_document: Arc::clone(&self.supports_show_document), supports_semantic_tokens_refresh: Arc::clone(&self.supports_semantic_tokens_refresh), + supports_code_lens_refresh: Arc::clone(&self.supports_code_lens_refresh), supports_inlay_hint_refresh: Arc::clone(&self.supports_inlay_hint_refresh), member_ref_counts: Arc::clone(&self.member_ref_counts), init_complete: Arc::clone(&self.init_complete), diff --git a/src/mem_audit.rs b/src/mem_audit.rs index 92cbec5c0..8b0f133f7 100644 --- a/src/mem_audit.rs +++ b/src/mem_audit.rs @@ -217,6 +217,8 @@ fn variant_name(t: &PhpType) -> &'static str { TypeKind::IndexAccess(..) => "IndexAccess", TypeKind::Literal(_) => "Literal", TypeKind::Raw(_) => "Raw", + TypeKind::Benevolent(_) => "Benevolent", + TypeKind::ListShape(_) => "ListShape", } } @@ -248,7 +250,12 @@ fn ty(t: &PhpType) -> Sz { match t.kind() { TypeKind::Named(_) | TypeKind::StaticType(_) | TypeKind::ThisType(_) => {} - TypeKind::Nullable(b) | TypeKind::Array(b) | TypeKind::KeyOf(b) | TypeKind::ValueOf(b) => { + TypeKind::Nullable(b) + | TypeKind::Array(b) + | TypeKind::KeyOf(b) + | TypeKind::ValueOf(b) + | TypeKind::Benevolent(b) + | TypeKind::ListShape(b) => { z.slot(1); z += ty(b); } @@ -1378,6 +1385,17 @@ pub(crate) fn report(backend: &Backend, runner_content_bytes: usize) { refs.allocs, ); + let (member_names, member_entries, member_locations, member_bytes, member_allocations) = + backend.member_ref_counts.audit_heap(); + eprintln!( + "── member_reference_cache: {} names, {} declarations, {} locations, {:.1} MB ({} allocs)", + member_names, + member_entries, + member_locations, + mb(member_bytes), + member_allocations, + ); + // ── 7. Remaining session stores ───────────────────────────────── let mut open = Sz::default(); { @@ -1627,6 +1645,9 @@ pub(crate) fn report(backend: &Backend, runner_content_bytes: usize) { probe("member_completion_cache", &mut || { backend.member_completion_cache.lock().clear() }); + probe("member_reference_cache", &mut || { + backend.member_ref_counts.clear_cached() + }); probe("auth_user_type_cache", &mut || { backend.auth_user_type_cache.write().clear() }); diff --git a/src/reference_counts.rs b/src/reference_counts.rs index 2cc2d3ed5..4753adf1f 100644 --- a/src/reference_counts.rs +++ b/src/reference_counts.rs @@ -1,18 +1,20 @@ -//! Cached member reference counts for the declaration inlay hints. +//! Cached exact member references for declaration annotations. //! //! A count shown next to a declaration has to mean "references to *this* //! symbol", which is the search Find References runs: resolve the receiver //! of every candidate access and keep the ones whose type is in the //! declaring class' hierarchy. That search is far too slow for the -//! inlay-hint request path (hundreds of milliseconds for one member on a -//! large project), so counts are computed on a background thread and -//! served from this cache. +//! inlay-hint and CodeLens request paths (hundreds of milliseconds for one +//! member on a large project), so exact locations are computed on a background +//! thread and served from this bounded cache. Inlay hints read their count; +//! CodeLens reuses the locations when the user opens the reference list. //! //! A hint is emitted only for a member whose count is already cached, and //! a cached value keeps being served once it goes stale so the annotation -//! does not blink out between edits. The reference index marks entries -//! stale rather than dropping them, and the next inlay-hint request queues -//! them for recomputation. +//! does not blink out between edits. Clickable lenses require fresh locations +//! and are omitted for refresh-capable clients until recomputation finishes. +//! The reference index marks entries stale rather than dropping them, and the +//! next annotation request queues them for recomputation. use std::collections::hash_map::DefaultHasher; use std::collections::{HashMap, HashSet}; @@ -21,6 +23,7 @@ use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use parking_lot::{Mutex, RwLock}; +use tower_lsp::lsp_types::{Location, Range, Url}; use crate::Backend; use crate::atom::{Atom, AtomMap}; @@ -31,6 +34,12 @@ use crate::class_lookup::find_class_at_offset; /// whole: keeping it in LRU order would cost more than recomputing. const MAX_CACHED_MEMBERS: usize = 20_000; +/// Keep exact locations only while their aggregate stays small enough for an +/// interactive cache. Counts remain cacheable for unusually popular symbols. +const MAX_CACHED_LOCATIONS: usize = 50_000; +const MAX_LOCATIONS_PER_MEMBER: usize = 5_000; +const MAX_CACHED_URIS: usize = 50_000; + /// A member declaration whose count still has to be computed. #[derive(Clone, PartialEq, Eq, Hash)] struct PendingCount { @@ -42,12 +51,33 @@ struct PendingCount { is_static: bool, } -#[derive(Clone, Copy)] -struct CachedCount { +#[derive(Clone)] +struct CachedReferences { count: u32, + locations: Option>, /// Set when the reference index changed in a way that can affect this /// count. The value is still served; it is only a recompute request. - stale: bool, + count_stale: bool, + /// Set after any source edit, since receiver type resolution can change + /// without changing the indexed member name or candidate count. + locations_stale: bool, +} + +/// A cached LSP location without a separately allocated `Url` string for +/// every occurrence. URI strings are interned per cache below. +#[derive(Clone, PartialEq, Eq)] +struct CompactLocation { + uri: Arc, + range: Range, +} + +impl CompactLocation { + fn to_lsp(&self) -> Option { + Some(Location { + uri: Url::parse(&self.uri).ok()?, + range: self.range, + }) + } } /// Per-member-name counts, keyed by the class that declares the member. @@ -56,11 +86,18 @@ struct CachedCount { /// reference index can invalidate at: a file that gains or loses an access /// to `save` can only change counts of members named `save`. The two /// slots are the instance and static member of that name. -type MemberCounts = AtomMap<[Option; 2]>; +type MemberCounts = AtomMap<[Option; 2]>; + +#[derive(Default)] +struct ReferenceCache { + by_member: AtomMap, + location_count: usize, + uris: HashSet>, +} #[derive(Default)] pub(crate) struct MemberRefCounts { - counts: RwLock>, + counts: RwLock, pending: Mutex>, /// Per-file digest of the inheritance each class declares, so a file /// that starts extending something can be told from one that only @@ -69,6 +106,10 @@ pub(crate) struct MemberRefCounts { /// Set while a background computation runs, so a burst of inlay-hint /// requests schedules one job rather than one each. computing: AtomicBool, + /// Serialises exact searches started by background refreshes and lazy + /// CodeLens resolves. A resolve that races the worker reuses its result + /// instead of launching the same expensive scan twice. + compute_lock: Mutex<()>, } fn slot(is_static: bool) -> usize { @@ -76,39 +117,114 @@ fn slot(is_static: bool) -> usize { } impl MemberRefCounts { - fn get(&self, class_fqn: Atom, member: Atom, is_static: bool) -> Option { - self.counts.read().get(&member)?.get(&class_fqn)?[slot(is_static)] + fn get(&self, class_fqn: Atom, member: Atom, is_static: bool) -> Option { + self.counts.read().by_member.get(&member)?.get(&class_fqn)?[slot(is_static)].clone() } - /// Store a freshly computed count, returning whether it differs from - /// the one the editor was last given. - fn store(&self, class_fqn: Atom, member: Atom, is_static: bool, count: u32) -> bool { - let mut counts = self.counts.write(); - if counts.len() >= MAX_CACHED_MEMBERS { - counts.clear(); + /// Store freshly computed references, returning whether they differ from + /// the result the editor was last given. + fn store( + &self, + class_fqn: Atom, + member: Atom, + is_static: bool, + locations: Vec, + ) -> bool { + let mut cache = self.counts.write(); + let previous = cache + .by_member + .get(&member) + .and_then(|members| members.get(&class_fqn)) + .and_then(|slots| slots[slot(is_static)].clone()); + let previous_location_count = previous + .as_ref() + .and_then(|cached| cached.locations.as_ref()) + .map_or(0, |locations| locations.len()); + let count = locations.len() as u32; + let cache_locations = locations.len() <= MAX_LOCATIONS_PER_MEMBER; + let new_location_count = if cache_locations { locations.len() } else { 0 }; + + if cache.by_member.len() >= MAX_CACHED_MEMBERS + || cache.location_count - previous_location_count + new_location_count + > MAX_CACHED_LOCATIONS + || cache.uris.len() >= MAX_CACHED_URIS + { + cache.by_member.clear(); + cache.location_count = 0; + cache.uris.clear(); + } else { + cache.location_count -= previous_location_count; } - let entry = &mut counts + + let cached_locations = cache_locations.then(|| { + let locations: Vec = locations + .into_iter() + .map(|location| { + let uri = match cache.uris.get(location.uri.as_str()) { + Some(uri) => Arc::clone(uri), + None => { + let uri: Arc = Arc::from(location.uri.as_str()); + cache.uris.insert(Arc::clone(&uri)); + uri + } + }; + CompactLocation { + uri, + range: location.range, + } + }) + .collect(); + Arc::<[CompactLocation]>::from(locations) + }); + + let changed = previous.as_ref().is_none_or(|cached| { + cached.count_stale + || cached.locations_stale + || cached.count != count + || cached.locations.as_deref() != cached_locations.as_deref() + }); + let entry = &mut cache + .by_member .entry(member) .or_default() .entry(class_fqn) .or_default()[slot(is_static)]; - let changed = entry.is_none_or(|cached| cached.count != count); - *entry = Some(CachedCount { + *entry = Some(CachedReferences { count, - stale: false, + locations: cached_locations, + count_stale: false, + locations_stale: false, }); + cache.location_count += new_location_count; changed } /// Mark every count for members of this name as needing recomputation. pub(crate) fn invalidate_member(&self, member: Atom) { - let mut counts = self.counts.write(); - let Some(entries) = counts.get_mut(&member) else { + let mut cache = self.counts.write(); + let Some(entries) = cache.by_member.get_mut(&member) else { return; }; for slots in entries.values_mut() { for cached in slots.iter_mut().flatten() { - cached.stale = true; + cached.count_stale = true; + cached.locations_stale = true; + } + } + } + + /// Mark exact locations stale while preserving cached counts. + /// + /// A source edit can change the resolved receiver class without changing + /// the member name or number of indexed candidates. Counts are invalidated + /// more selectively, but clickable locations must never survive that edit. + pub(crate) fn invalidate_locations_all(&self) { + let mut cache = self.counts.write(); + for entries in cache.by_member.values_mut() { + for slots in entries.values_mut() { + for cached in slots.iter_mut().flatten() { + cached.locations_stale = true; + } } } } @@ -118,11 +234,12 @@ impl MemberRefCounts { /// Used when a class' place in the inheritance graph changes, since /// that moves which accesses belong to which declaration. pub(crate) fn invalidate_all(&self) { - let mut counts = self.counts.write(); - for entries in counts.values_mut() { + let mut cache = self.counts.write(); + for entries in cache.by_member.values_mut() { for slots in entries.values_mut() { for cached in slots.iter_mut().flatten() { - cached.stale = true; + cached.count_stale = true; + cached.locations_stale = true; } } } @@ -136,7 +253,55 @@ impl MemberRefCounts { /// hint has been asked for, and the reference index skips its /// invalidation bookkeeping until then. pub(crate) fn is_empty(&self) -> bool { - self.counts.read().is_empty() + self.counts.read().by_member.is_empty() + } + + #[cfg(feature = "mem-audit")] + pub(crate) fn audit_heap(&self) -> (usize, usize, usize, usize, usize) { + use std::mem::size_of; + + let cache = self.counts.read(); + let mut bytes = + cache.by_member.capacity() * (size_of::() + size_of::() + 1); + let mut allocations = usize::from(cache.by_member.capacity() > 0); + let mut entries = 0usize; + for members in cache.by_member.values() { + bytes += members.capacity() + * (size_of::() + size_of::<[Option; 2]>() + 1); + allocations += usize::from(members.capacity() > 0); + for cached in members.values().flat_map(|slots| slots.iter().flatten()) { + entries += 1; + if let Some(locations) = &cached.locations { + bytes += + size_of::() * 2 + locations.len() * size_of::(); + allocations += 1; + } + } + } + bytes += cache.uris.capacity() * (size_of::>() + 1); + allocations += usize::from(cache.uris.capacity() > 0); + for uri in &cache.uris { + bytes += size_of::() * 2 + uri.len(); + allocations += 1; + } + ( + cache.by_member.len(), + entries, + cache.location_count, + bytes, + allocations, + ) + } + + #[cfg(feature = "mem-audit")] + pub(crate) fn clear_cached(&self) { + let mut cache = self.counts.write(); + cache.by_member.clear(); + cache.location_count = 0; + cache.uris.clear(); + drop(cache); + self.pending.lock().clear(); + self.class_shapes.write().clear(); } } @@ -196,18 +361,101 @@ impl Backend { is_static: bool, ) -> Option { let cached = self.member_ref_counts.get(class_fqn, member, is_static); - if cached.is_none_or(|cached| cached.stale) { - self.member_ref_counts.pending.lock().insert(PendingCount { - uri: Arc::from(uri), - offset, - class_fqn, - member, - is_static, - }); + if cached.as_ref().is_none_or(|cached| cached.count_stale) { + self.queue_member_references(uri, offset, class_fqn, member, is_static); } cached.map(|cached| cached.count) } + /// Fresh exact locations for a member declaration, if already cached. + /// Missing or stale entries are queued for the shared background worker. + pub(crate) fn member_ref_locations_cached( + &self, + uri: &str, + offset: u32, + class_fqn: Atom, + member: Atom, + is_static: bool, + ) -> Option> { + let cached = self.member_ref_counts.get(class_fqn, member, is_static); + if cached + .as_ref() + .is_none_or(|cached| cached.count_stale || cached.locations_stale) + { + self.queue_member_references(uri, offset, class_fqn, member, is_static); + } + cached.and_then(|cached| { + if cached.count_stale || cached.locations_stale { + return None; + } + cached.locations.map(|locations| { + locations + .iter() + .filter_map(CompactLocation::to_lsp) + .collect() + }) + }) + } + + fn queue_member_references( + &self, + uri: &str, + offset: u32, + class_fqn: Atom, + member: Atom, + is_static: bool, + ) { + self.member_ref_counts.pending.lock().insert(PendingCount { + uri: Arc::from(uri), + offset, + class_fqn, + member, + is_static, + }); + } + + /// Exact locations for a lazy CodeLens resolve, reusing a fresh cache hit + /// or computing and storing the declaration once under the shared search + /// lock. + pub(crate) fn resolve_member_ref_locations( + &self, + uri: &str, + offset: u32, + class_fqn: Atom, + member: Atom, + is_static: bool, + ) -> Vec { + if let Some(locations) = + self.member_ref_locations_cached(uri, offset, class_fqn, member, is_static) + { + return locations; + } + + let _compute_guard = self.member_ref_counts.compute_lock.lock(); + if let Some(cached) = self.member_ref_counts.get(class_fqn, member, is_static) + && !cached.count_stale + && !cached.locations_stale + && let Some(locations) = cached.locations + { + return locations + .iter() + .filter_map(CompactLocation::to_lsp) + .collect(); + } + + let locations = self.member_declaration_references(uri, offset, &member, is_static); + self.member_ref_counts + .store(class_fqn, member, is_static, locations.clone()); + self.member_ref_counts.pending.lock().remove(&PendingCount { + uri: Arc::from(uri), + offset, + class_fqn, + member, + is_static, + }); + locations + } + /// Compute every queued member reference count. /// /// Returns `true` when at least one count changed, which is the signal @@ -215,6 +463,7 @@ impl Backend { /// References runs, so the number matches what the user gets when they /// follow it. pub(crate) fn compute_pending_member_ref_counts(&self) -> bool { + let _compute_guard = self.member_ref_counts.compute_lock.lock(); // Taken rather than drained: a request that arrives while this // runs sees the counts it wants still stale and queues them // again, and clearing them at the end keeps that from buying a @@ -242,7 +491,7 @@ impl Backend { if !self.declaration_still_at(item) { continue; } - let count = self.member_declaration_reference_count( + let locations = self.member_declaration_references( &item.uri, item.offset, &item.member, @@ -252,7 +501,7 @@ impl Backend { item.class_fqn, item.member, item.is_static, - count as u32, + locations, ); } @@ -306,10 +555,13 @@ impl Backend { match changed { Some(true) => { - if backend.supports_inlay_hint_refresh.load(Ordering::Acquire) - && let Some(ref client) = backend.client - { - let _ = client.inlay_hint_refresh().await; + if let Some(ref client) = backend.client { + if backend.supports_inlay_hint_refresh.load(Ordering::Acquire) { + let _ = client.inlay_hint_refresh().await; + } + if backend.supports_code_lens_refresh.load(Ordering::Acquire) { + let _ = client.code_lens_refresh().await; + } } } Some(false) => {} @@ -384,6 +636,30 @@ mod tests { }) } + #[test] + fn exact_location_cache_is_bounded_and_interns_uris() { + let cache = MemberRefCounts::default(); + let location = Location { + uri: Url::parse("file:///uses.php").unwrap(), + range: Range::new(Position::new(1, 2), Position::new(1, 6)), + }; + + for index in 0..=MAX_CACHED_LOCATIONS / MAX_LOCATIONS_PER_MEMBER { + cache.store( + crate::atom::atom("Order"), + crate::atom::atom(&format!("member{index}")), + false, + vec![location.clone(); MAX_LOCATIONS_PER_MEMBER], + ); + } + + let state = cache.counts.read(); + assert!(state.location_count <= MAX_CACHED_LOCATIONS); + assert_eq!(state.location_count, MAX_LOCATIONS_PER_MEMBER); + assert_eq!(state.by_member.len(), 1); + assert_eq!(state.uris.len(), 1); + } + const ONE_CALL: &str = r#"save();", "$order->save();\n $order->save();"); parse(&backend, &edited); + assert!( + backend + .member_ref_locations_cached( + URI, + declaration_offset, + crate::atom::atom("Order"), + crate::atom::atom("save"), + false, + ) + .is_none(), + "stale locations must not be served to a clickable lens" + ); + // The count the editor already has keeps being served until the // new one is ready, so the annotation does not blink out. assert_eq!( @@ -418,6 +721,88 @@ function persist(Order $order): void { count_on_line(&hints(&backend, &edited), 2).as_deref(), Some(" 2 references") ); + assert_eq!( + backend + .member_ref_locations_cached( + URI, + declaration_offset, + crate::atom::atom("Order"), + crate::atom::atom("save"), + false, + ) + .expect("edited exact locations should replace the stale cache") + .len(), + 2 + ); + } + + #[test] + fn changing_only_a_receiver_type_invalidates_cached_locations() { + const ORDER_URI: &str = "file:///Order.php"; + const BUYER_URI: &str = "file:///Buyer.php"; + const CONSUMER_URI: &str = "file:///Consumer.php"; + let backend = Backend::new_test(); + let order = "save(); }\n"; + parse_extra(&backend, ORDER_URI, order); + parse_extra(&backend, BUYER_URI, buyer); + parse_extra(&backend, CONSUMER_URI, consumer); + + let declaration_offset = order.find("save").unwrap() as u32; + assert!( + backend + .member_ref_locations_cached( + ORDER_URI, + declaration_offset, + crate::atom::atom("Order"), + crate::atom::atom("save"), + false, + ) + .is_none() + ); + backend.compute_pending_member_ref_counts(); + assert_eq!( + backend + .member_ref_locations_cached( + ORDER_URI, + declaration_offset, + crate::atom::atom("Order"), + crate::atom::atom("save"), + false, + ) + .unwrap() + .len(), + 1 + ); + + let edited = consumer.replace("Order $value", "Buyer $value"); + parse_extra(&backend, CONSUMER_URI, &edited); + assert!( + backend + .member_ref_locations_cached( + ORDER_URI, + declaration_offset, + crate::atom::atom("Order"), + crate::atom::atom("save"), + false, + ) + .is_none(), + "a type-only edit must not leave a clickable lens pointing at stale locations" + ); + backend.compute_pending_member_ref_counts(); + assert!( + backend + .member_ref_locations_cached( + ORDER_URI, + declaration_offset, + crate::atom::atom("Order"), + crate::atom::atom("save"), + false, + ) + .unwrap() + .is_empty() + ); } #[test] diff --git a/src/reference_index.rs b/src/reference_index.rs index 4b7b6d6f9..680d67796 100644 --- a/src/reference_index.rs +++ b/src/reference_index.rs @@ -154,6 +154,9 @@ impl Backend { }; evict_reference_index_uri_locked(&mut index, uri); drop(index); + if track_members { + self.member_ref_counts.invalidate_locations_all(); + } for name in dropped.into_keys() { self.member_ref_counts.invalidate_member(name); } @@ -178,6 +181,26 @@ impl Backend { Some(uris) } + /// Number of indexed reference occurrences for `key`. + /// + /// `None` means the workspace index cannot answer yet. A returned zero + /// is conclusive even for the deliberately coarse member keys: semantic + /// filtering can remove name matches, but it cannot create a reference + /// that the symbol map did not index. + pub(crate) fn indexed_reference_count(&self, key: &ReferenceIndexKey) -> Option { + if self.skip_reference_index || !self.workspace_indexed.load(Ordering::Acquire) { + return None; + } + + Some( + self.reference_index + .read() + .get(key) + .map(|entries| entries.values().map(|&count| count as usize).sum()) + .unwrap_or(0), + ) + } + /// Narrow `uris` to the files that reference one of `keys`. /// /// A file the index does not track at all is kept: the index skips @@ -251,6 +274,10 @@ impl Backend { .filter_map(|(idx, item)| keep[idx].then_some(item)) .collect(); + if track_members && !rebuilt.is_empty() { + self.member_ref_counts.invalidate_locations_all(); + } + // Which member names each file contributed a reference to, so the // counts cached for those members can be marked stale. Only the // members whose contribution actually changed are invalidated: diff --git a/src/references/members.rs b/src/references/members.rs index 4667f0a24..07ec82a2a 100644 --- a/src/references/members.rs +++ b/src/references/members.rs @@ -168,18 +168,18 @@ impl Backend { push_unique_location(locations, &parsed_uri, start, end); } - /// Count the references to a member declaration, scoped to the class + /// Find the references to a member declaration, scoped to the class /// hierarchy that declares it. /// /// This is the same search Find References runs on the declaration, so /// the number matches what the user sees when they follow the hint. - pub(crate) fn member_declaration_reference_count( + pub(crate) fn member_declaration_references( &self, uri: &str, offset: u32, member_name: &str, is_static: bool, - ) -> usize { + ) -> Vec { let mode = ReferenceSearchMode::References; let hierarchy = self.resolve_member_declaration_hierarchy(uri, offset, member_name, is_static, mode); @@ -192,7 +192,6 @@ impl Backend { hierarchy.as_ref(), declaration_scope.as_ref(), ) - .len() } /// Find all references to a member (method, property, or constant) diff --git a/src/server.rs b/src/server.rs index 828d2fdb1..11d145f12 100644 --- a/src/server.rs +++ b/src/server.rs @@ -177,6 +177,16 @@ impl LanguageServer for Backend { self.supports_semantic_tokens_refresh .store(client_supports_semantic_tokens_refresh, Ordering::Release); + let client_supports_code_lens_refresh = params + .capabilities + .workspace + .as_ref() + .and_then(|ws| ws.code_lens.as_ref()) + .and_then(|code_lens| code_lens.refresh_support) + .unwrap_or(false); + self.supports_code_lens_refresh + .store(client_supports_code_lens_refresh, Ordering::Release); + // Reference counts on declarations are computed off the request // path, so the hints an editor holds are the ones from before the // counts landed unless it can be asked to re-pull them. @@ -274,7 +284,7 @@ impl LanguageServer for Backend { workspace_symbol_provider: Some(OneOf::Left(true)), folding_range_provider: Some(FoldingRangeProviderCapability::Simple(true)), code_lens_provider: Some(CodeLensOptions { - resolve_provider: Some(false), + resolve_provider: Some(true), }), selection_range_provider: Some(SelectionRangeProviderCapability::Simple(true)), document_formatting_provider: Some(OneOf::Left(true)), @@ -878,6 +888,12 @@ impl LanguageServer for Backend { { let _ = client.inlay_hint_refresh().await; } + if refresh_backend + .supports_code_lens_refresh + .load(Ordering::Acquire) + { + let _ = client.code_lens_refresh().await; + } } }); } @@ -1471,12 +1487,25 @@ impl LanguageServer for Backend { let uri = params.text_document.uri.to_string(); let backend = self.clone_for_blocking(); let u = uri.clone(); - self.coalesced_whole_file("code_lens", &uri, move || { - backend.handle_with_uri("code_lens", &u, |content| { - backend.handle_code_lens(&u, content) + let lenses = self + .coalesced_whole_file("code_lens", &uri, move || { + backend.handle_with_uri("code_lens", &u, |content| { + backend.handle_code_lens(&u, content) + }) }) + .await; + self.schedule_member_ref_counts(); + lenses + } + + async fn code_lens_resolve(&self, params: CodeLens) -> Result { + let fallback = params.clone(); + let backend = self.clone_for_blocking(); + Ok(run_blocking_cancel_safe("code_lens_resolve", move || { + backend.resolve_code_lens_item(params) }) .await + .unwrap_or(fallback)) } async fn execute_command( @@ -1909,6 +1938,13 @@ impl Backend { { let _ = client.inlay_hint_refresh().await; } + if progress_backend + .supports_code_lens_refresh + .load(Ordering::Acquire) + && let Some(ref client) = progress_backend.client + { + let _ = client.code_lens_refresh().await; + } // With the whole workspace parsed, eagerly resolve every // class so interactive requests hit a warm cache. This diff --git a/tests/integration/code_lens.rs b/tests/integration/code_lens.rs index 651fae2a4..3569c6b31 100644 --- a/tests/integration/code_lens.rs +++ b/tests/integration/code_lens.rs @@ -1,4 +1,5 @@ use crate::common::{create_psr4_workspace, create_test_backend}; +use tower_lsp::LanguageServer; use tower_lsp::lsp_types::*; /// Helper: open a file in the backend and return its code lenses. @@ -15,6 +16,344 @@ fn lens_titles(lenses: &[CodeLens]) -> Vec<&str> { .collect() } +async fn open_doc(backend: &phpantom_lsp::Backend, uri: Url, text: &str) { + backend + .did_open(DidOpenTextDocumentParams { + text_document: TextDocumentItem { + uri, + language_id: "php".to_string(), + version: 1, + text: text.to_string(), + }, + }) + .await; +} + +#[tokio::test] +async fn zero_candidate_reference_lenses_need_no_resolve_requests() { + let content = r#" = lenses + .iter() + .filter(|lens| { + lens.command + .as_ref() + .is_some_and(|command| command.title.ends_with("references")) + }) + .collect(); + + assert_eq!(reference_lenses.len(), 33); + assert!(reference_lenses.iter().all(|lens| { + lens.command + .as_ref() + .is_some_and(|command| command.title == "0 references") + && lens.data.is_none() + })); +} + +#[tokio::test] +async fn member_reference_lens_resolves_only_the_declaring_hierarchy() { + let order = r#"save(); + $order->save(); +} +"#; + let unrelated = r#"save(); + $value->save(); + $value->save(); +} +"#; + let (backend, dir) = create_psr4_workspace( + r#"{ "autoload": { "psr-4": { "App\\": "src/" } } }"#, + &[("src/Order.php", order), ("src/Unrelated.php", unrelated)], + ); + let uri = Url::from_file_path(dir.path().join("src/Order.php")).unwrap(); + open_doc(&backend, uri.clone(), order).await; + + backend + .references(ReferenceParams { + text_document_position: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { uri: uri.clone() }, + position: Position::new(3, 20), + }, + context: ReferenceContext { + include_declaration: false, + }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + }) + .await + .unwrap(); + + let lenses = backend + .code_lens(CodeLensParams { + text_document: TextDocumentIdentifier { uri: uri.clone() }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + }) + .await + .unwrap() + .expect("expected declaration reference lenses"); + let lens = lenses + .into_iter() + .find(|lens| lens.range.start.line == 3 && lens.command.is_none()) + .expect("expected an unresolved reference lens above Order::save"); + + let resolved = backend + .code_lens_resolve(lens) + .await + .expect("reference lens should resolve"); + assert_eq!( + resolved + .command + .as_ref() + .map(|command| command.title.as_str()), + Some("2 references") + ); + let locations: Vec = serde_json::from_value( + resolved + .command + .as_ref() + .and_then(|command| command.arguments.as_ref()) + .and_then(|arguments| arguments.get(2)) + .cloned() + .expect("expected reference locations"), + ) + .expect("reference targets should be locations"); + assert_eq!(locations.len(), 2); + assert!(locations.iter().all(|location| location.uri == uri)); +} + +#[tokio::test] +async fn refresh_capable_clients_receive_only_warm_member_reference_lenses() { + let content = r#"save(); +} +"#; + let (backend, dir) = create_psr4_workspace( + r#"{ "autoload": { "psr-4": { "App\\": "src/" } } }"#, + &[("src/Order.php", content)], + ); + let initialize = backend + .initialize( + serde_json::from_value(serde_json::json!({ + "capabilities": { + "workspace": { + "codeLens": { "refreshSupport": true } + } + } + })) + .unwrap(), + ) + .await + .unwrap(); + assert!(matches!( + initialize.capabilities.code_lens_provider, + Some(CodeLensOptions { + resolve_provider: Some(true) + }) + )); + + let uri = Url::from_file_path(dir.path().join("src/Order.php")).unwrap(); + open_doc(&backend, uri.clone(), content).await; + backend + .references(ReferenceParams { + text_document_position: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { uri: uri.clone() }, + position: Position::new(3, 20), + }, + context: ReferenceContext { + include_declaration: false, + }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + }) + .await + .unwrap(); + + let params = CodeLensParams { + text_document: TextDocumentIdentifier { uri }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + }; + let cold = backend + .code_lens(params.clone()) + .await + .unwrap() + .unwrap_or_default(); + assert!( + cold.iter().all(|lens| lens.range.start.line != 3), + "a cold member lens would make the client resolve it eagerly: {cold:?}" + ); + + let warm = tokio::time::timeout(std::time::Duration::from_secs(2), async { + loop { + let lenses = backend + .code_lens(params.clone()) + .await + .unwrap() + .unwrap_or_default(); + if let Some(lens) = lenses.into_iter().find(|lens| { + lens.range.start.line == 3 + && lens + .command + .as_ref() + .is_some_and(|command| command.title == "1 reference") + }) { + break lens; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + }) + .await + .expect("background member-reference cache did not warm"); + assert!(warm.data.is_none()); +} + +#[tokio::test] +async fn class_and_function_reference_lenses_resolve_exact_locations() { + let content = r#" Date: Thu, 27 Aug 2026 14:31:39 +0200 Subject: [PATCH 02/17] fix(indexing): Reuse workspace index for annotations Stop reference counts and CodeLens resolves from repeating the full workspace walk for every declaration. Internal annotation requests share the initial index, including callers queued behind it, while an explicit Find References command keeps its single refresh for files created without watcher notifications. --- docs/CHANGELOG.md | 1 + src/code_lens.rs | 4 ++- src/indexing/preload.rs | 63 +++++++++++++++++++++++++++++++++++--- src/lib.rs | 11 ++++--- src/references/dispatch.rs | 24 +++++++++++++++ src/references/mod.rs | 4 +-- src/references/tests.rs | 48 +++++++++++++++++++++++++++-- 7 files changed, 141 insertions(+), 14 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 90d336919..3911e5838 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -54,6 +54,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`@phpstan-assert` narrows a property of the receiver, not just an argument.** A tag naming a path through `$this` (`@phpstan-assert bool $this->resolved`) was ignored, because the subject was looked for among the call's arguments and such a call often has none. The lazy-initialiser idiom (`if ($this->resolved === null) { $this->resolve(); } return $this->resolved;`) therefore reported the nullable property as the return value. The tag's `$this` now stands for whatever the call was made on, so it narrows through a variable receiver as well. - **A class named `Scalar` or `Numeric` is a class, not a PHPDoc pseudo-type.** `scalar` and `numeric` have no native spelling in PHP, so a project may name a class either of them, and nikic/php-parser does exactly that with `PhpParser\Node\Scalar`. Any capitalised spelling was folded into the pseudo-type instead of being resolved through the file's imports, which left the name unqualified and every check against it failing: passing a `Scalar` to a parameter typed as its own parent was reported as a mismatch, in a native type hint, a `@param`, a `@return`, and a `@implements` type argument alike. The all-lowercase spellings keep their PHPDoc meaning, which is the same rule already applied to `Number`, `Integer`, `Boolean`, `Double`, and `Resource`. - **An `&&` operand that pins a value to one class outranks a later operand that only lists alternatives.** `$bound instanceof GenericType && ($class === GenericType::class || $bound instanceof TemplateType)` read the two operands as peers and answered `GenericType|TemplateType`, so passing the value on to anything expecting a `GenericType` was reported as a mismatch — even though the first operand alone settles the question. The disjunction can only narrow the value further, never widen it past what was already proven. A disjunction still narrows on its own when nothing in the chain pinned the subject down. +- **Reference and CodeLens annotations reuse the completed workspace index.** Resolving many lenses in a large project no longer starts another full filesystem walk for every declaration. Concurrent annotations share the first indexing pass, while an explicit Find References command still refreshes once so files created without an editor notification remain discoverable. Contributed by @sidux. - **Argument checks accept the widenings PHP performs and the types the engine admits it does not know.** Four shapes of correct code were reported as type mismatches: a bounded `int<0, max>` passed to a `float` parameter, even though PHP widens an integer to a float on the way in; a `class-string` passed to `non-empty-string`, even though a string that names a class always has content; an `array-key` passed to `int` or to `string`, which is the key type of an array nobody described rather than a value measured to be two things; and a closure body doing `$a & $b` on untyped parameters, which produces a string from two strings just as readily as an int from two numbers. - **`get_class($x) === Foo::class` narrows the same subjects `instanceof` does.** The identity check only pinned a plain variable, so `get_class($this->held) === Sub::class` and `get_class($items[0]) === Sub::class` left the subject at its declared type and every member read past the check was reported as missing. A property fetch, an array element, and a call result are all narrowed now, in the `$x::class === Foo::class` spelling as well. - **A global function written with a leading backslash is the same function.** `\get_class($x) === Foo::class` and `\is_a($x, Foo::class)` narrowed nothing, and `if (!\class_exists('Vendor\Optional\Config')) { return; }` read as an un-negated guard, so it protected the `return;` instead of everything after it and the guarded class was reported as not found. A class named in such a guard with escaped backslashes (`'Vendor\\Optional\\Config'`) is now matched against the reference it guards, too. diff --git a/src/code_lens.rs b/src/code_lens.rs index 440382f96..235e8a554 100644 --- a/src/code_lens.rs +++ b/src/code_lens.rs @@ -373,7 +373,9 @@ impl Backend { let Some(content) = self.get_file_content(uri) else { return lens; }; - let Some(locations) = self.find_references(uri, &content, position, false) else { + let Some(locations) = + self.find_references_from_workspace_index(uri, &content, position, false) + else { return lens; }; locations diff --git a/src/indexing/preload.rs b/src/indexing/preload.rs index 5b8d422ff..f51939c7e 100644 --- a/src/indexing/preload.rs +++ b/src/indexing/preload.rs @@ -122,6 +122,26 @@ impl Backend { } } + /// Wait for the initial workspace index when necessary, but reuse a + /// completed index without refreshing the filesystem. + /// + /// Internal consumers such as declaration CodeLens and cached reference + /// counts call this once per symbol. Explicit Find References requests use + /// [`ensure_workspace_indexed_for_request`](Self::ensure_workspace_indexed_for_request) + /// once at their entry point so they retain the existing on-demand refresh + /// that discovers files created without a watcher notification. + pub(crate) fn ensure_workspace_index_ready_for_request(&self) { + match self.request_progress.as_deref() { + Some(state) => { + let forward = |percentage: u32, message: String| { + state.set_percentage(percentage.min(100) * 4 / 5, message); + }; + self.ensure_workspace_index_ready_with_progress(Some(&forward)); + } + None => self.ensure_workspace_index_ready_with_progress(None), + } + } + /// Acquire `workspace_index_lock`, mirroring the in-flight index's own /// progress into `progress` while another thread holds it. /// @@ -190,7 +210,41 @@ impl Backend { &self, progress: Option<&(dyn Fn(u32, String) + Sync)>, ) { + self.ensure_workspace_indexed_with_progress_mode(progress, true); + } + + pub(crate) fn ensure_workspace_index_ready_with_progress( + &self, + progress: Option<&(dyn Fn(u32, String) + Sync)>, + ) { + self.ensure_workspace_indexed_with_progress_mode(progress, false); + } + + fn ensure_workspace_indexed_with_progress_mode( + &self, + progress: Option<&(dyn Fn(u32, String) + Sync)>, + refresh_completed: bool, + ) { + // Reference counts and CodeLens resolution can ask for the complete + // index once per declaration. Once the initial pass has published + // every batch, those requests must reuse it instead of walking the + // workspace again. Watched-file notifications keep the completed + // index current after this point. + if !refresh_completed && self.workspace_indexed.load(Ordering::Acquire) { + return; + } + let _workspace_index_guard = self.acquire_workspace_index_lock(progress); + + // Another request may have completed the index while this one was + // waiting for the single-flight lock. + if !refresh_completed && self.workspace_indexed.load(Ordering::Acquire) { + if let Some(progress) = progress { + progress(100, "Workspace index ready".to_string()); + } + return; + } + let start = std::time::Instant::now(); self.report_workspace_index_progress(progress, 1, "Preparing workspace index"); let existing_uris: HashSet = self.symbol_maps.read().keys().cloned().collect(); @@ -221,10 +275,11 @@ impl Backend { // ── Phase 2: workspace directory scan ─────────────────────────── // - // Even after the initial scan, repeat the walk so newly-created PHP - // files that are not open in the editor can still be discovered. - // The existing-URI filter below keeps this cheap by parsing only files - // that are not already in `symbol_maps`. + // The initial pass discovers every PHP and resource file. Watched-file + // notifications apply later changes incrementally. Explicit reference + // requests may still refresh this walk to discover a file created + // without a watcher event; per-symbol internal consumers only wait for + // the initial pass and reuse it. let workspace_root = self.workspace.workspace_root.read().clone(); let phase1_uri_set: HashSet<&str> = phase1_uris.iter().map(|uri| uri.as_str()).collect(); let phase2_work = if let Some(root) = workspace_root.clone() { diff --git a/src/lib.rs b/src/lib.rs index aa182a1bf..f213c29e6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -928,12 +928,13 @@ pub struct Backend { /// symbol map recorded a candidate site ever get an entry. pub(crate) typed_receiver_view_spans_cache: Arc>>, - /// Whether the workspace directory has been fully scanned for PHP files. + /// Whether the workspace directory has been fully scanned for PHP and + /// resource files. /// - /// Set to `true` after the first Phase 2 walk in `ensure_workspace_indexed`. - /// Subsequent calls still re-walk the directory to discover newly created - /// files, but the flag lets us log the difference between initial and - /// refresh scans. + /// Set to `true` after the initial `ensure_workspace_indexed` pass. + /// Per-symbol consumers reuse that index, watched-file notifications + /// update it incrementally, and an explicit reference search may refresh + /// it once to discover filesystem changes the editor did not report. pub(crate) workspace_indexed: Arc, /// Serializes whole-workspace indexing so a foreground request does not /// duplicate the background full-index parse. diff --git a/src/references/dispatch.rs b/src/references/dispatch.rs index 277fedcae..b3252b1a1 100644 --- a/src/references/dispatch.rs +++ b/src/references/dispatch.rs @@ -27,6 +27,29 @@ impl Backend { position: Position, include_declaration: bool, ) -> Option> { + // Refresh once for the user command so files created without a + // watcher event remain discoverable. The per-symbol scanners below + // only wait for/reuse that completed index. + self.ensure_workspace_indexed_for_request(); + self.find_references_inner( + uri, + content, + position, + include_declaration, + ReferenceSearchMode::References, + ) + } + + /// Resolve declaration annotations against the completed index without + /// turning every CodeLens item into another workspace refresh. + pub(crate) fn find_references_from_workspace_index( + &self, + uri: &str, + content: &str, + position: Position, + include_declaration: bool, + ) -> Option> { + self.ensure_workspace_index_ready_for_request(); self.find_references_inner( uri, content, @@ -45,6 +68,7 @@ impl Backend { position: Position, include_declaration: bool, ) -> Option> { + self.ensure_workspace_indexed_for_request(); self.find_references_inner( uri, content, diff --git a/src/references/mod.rs b/src/references/mod.rs index 6c173db94..41ee68dfc 100644 --- a/src/references/mod.rs +++ b/src/references/mod.rs @@ -67,7 +67,7 @@ impl Backend { /// vendor directory or the internal stub scheme. All four cross-file /// reference scanners use this to restrict results to user code. pub(crate) fn user_file_symbol_maps(&self) -> Vec<(String, Arc)> { - self.ensure_workspace_indexed_for_request(); + self.ensure_workspace_index_ready_for_request(); self.user_file_symbol_maps_matching(None) } @@ -85,7 +85,7 @@ impl Backend { &self, keys: &[ReferenceIndexKey], ) -> Vec<(String, Arc)> { - self.ensure_workspace_indexed_for_request(); + self.ensure_workspace_index_ready_for_request(); let candidate_uris = self.reference_candidate_uris_for_keys(keys); self.user_file_symbol_maps_matching(candidate_uris.as_ref()) } diff --git a/src/references/tests.rs b/src/references/tests.rs index 5400087ac..a2e1ed923 100644 --- a/src/references/tests.rs +++ b/src/references/tests.rs @@ -2429,7 +2429,7 @@ fn user_file_symbol_maps_exclude_vendor_and_stubs() { } #[test] -fn workspace_index_progress_covers_known_files_and_refresh_walks() { +fn workspace_index_progress_covers_known_and_discovered_files() { let dir = tempfile::tempdir().expect("temp dir"); let src = dir.path().join("src"); std::fs::create_dir_all(&src).expect("src dir"); @@ -2510,6 +2510,33 @@ fn workspace_index_progress_covers_known_files_and_refresh_walks() { ); } +/// Once the first workspace pass is complete, each reference-count or +/// CodeLens query must reuse it. In particular, a concurrent caller must not +/// queue behind the workspace lock and begin another disk walk. +#[test] +fn completed_workspace_index_is_reused_without_waiting() { + let backend = Backend::new_test(); + backend + .workspace_indexed + .store(true, std::sync::atomic::Ordering::Release); + let indexing = backend.workspace_index_lock.lock(); + + let (done_tx, done_rx) = std::sync::mpsc::channel(); + let waiter = { + let backend = backend.clone_for_blocking(); + std::thread::spawn(move || { + backend.ensure_workspace_index_ready_with_progress(None); + done_tx.send(()).expect("report completion"); + }) + }; + + done_rx + .recv_timeout(std::time::Duration::from_secs(1)) + .expect("a completed index should bypass the in-flight lock"); + drop(indexing); + waiter.join().expect("waiter thread"); +} + #[test] fn request_progress_maps_indexing_into_lower_window() { let dir = tempfile::tempdir().expect("temp dir"); @@ -2559,7 +2586,7 @@ fn blocked_request_reports_in_flight_index_status() { let backend = backend.clone_for_blocking(); let reports = std::sync::Arc::clone(&reports); std::thread::spawn(move || { - backend.ensure_workspace_indexed_with_progress(Some(&|percentage, message| { + backend.ensure_workspace_index_ready_with_progress(Some(&|percentage, message| { reports .lock() .expect("reports lock") @@ -2583,6 +2610,12 @@ fn blocked_request_reports_in_flight_index_status() { "Waiting for workspace index: Parsing workspace files (3/9)" ); + // Stand in for the lock owner publishing the completed index before it + // releases the single-flight guard. + backend + .workspace_indexed + .store(true, std::sync::atomic::Ordering::Release); + *backend.workspace_index_status.lock() = None; drop(indexing); waiter.join().expect("waiter thread"); @@ -2590,6 +2623,17 @@ fn blocked_request_reports_in_flight_index_status() { backend.workspace_index_status.lock().is_none(), "a finished indexing pass clears the shared status" ); + let reports = reports.lock().expect("reports lock"); + assert!( + !reports + .iter() + .any(|(_, message)| message == "Preparing workspace index"), + "the waiting request must reuse the index published by the lock owner" + ); + assert_eq!( + reports.last(), + Some(&(100, "Workspace index ready".to_string())) + ); } #[test] From be8c516a010fff0e6bd0f2ddec744a8568d7f3ed Mon Sep 17 00:00:00 2001 From: sidux Date: Thu, 27 Aug 2026 15:34:49 +0200 Subject: [PATCH 03/17] fix(indexing): Batch member reference counts --- docs/CHANGELOG.md | 1 + src/code_lens.rs | 33 ++++--- src/reference_counts.rs | 88 ++++++++++++++---- src/references/members.rs | 183 +++++++++++++++++++++++++++++++++++--- src/references/mod.rs | 1 + 5 files changed, 267 insertions(+), 39 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 3911e5838..300a9c624 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -54,6 +54,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`@phpstan-assert` narrows a property of the receiver, not just an argument.** A tag naming a path through `$this` (`@phpstan-assert bool $this->resolved`) was ignored, because the subject was looked for among the call's arguments and such a call often has none. The lazy-initialiser idiom (`if ($this->resolved === null) { $this->resolve(); } return $this->resolved;`) therefore reported the nullable property as the return value. The tag's `$this` now stands for whatever the call was made on, so it narrows through a variable receiver as well. - **A class named `Scalar` or `Numeric` is a class, not a PHPDoc pseudo-type.** `scalar` and `numeric` have no native spelling in PHP, so a project may name a class either of them, and nikic/php-parser does exactly that with `PhpParser\Node\Scalar`. Any capitalised spelling was folded into the pseudo-type instead of being resolved through the file's imports, which left the name unqualified and every check against it failing: passing a `Scalar` to a parameter typed as its own parent was reported as a mismatch, in a native type hint, a `@param`, a `@return`, and a `@implements` type argument alike. The all-lowercase spellings keep their PHPDoc meaning, which is the same rule already applied to `Number`, `Integer`, `Boolean`, `Double`, and `Resource`. - **An `&&` operand that pins a value to one class outranks a later operand that only lists alternatives.** `$bound instanceof GenericType && ($class === GenericType::class || $bound instanceof TemplateType)` read the two operands as peers and answered `GenericType|TemplateType`, so passing the value on to anything expecting a `GenericType` was reported as a mismatch — even though the first operand alone settles the question. The disjunction can only narrow the value further, never widen it past what was already proven. A disjunction still narrows on its own when nothing in the chain pinned the subject down. +- **Reference CodeLens stays responsive in large projects.** Queued member lenses share one semantic pass over their candidate files and one parsed syntax tree per file; clients without lens refresh compute only the lens they resolve. Contributed by @sidux. - **Reference and CodeLens annotations reuse the completed workspace index.** Resolving many lenses in a large project no longer starts another full filesystem walk for every declaration. Concurrent annotations share the first indexing pass, while an explicit Find References command still refreshes once so files created without an editor notification remain discoverable. Contributed by @sidux. - **Argument checks accept the widenings PHP performs and the types the engine admits it does not know.** Four shapes of correct code were reported as type mismatches: a bounded `int<0, max>` passed to a `float` parameter, even though PHP widens an integer to a float on the way in; a `class-string` passed to `non-empty-string`, even though a string that names a class always has content; an `array-key` passed to `int` or to `string`, which is the key type of an array nobody described rather than a value measured to be two things; and a closure body doing `$a & $b` on untyped parameters, which produces a string from two strings just as readily as an int from two numbers. - **`get_class($x) === Foo::class` narrows the same subjects `instanceof` does.** The identity check only pinned a plain variable, so `get_class($this->held) === Sub::class` and `get_class($items[0]) === Sub::class` left the subject at its declared type and every member read past the check was reported as missing. A property fetch, an array element, and a call result are all narrowed now, in the `$x::class === Foo::class` spelling as well. diff --git a/src/code_lens.rs b/src/code_lens.rs index 235e8a554..be9176924 100644 --- a/src/code_lens.rs +++ b/src/code_lens.rs @@ -265,13 +265,27 @@ impl Backend { }); } - if let Some(locations) = self.member_ref_locations_cached( - origin_uri, - declaration_offset, - class_fqn, - member, - is_static, - ) { + let supports_refresh = self + .supports_code_lens_refresh + .load(std::sync::atomic::Ordering::Acquire); + let cached_locations = if supports_refresh { + self.member_ref_locations_cached( + origin_uri, + declaration_offset, + class_fqn, + member, + is_static, + ) + } else { + self.member_ref_locations_ready( + origin_uri, + declaration_offset, + class_fqn, + member, + is_static, + ) + }; + if let Some(locations) = cached_locations { return Some(CodeLens { range, command: Some(Self::reference_lens_command( @@ -284,10 +298,7 @@ impl Backend { // Clients with refresh support can re-pull once the shared background // worker fills the exact cache. Omitting the cold lens avoids an // eager resolve burst merely to obtain titles for the viewport. - if self - .supports_code_lens_refresh - .load(std::sync::atomic::Ordering::Acquire) - { + if supports_refresh { return None; } diff --git a/src/reference_counts.rs b/src/reference_counts.rs index 4753adf1f..722c4e8f0 100644 --- a/src/reference_counts.rs +++ b/src/reference_counts.rs @@ -376,11 +376,40 @@ impl Backend { class_fqn: Atom, member: Atom, is_static: bool, + ) -> Option> { + self.member_ref_locations(uri, offset, class_fqn, member, is_static, true) + } + + /// Fresh exact locations without queuing a background computation. + /// + /// Clients without CodeLens refresh receive a lazy lens and resolve only + /// the entries they display. Avoiding a background queue here prevents + /// that resolve from waiting behind every declaration in the file. + pub(crate) fn member_ref_locations_ready( + &self, + uri: &str, + offset: u32, + class_fqn: Atom, + member: Atom, + is_static: bool, + ) -> Option> { + self.member_ref_locations(uri, offset, class_fqn, member, is_static, false) + } + + fn member_ref_locations( + &self, + uri: &str, + offset: u32, + class_fqn: Atom, + member: Atom, + is_static: bool, + queue_if_missing: bool, ) -> Option> { let cached = self.member_ref_counts.get(class_fqn, member, is_static); - if cached - .as_ref() - .is_none_or(|cached| cached.count_stale || cached.locations_stale) + if queue_if_missing + && cached + .as_ref() + .is_none_or(|cached| cached.count_stale || cached.locations_stale) { self.queue_member_references(uri, offset, class_fqn, member, is_static); } @@ -482,21 +511,26 @@ impl Backend { let _chain_guard = crate::type_engine::resolver::with_chain_resolution_cache(); let _resolver_guard = crate::type_engine::call_resolution::activate_type_engine_caches(); + // A declaration may have moved or gone since the hint was requested. + // Exclude stale offsets before preparing the shared semantic scan so + // they cannot fall back to counting every member of that name. + let valid_pending: Vec<_> = pending + .iter() + .filter(|item| self.declaration_still_at(item)) + .collect(); + let queries: Vec<_> = valid_pending + .iter() + .map(|item| crate::references::MemberDeclarationReferenceQuery { + uri: Arc::clone(&item.uri), + offset: item.offset, + member: item.member, + is_static: item.is_static, + }) + .collect(); + let results = self.member_declaration_references_batch(&queries); + let mut changed = false; - for item in &pending { - // The declaration may have moved or gone since the hint was - // requested, and recomputing against a stale offset would scope - // the search to the wrong class (or to none at all, which falls - // back to counting every member of that name). - if !self.declaration_still_at(item) { - continue; - } - let locations = self.member_declaration_references( - &item.uri, - item.offset, - &item.member, - item.is_static, - ); + for (item, locations) in valid_pending.into_iter().zip(results) { changed |= self.member_ref_counts.store( item.class_fqn, item.member, @@ -669,6 +703,26 @@ function persist(Order $order): void { } "#; + #[test] + fn ready_only_location_lookup_does_not_queue_background_work() { + let backend = Backend::new_test(); + parse(&backend, ONE_CALL); + let declaration_offset = ONE_CALL.find("save").unwrap() as u32; + + assert!( + backend + .member_ref_locations_ready( + URI, + declaration_offset, + crate::atom::atom("Order"), + crate::atom::atom("save"), + false, + ) + .is_none() + ); + assert!(!backend.member_ref_counts.has_pending()); + } + #[test] fn an_edit_that_adds_an_access_recomputes_the_count() { let backend = Backend::new_test(); diff --git a/src/references/members.rs b/src/references/members.rs index 07ec82a2a..51ac35f76 100644 --- a/src/references/members.rs +++ b/src/references/members.rs @@ -13,12 +13,21 @@ use std::collections::HashMap; use tower_lsp::lsp_types::{Location, Range}; +use crate::atom::{Atom, AtomMap}; use crate::class_lookup::find_class_at_offset; use crate::references::push_unique_location; use crate::symbol_map::SymbolKind; use crate::text_position::offset_to_position; use crate::types::ClassInfo; +#[derive(Clone)] +pub(crate) struct MemberDeclarationReferenceQuery { + pub(crate) uri: Arc, + pub(crate) offset: u32, + pub(crate) member: Atom, + pub(crate) is_static: bool, +} + impl Backend { pub(super) fn find_laravel_macro_references( &self, @@ -180,18 +189,170 @@ impl Backend { member_name: &str, is_static: bool, ) -> Vec { - let mode = ReferenceSearchMode::References; - let hierarchy = - self.resolve_member_declaration_hierarchy(uri, offset, member_name, is_static, mode); - let declaration_scope = - self.resolve_member_declaration_scope(uri, offset, member_name, is_static, mode); - self.find_member_references( - member_name, + self.member_declaration_references_batch(&[MemberDeclarationReferenceQuery { + uri: Arc::from(uri), + offset, + member: crate::atom::atom(member_name), is_static, - false, - hierarchy.as_ref(), - declaration_scope.as_ref(), - ) + }]) + .pop() + .unwrap_or_default() + } + + /// Find exact references for several member declarations in one semantic + /// pass over the union of their candidate files. + /// + /// A viewport commonly queues many declarations at once. Resolving each + /// declaration separately reopens the same files and repeats receiver + /// inference for every same-named method in unrelated class hierarchies. + /// This batch resolves each matching access once, then attributes it only + /// to queries whose hierarchy contains the receiver class. + pub(crate) fn member_declaration_references_batch( + &self, + queries: &[MemberDeclarationReferenceQuery], + ) -> Vec> { + struct PreparedQuery { + member: Atom, + is_static: bool, + hierarchy: Option>, + } + + if queries.is_empty() { + return Vec::new(); + } + + let mode = ReferenceSearchMode::References; + let prepared: Vec<_> = queries + .iter() + .map(|query| PreparedQuery { + member: query.member, + is_static: query.is_static, + hierarchy: self.resolve_member_declaration_hierarchy( + &query.uri, + query.offset, + &query.member, + query.is_static, + mode, + ), + }) + .collect(); + + let mut by_member: AtomMap> = AtomMap::default(); + let mut candidate_keys = HashSet::new(); + for (query_index, query) in prepared.iter().enumerate() { + by_member.entry(query.member).or_default().push(query_index); + candidate_keys.extend(member_candidate_keys( + &query.member, + query.is_static, + query.hierarchy.as_ref(), + )); + } + + let candidate_keys: Vec<_> = candidate_keys.into_iter().collect(); + let snapshot = self.user_file_symbol_maps_for_reference_keys(&candidate_keys); + self.begin_request_scan_window(snapshot.len(), "Scanning for member references"); + + let mut locations = vec![Vec::new(); queries.len()]; + for (file_uri, symbol_map) in &snapshot { + self.request_scan_file_done(); + + let mut span_indices = Vec::new(); + for member in by_member.keys() { + span_indices.extend_from_slice(symbol_map.member_access_indices(member)); + } + if span_indices.is_empty() { + continue; + } + span_indices.sort_unstable(); + span_indices.dedup(); + + let Ok(parsed_uri) = Url::parse(file_uri) else { + continue; + }; + let Some(content) = self.reference_file_content_arc(file_uri) else { + continue; + }; + let _parse_cache_guard = crate::parser::with_parse_cache(&content); + let file_ctx = self.file_context(file_uri); + + for span_index in span_indices { + let span = &symbol_map.spans[span_index]; + let SymbolKind::MemberAccess { + member_name, + subject_text, + is_static, + .. + } = &span.kind + else { + continue; + }; + let Some(query_indices) = by_member.get(member_name) else { + continue; + }; + + let needs_receiver = query_indices + .iter() + .any(|&index| prepared[index].hierarchy.is_some()); + let subject_fqns = needs_receiver.then(|| { + self.resolve_subject_to_fqns( + subject_text.as_str(&content), + *is_static, + &file_ctx, + span.start, + &content, + ) + }); + let range = Range::new( + offset_to_position(&content, span.start as usize), + offset_to_position(&content, span.end as usize), + ); + + for &query_index in query_indices { + let query = &prepared[query_index]; + if let Some(hierarchy) = &query.hierarchy { + if !subject_fqns + .as_ref() + .is_some_and(|fqns| fqns.iter().any(|fqn| hierarchy.contains(fqn))) + { + continue; + } + } else if query.is_static != *is_static { + continue; + } + + locations[query_index].push(Location { + uri: parsed_uri.clone(), + range, + }); + } + } + } + + for (query, query_locations) in prepared.iter().zip(&mut locations) { + for location in + self.framework_member_reference_locations(&query.member, query.hierarchy.as_ref()) + { + push_unique_location( + query_locations, + &location.uri, + location.range.start, + location.range.end, + ); + } + for location in + self.framework_property_reference_locations(&query.member, query.hierarchy.as_ref()) + { + push_unique_location( + query_locations, + &location.uri, + location.range.start, + location.range.end, + ); + } + sort_locations_for_references(query_locations); + } + + locations } /// Find all references to a member (method, property, or constant) diff --git a/src/references/mod.rs b/src/references/mod.rs index 41ee68dfc..00c49feec 100644 --- a/src/references/mod.rs +++ b/src/references/mod.rs @@ -36,6 +36,7 @@ mod functions; mod members; mod variables; +pub(crate) use members::MemberDeclarationReferenceQuery; use std::collections::HashSet; use std::path::{Path, PathBuf}; use std::sync::Arc; From 4176e9e2f382e1cdf96be5b50b168c53ace796da Mon Sep 17 00:00:00 2001 From: sidux Date: Thu, 27 Aug 2026 16:04:54 +0200 Subject: [PATCH 04/17] fix(indexing): Reuse forward scopes for member counts --- docs/CHANGELOG.md | 2 +- src/reference_counts.rs | 41 ++++++++++++++++++++++++++ src/references/members.rs | 28 ++++++++++++++++++ src/type_engine/variable/resolution.rs | 15 ++++++++++ 4 files changed, 85 insertions(+), 1 deletion(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 300a9c624..f1d0d74fa 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -54,7 +54,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`@phpstan-assert` narrows a property of the receiver, not just an argument.** A tag naming a path through `$this` (`@phpstan-assert bool $this->resolved`) was ignored, because the subject was looked for among the call's arguments and such a call often has none. The lazy-initialiser idiom (`if ($this->resolved === null) { $this->resolve(); } return $this->resolved;`) therefore reported the nullable property as the return value. The tag's `$this` now stands for whatever the call was made on, so it narrows through a variable receiver as well. - **A class named `Scalar` or `Numeric` is a class, not a PHPDoc pseudo-type.** `scalar` and `numeric` have no native spelling in PHP, so a project may name a class either of them, and nikic/php-parser does exactly that with `PhpParser\Node\Scalar`. Any capitalised spelling was folded into the pseudo-type instead of being resolved through the file's imports, which left the name unqualified and every check against it failing: passing a `Scalar` to a parameter typed as its own parent was reported as a mismatch, in a native type hint, a `@param`, a `@return`, and a `@implements` type argument alike. The all-lowercase spellings keep their PHPDoc meaning, which is the same rule already applied to `Number`, `Integer`, `Boolean`, `Double`, and `Resource`. - **An `&&` operand that pins a value to one class outranks a later operand that only lists alternatives.** `$bound instanceof GenericType && ($class === GenericType::class || $bound instanceof TemplateType)` read the two operands as peers and answered `GenericType|TemplateType`, so passing the value on to anything expecting a `GenericType` was reported as a mismatch — even though the first operand alone settles the question. The disjunction can only narrow the value further, never widen it past what was already proven. A disjunction still narrows on its own when nothing in the chain pinned the subject down. -- **Reference CodeLens stays responsive in large projects.** Queued member lenses share one semantic pass over their candidate files and one parsed syntax tree per file; clients without lens refresh compute only the lens they resolve. Contributed by @sidux. +- **Reference CodeLens stays responsive in large projects.** Queued member lenses share one semantic pass over their candidate files, one parsed syntax tree per file, and one forward-walked variable scope per method body; clients without lens refresh compute only the lens they resolve. Contributed by @sidux. - **Reference and CodeLens annotations reuse the completed workspace index.** Resolving many lenses in a large project no longer starts another full filesystem walk for every declaration. Concurrent annotations share the first indexing pass, while an explicit Find References command still refreshes once so files created without an editor notification remain discoverable. Contributed by @sidux. - **Argument checks accept the widenings PHP performs and the types the engine admits it does not know.** Four shapes of correct code were reported as type mismatches: a bounded `int<0, max>` passed to a `float` parameter, even though PHP widens an integer to a float on the way in; a `class-string` passed to `non-empty-string`, even though a string that names a class always has content; an `array-key` passed to `int` or to `string`, which is the key type of an array nobody described rather than a value measured to be two things; and a closure body doing `$a & $b` on untyped parameters, which produces a string from two strings just as readily as an int from two numbers. - **`get_class($x) === Foo::class` narrows the same subjects `instanceof` does.** The identity check only pinned a plain variable, so `get_class($this->held) === Sub::class` and `get_class($items[0]) === Sub::class` left the subject at its declared type and every member read past the check was reported as missing. A property fetch, an array element, and a call result are all narrowed now, in the `$x::class === Foo::class` spelling as well. diff --git a/src/reference_counts.rs b/src/reference_counts.rs index 722c4e8f0..5c07bf910 100644 --- a/src/reference_counts.rs +++ b/src/reference_counts.rs @@ -703,6 +703,47 @@ function persist(Order $order): void { } "#; + #[test] + fn batch_member_counts_reuse_forward_walked_scope_snapshots() { + const ORDER_URI: &str = "file:///Order.php"; + const CONSUMER_URI: &str = "file:///Consumer.php"; + const ORDER: &str = "save(); + $order->save(); + $order->save(); +} +"#; + + let backend = Backend::new_test(); + parse_extra(&backend, ORDER_URI, ORDER); + parse_extra(&backend, CONSUMER_URI, CONSUMER); + hints_for(&backend, ORDER_URI, ORDER); + + crate::type_engine::variable::resolution::reset_test_scope_cache_hits(); + backend.compute_pending_member_ref_counts(); + + let declaration_offset = ORDER.find("save").unwrap() as u32; + assert_eq!( + backend + .member_ref_locations_cached( + ORDER_URI, + declaration_offset, + crate::atom::atom("Order"), + crate::atom::atom("save"), + false, + ) + .unwrap() + .len(), + 3 + ); + assert!( + crate::type_engine::variable::resolution::test_scope_cache_hits() >= 3, + "each repeated receiver lookup should reuse the one forward-walked file scope" + ); + } + #[test] fn ready_only_location_lookup_does_not_queue_background_work() { let backend = Backend::new_test(); diff --git a/src/references/members.rs b/src/references/members.rs index 51ac35f76..685cedefc 100644 --- a/src/references/members.rs +++ b/src/references/members.rs @@ -275,6 +275,34 @@ impl Backend { let _parse_cache_guard = crate::parser::with_parse_cache(&content); let file_ctx = self.file_context(file_uri); + // Receiver resolution for a bare variable normally walks its + // enclosing body from the start. A batch can contain hundreds of + // accesses from the same file, so build the forward-walked scope + // snapshots once and make every variable lookup below O(log N). + // The guard is per file: offsets from different files must never + // share one thread-local snapshot map. + let _scope_guard = + crate::type_engine::variable::forward_walk::with_diagnostic_scope_cache(); + let class_loader = self.class_loader(&file_ctx); + let function_loader = self.function_loader(&file_ctx); + let constant_loader = self.constant_loader(&file_ctx); + let config_resolver = |key: &str| self.resolve_config_type(key); + let trans_resolver = |key: &str| self.resolve_trans_type(key); + let loaders = crate::type_engine::resolver::Loaders { + function_loader: Some(&function_loader), + constant_loader: Some(&constant_loader), + config_resolver: Some(&config_resolver), + trans_resolver: Some(&trans_resolver), + }; + crate::type_engine::variable::forward_walk::build_diagnostic_scopes( + &content, + &file_ctx.classes, + &class_loader, + Some(self), + loaders, + Some(&self.resolved_class_cache), + ); + for span_index in span_indices { let span = &symbol_map.spans[span_index]; let SymbolKind::MemberAccess { diff --git a/src/type_engine/variable/resolution.rs b/src/type_engine/variable/resolution.rs index 5d4fe27ee..5621c09e3 100644 --- a/src/type_engine/variable/resolution.rs +++ b/src/type_engine/variable/resolution.rs @@ -75,6 +75,19 @@ thread_local! { /// of `(variable, offset)` questions get asked hundreds of times. static VAR_TYPE_MEMO: RefCell>>> = const { RefCell::new(None) }; + + #[cfg(test)] + static TEST_SCOPE_CACHE_HITS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +#[cfg(test)] +pub(crate) fn reset_test_scope_cache_hits() { + TEST_SCOPE_CACHE_HITS.with(|count| count.set(0)); +} + +#[cfg(test)] +pub(crate) fn test_scope_cache_hits() -> usize { + TEST_SCOPE_CACHE_HITS.with(std::cell::Cell::get) } /// What identifies one "what is the type of `$var` here?" question: the @@ -309,6 +322,8 @@ pub(crate) fn resolve_variable_types( }; if let Some(types) = super::forward_walk::lookup_diagnostic_scope(&prefixed, cursor_offset) { + #[cfg(test)] + TEST_SCOPE_CACHE_HITS.with(|count| count.set(count.get() + 1)); return types; } // Variable not in the forward-walked scope — fall through to From 57571912bf493a4141ab617d825cf6485eca4251 Mon Sep 17 00:00:00 2001 From: sidux Date: Thu, 27 Aug 2026 16:16:27 +0200 Subject: [PATCH 05/17] fix(indexing): Coalesce member count refreshes --- docs/CHANGELOG.md | 2 +- src/reference_counts.rs | 30 +++++++++++++++++++++--------- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index f1d0d74fa..d0621a53d 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -54,7 +54,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`@phpstan-assert` narrows a property of the receiver, not just an argument.** A tag naming a path through `$this` (`@phpstan-assert bool $this->resolved`) was ignored, because the subject was looked for among the call's arguments and such a call often has none. The lazy-initialiser idiom (`if ($this->resolved === null) { $this->resolve(); } return $this->resolved;`) therefore reported the nullable property as the return value. The tag's `$this` now stands for whatever the call was made on, so it narrows through a variable receiver as well. - **A class named `Scalar` or `Numeric` is a class, not a PHPDoc pseudo-type.** `scalar` and `numeric` have no native spelling in PHP, so a project may name a class either of them, and nikic/php-parser does exactly that with `PhpParser\Node\Scalar`. Any capitalised spelling was folded into the pseudo-type instead of being resolved through the file's imports, which left the name unqualified and every check against it failing: passing a `Scalar` to a parameter typed as its own parent was reported as a mismatch, in a native type hint, a `@param`, a `@return`, and a `@implements` type argument alike. The all-lowercase spellings keep their PHPDoc meaning, which is the same rule already applied to `Number`, `Integer`, `Boolean`, `Double`, and `Resource`. - **An `&&` operand that pins a value to one class outranks a later operand that only lists alternatives.** `$bound instanceof GenericType && ($class === GenericType::class || $bound instanceof TemplateType)` read the two operands as peers and answered `GenericType|TemplateType`, so passing the value on to anything expecting a `GenericType` was reported as a mismatch — even though the first operand alone settles the question. The disjunction can only narrow the value further, never widen it past what was already proven. A disjunction still narrows on its own when nothing in the chain pinned the subject down. -- **Reference CodeLens stays responsive in large projects.** Queued member lenses share one semantic pass over their candidate files, one parsed syntax tree per file, and one forward-walked variable scope per method body; clients without lens refresh compute only the lens they resolve. Contributed by @sidux. +- **Reference CodeLens stays responsive in large projects.** Queued member lenses share one semantic pass over their candidate files, one parsed syntax tree per file, and one forward-walked variable scope per method body. The worker drains a request burst before sending one editor refresh, and clients without lens refresh compute only the lens they resolve. Contributed by @sidux. - **Reference and CodeLens annotations reuse the completed workspace index.** Resolving many lenses in a large project no longer starts another full filesystem walk for every declaration. Concurrent annotations share the first indexing pass, while an explicit Find References command still refreshes once so files created without an editor notification remain discoverable. Contributed by @sidux. - **Argument checks accept the widenings PHP performs and the types the engine admits it does not know.** Four shapes of correct code were reported as type mismatches: a bounded `int<0, max>` passed to a `float` parameter, even though PHP widens an integer to a float on the way in; a `class-string` passed to `non-empty-string`, even though a string that names a class always has content; an `array-key` passed to `int` or to `string`, which is the key type of an array nobody described rather than a value measured to be two things; and a closure body doing `$a & $b` on untyped parameters, which produces a string from two strings just as readily as an int from two numbers. - **`get_class($x) === Foo::class` narrows the same subjects `instanceof` does.** The identity check only pinned a plain variable, so `get_class($this->held) === Sub::class` and `get_class($items[0]) === Sub::class` left the subject at its declared type and every member read past the check was reported as missing. A property fetch, an array element, and a call result are all narrowed now, in the `$x::class === Foo::class` spelling as well. diff --git a/src/reference_counts.rs b/src/reference_counts.rs index 5c07bf910..932b6d18c 100644 --- a/src/reference_counts.rs +++ b/src/reference_counts.rs @@ -561,9 +561,10 @@ impl Backend { /// Run the queued member reference counts on a background thread and /// ask the editor to re-pull inlay hints once they land. /// - /// At most one computation runs at a time: the counts a viewport needs - /// are queued again by the next request, so a dropped schedule costs - /// nothing but the wait. + /// At most one computation runs at a time. Requests that arrive while it + /// runs join the same burst, which is drained before one editor refresh. + /// Refreshing after every partial batch creates a feedback loop in clients + /// that immediately re-request lenses for all open buffers. pub(crate) fn schedule_member_ref_counts(&self) { if !self.member_ref_counts.has_pending() || self @@ -578,12 +579,23 @@ impl Backend { tokio::spawn(async move { let worker = backend.clone_for_blocking(); let changed = crate::server::run_blocking_cancel_safe("member ref counts", move || { - let changed = worker.compute_pending_member_ref_counts(); - worker - .member_ref_counts - .computing - .store(false, Ordering::Release); - changed + let mut changed = false; + loop { + changed |= worker.compute_pending_member_ref_counts(); + + // Pair the empty check with clearing `computing` under + // the queue lock. A request either lands before this and + // is drained by the loop, or lands afterwards, observes + // `computing == false`, and starts the next worker. + let pending = worker.member_ref_counts.pending.lock(); + if pending.is_empty() { + worker + .member_ref_counts + .computing + .store(false, Ordering::Release); + return changed; + } + } }) .await; From e1daa321ecf61450b70a79fb47767a8b5f373557 Mon Sep 17 00:00:00 2001 From: sidux Date: Thu, 27 Aug 2026 17:04:47 +0200 Subject: [PATCH 06/17] fix(indexing): Cache semantic member targets --- docs/ARCHITECTURE.md | 2 +- docs/CHANGELOG.md | 2 +- src/mem_audit.rs | 20 +++- src/parser/ast_update.rs | 4 + src/reference_counts.rs | 73 +++++++++++++ src/reference_index.rs | 136 +++++++++++++++++++++++- src/references/members.rs | 217 ++++++++++++++++++++++++++++---------- 7 files changed, 392 insertions(+), 62 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3907b37de..003b137d7 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -883,7 +883,7 @@ Both phases parse files in parallel using `std::thread::scope`. The work is spli Parsed files stay cached in `uri_classes_index`, `symbol_maps`, `file_imports`, and `file_namespaces` after the scan completes. There is no post-scan eviction; keeping the entries means subsequent operations (a second find-references call, go-to-definition on a cross-file symbol) benefit from the work already done. -The workspace reference index remains deliberately coarse: it stores candidate URIs and occurrence counts, not a second copy of every source position. CodeLens can therefore answer a conclusive zero without a semantic scan. Nonzero member references are resolved with the same hierarchy-aware search as Find References and cached as exact locations behind a 50,000-location bound. URI strings are shared inside that cache. Refresh-capable clients receive the lens after the background result is ready; other clients retain lazy `codeLens/resolve` as a compatibility path. +The workspace reference index keeps its primary map deliberately coarse: it stores candidate URIs and occurrence counts, not a second copy of every source position. CodeLens can therefore answer a conclusive zero without a semantic scan. The first nonzero member query resolves every member receiver in each candidate file while one forward-walked variable scope is active, packs the target class atoms by symbol-span index, and retains that compact per-file semantic layer for later member names. Candidate files are filled in parallel; edits evict their own layer, and signature changes clear layers whose receiver types may have changed. Exact locations remain bounded behind the 50,000-location annotation cache. Refresh-capable clients receive the lens after the background result is ready; other clients retain lazy `codeLens/resolve` as a compatibility path. ### Cross-file scanning diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index d0621a53d..8959623ed 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -54,7 +54,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`@phpstan-assert` narrows a property of the receiver, not just an argument.** A tag naming a path through `$this` (`@phpstan-assert bool $this->resolved`) was ignored, because the subject was looked for among the call's arguments and such a call often has none. The lazy-initialiser idiom (`if ($this->resolved === null) { $this->resolve(); } return $this->resolved;`) therefore reported the nullable property as the return value. The tag's `$this` now stands for whatever the call was made on, so it narrows through a variable receiver as well. - **A class named `Scalar` or `Numeric` is a class, not a PHPDoc pseudo-type.** `scalar` and `numeric` have no native spelling in PHP, so a project may name a class either of them, and nikic/php-parser does exactly that with `PhpParser\Node\Scalar`. Any capitalised spelling was folded into the pseudo-type instead of being resolved through the file's imports, which left the name unqualified and every check against it failing: passing a `Scalar` to a parameter typed as its own parent was reported as a mismatch, in a native type hint, a `@param`, a `@return`, and a `@implements` type argument alike. The all-lowercase spellings keep their PHPDoc meaning, which is the same rule already applied to `Number`, `Integer`, `Boolean`, `Double`, and `Resource`. - **An `&&` operand that pins a value to one class outranks a later operand that only lists alternatives.** `$bound instanceof GenericType && ($class === GenericType::class || $bound instanceof TemplateType)` read the two operands as peers and answered `GenericType|TemplateType`, so passing the value on to anything expecting a `GenericType` was reported as a mismatch — even though the first operand alone settles the question. The disjunction can only narrow the value further, never widen it past what was already proven. A disjunction still narrows on its own when nothing in the chain pinned the subject down. -- **Reference CodeLens stays responsive in large projects.** Queued member lenses share one semantic pass over their candidate files, one parsed syntax tree per file, and one forward-walked variable scope per method body. The worker drains a request burst before sending one editor refresh, and clients without lens refresh compute only the lens they resolve. Contributed by @sidux. +- **Reference CodeLens stays responsive in large projects.** PHP member receivers are resolved once per file in parallel and kept in a compact semantic index, so later lens batches filter exact references without reopening or walking source files. The worker drains a request burst before sending one editor refresh, and clients without lens refresh compute only the lens they resolve. Contributed by @sidux. - **Reference and CodeLens annotations reuse the completed workspace index.** Resolving many lenses in a large project no longer starts another full filesystem walk for every declaration. Concurrent annotations share the first indexing pass, while an explicit Find References command still refreshes once so files created without an editor notification remain discoverable. Contributed by @sidux. - **Argument checks accept the widenings PHP performs and the types the engine admits it does not know.** Four shapes of correct code were reported as type mismatches: a bounded `int<0, max>` passed to a `float` parameter, even though PHP widens an integer to a float on the way in; a `class-string` passed to `non-empty-string`, even though a string that names a class always has content; an `array-key` passed to `int` or to `string`, which is the key type of an array nobody described rather than a value measured to be two things; and a closure body doing `$a & $b` on untyped parameters, which produces a string from two strings just as readily as an int from two numbers. - **`get_class($x) === Foo::class` narrows the same subjects `instanceof` does.** The identity check only pinned a plain variable, so `get_class($this->held) === Sub::class` and `get_class($items[0]) === Sub::class` left the subject at its declared type and every member read past the check was reported as missing. A property fetch, an array element, and a call result are all narrowed now, in the `$x::class === Foo::class` spelling as well. diff --git a/src/mem_audit.rs b/src/mem_audit.rs index 8b0f133f7..bd305ce85 100644 --- a/src/mem_audit.rs +++ b/src/mem_audit.rs @@ -1348,11 +1348,13 @@ pub(crate) fn report(backend: &Backend, runner_content_bytes: usize) { // count, so there is no per-span duplication left to account for. let mut refs = Sz::default(); let mut n_key_uri_pairs = 0usize; + let n_resolved_member_files; + let mut n_resolved_member_accesses = 0usize; let n_keys; let mut distinct_uris: HashSet<*const u8> = HashSet::new(); { let idx = backend.reference_index.read(); - let (by_key, uri_keys) = idx.audit_maps(); + let (by_key, uri_keys, resolved_members) = idx.audit_maps(); n_keys = by_key.len(); refs += map_buckets::, u32>>( by_key.capacity(), @@ -1375,11 +1377,25 @@ pub(crate) fn report(backend: &Backend, runner_content_bytes: usize) { refs.add(k.audit_heap()); } } + refs += map_buckets::, Arc>( + resolved_members.capacity(), + ); + n_resolved_member_files = resolved_members.len(); + for (uri, file) in resolved_members { + distinct_uris.insert(Arc::as_ptr(uri).cast::()); + let (accesses, bytes, allocations) = file.audit_heap(); + n_resolved_member_accesses += accesses; + refs.add(ARC + size_of::()); + refs.add(bytes); + refs.allocs += allocations; + } } eprintln!( - "── reference_index: {} keys, {} (key, uri) pairs, {} distinct uris, {:.1} MB ({} allocs)", + "── reference_index: {} keys, {} (key, uri) pairs, {} exact member accesses in {} files, {} distinct uris, {:.1} MB ({} allocs)", n_keys, n_key_uri_pairs, + n_resolved_member_accesses, + n_resolved_member_files, distinct_uris.len(), mb(refs.bytes), refs.allocs, diff --git a/src/parser/ast_update.rs b/src/parser/ast_update.rs index 3d21d3a79..3492e43ca 100644 --- a/src/parser/ast_update.rs +++ b/src/parser/ast_update.rs @@ -1322,6 +1322,10 @@ impl Backend { if changed { self.member_completion_cache.lock().clear(); + // Exact member targets in other files may depend on the return or + // property type that changed here. Rebuild those files lazily; + // the edited file itself is evicted by reference reindexing below. + self.clear_resolved_member_files(); // A receiver's type is settled against the classes of the whole // workspace, so a signature change anywhere can turn a call that // was not a render into one, or the other way round. diff --git a/src/reference_counts.rs b/src/reference_counts.rs index 932b6d18c..fcb196ff5 100644 --- a/src/reference_counts.rs +++ b/src/reference_counts.rs @@ -756,6 +756,79 @@ function persist(Order $order): void { ); } + #[test] + fn later_member_batches_reuse_the_semantic_file_index() { + const SERVICE_URI: &str = "file:///Service.php"; + const CONSUMER_URI: &str = "file:///Consumer.php"; + const SERVICE: &str = r#"save(); + $service->cancel(); +} +"#; + + let backend = Backend::new_test(); + parse_extra(&backend, SERVICE_URI, SERVICE); + parse_extra(&backend, CONSUMER_URI, CONSUMER); + backend.workspace_indexed.store(true, Ordering::Release); + + let class_fqn = crate::atom::atom("Service"); + let save_offset = SERVICE.find("save").unwrap() as u32; + assert!( + backend + .member_ref_count_cached( + SERVICE_URI, + save_offset, + class_fqn, + crate::atom::atom("save"), + false, + ) + .is_none() + ); + crate::type_engine::variable::resolution::reset_test_scope_cache_hits(); + backend.compute_pending_member_ref_counts(); + assert!(crate::type_engine::variable::resolution::test_scope_cache_hits() > 0); + + let consumer_map = backend + .symbol_maps + .read() + .get(CONSUMER_URI) + .cloned() + .unwrap(); + assert!( + backend + .resolved_member_file(CONSUMER_URI, &consumer_map) + .is_some(), + "the first member query should index every receiver in its candidate file" + ); + + let cancel_offset = SERVICE.find("cancel").unwrap() as u32; + assert!( + backend + .member_ref_count_cached( + SERVICE_URI, + cancel_offset, + class_fqn, + crate::atom::atom("cancel"), + false, + ) + .is_none() + ); + crate::type_engine::variable::resolution::reset_test_scope_cache_hits(); + backend.compute_pending_member_ref_counts(); + assert_eq!( + crate::type_engine::variable::resolution::test_scope_cache_hits(), + 0, + "a later member name must not rebuild or query the file's variable scopes" + ); + } + #[test] fn ready_only_location_lookup_does_not_queue_background_work() { let backend = Backend::new_test(); diff --git a/src/reference_index.rs b/src/reference_index.rs index 680d67796..c71f5d58f 100644 --- a/src/reference_index.rs +++ b/src/reference_index.rs @@ -14,7 +14,7 @@ use std::sync::atomic::Ordering; use parking_lot::RwLock; use crate::Backend; -use crate::atom::{AtomMap, AtomSet, atom}; +use crate::atom::{Atom, AtomMap, AtomSet, atom}; use crate::backend::file_access::namespace_in_spans; use crate::class_lookup::find_class_at_offset; use crate::symbol_map::{LaravelStringKind, SelfStaticParentKind, SymbolKind, SymbolMap}; @@ -111,6 +111,80 @@ impl ReferenceIndexKey { pub(crate) struct ReferenceIndexInner { by_key: HashMap, u32>>, uri_keys: HashMap, Vec>, + resolved_members: HashMap, Arc>, +} + +#[derive(Clone, Copy)] +struct ResolvedMemberAccess { + span_index: u32, + target_start: u32, + target_len: u32, +} + +/// Receiver classes resolved for every member access in one immutable symbol +/// map. Targets are packed into one allocation; the access table points into +/// it and stays sorted by symbol-span index for binary lookup. +pub(crate) struct ResolvedMemberFile { + symbol_map: Arc, + accesses: Vec, + targets: Vec, +} + +impl ResolvedMemberFile { + pub(crate) fn new(symbol_map: Arc, mut resolved: Vec<(usize, Vec)>) -> Self { + resolved.sort_unstable_by_key(|(span_index, _)| *span_index); + let target_count = resolved.iter().map(|(_, targets)| targets.len()).sum(); + let mut accesses = Vec::with_capacity(resolved.len()); + let mut packed_targets = Vec::with_capacity(target_count); + for (span_index, mut targets) in resolved { + if targets.is_empty() { + continue; + } + targets.sort_unstable(); + targets.dedup(); + let target_start = packed_targets.len(); + packed_targets.extend(targets); + accesses.push(ResolvedMemberAccess { + span_index: span_index as u32, + target_start: target_start as u32, + target_len: (packed_targets.len() - target_start) as u32, + }); + } + Self { + symbol_map, + accesses, + targets: packed_targets, + } + } + + pub(crate) fn targets_for_span(&self, span_index: usize) -> &[Atom] { + let Ok(span_index) = u32::try_from(span_index) else { + return &[]; + }; + let Ok(index) = self + .accesses + .binary_search_by_key(&span_index, |access| access.span_index) + else { + return &[]; + }; + let access = self.accesses[index]; + let start = access.target_start as usize; + &self.targets[start..start + access.target_len as usize] + } + + fn matches_symbol_map(&self, symbol_map: &Arc) -> bool { + Arc::ptr_eq(&self.symbol_map, symbol_map) + } + + #[cfg(feature = "mem-audit")] + pub(crate) fn audit_heap(&self) -> (usize, usize, usize) { + ( + self.accesses.len(), + self.accesses.capacity() * std::mem::size_of::() + + self.targets.capacity() * std::mem::size_of::(), + usize::from(self.accesses.capacity() > 0) + usize::from(self.targets.capacity() > 0), + ) + } } impl ReferenceIndexInner { @@ -127,8 +201,9 @@ impl ReferenceIndexInner { ) -> ( &HashMap, u32>>, &HashMap, Vec>, + &HashMap, Arc>, ) { - (&self.by_key, &self.uri_keys) + (&self.by_key, &self.uri_keys, &self.resolved_members) } #[cfg(test)] @@ -144,6 +219,62 @@ pub(crate) fn new_reference_index() -> ReferenceIndex { } impl Backend { + pub(crate) fn resolved_member_file( + &self, + uri: &str, + symbol_map: &Arc, + ) -> Option> { + self.reference_index + .read() + .resolved_members + .get(uri) + .filter(|file| file.matches_symbol_map(symbol_map)) + .cloned() + } + + pub(crate) fn cache_resolved_member_file( + &self, + uri: &str, + symbol_map: Arc, + resolved: Vec<(usize, Vec)>, + ) -> Arc { + let built = Arc::new(ResolvedMemberFile::new(Arc::clone(&symbol_map), resolved)); + + // A didChange parse may have replaced the symbol map while the + // semantic walk was running. The result remains usable by its caller, + // which owns the same snapshot, but must not become the current cache. + if !self + .symbol_maps + .read() + .get(uri) + .is_some_and(|current| Arc::ptr_eq(current, &symbol_map)) + { + return built; + } + + let mut index = self.reference_index.write(); + if let Some(existing) = index + .resolved_members + .get(uri) + .filter(|file| file.matches_symbol_map(&symbol_map)) + { + return Arc::clone(existing); + } + let interned_uri = index + .uri_keys + .get_key_value(uri) + .map(|(uri, _)| Arc::clone(uri)) + .unwrap_or_else(|| Arc::from(uri)); + index + .resolved_members + .insert(interned_uri, Arc::clone(&built)); + built + } + + pub(crate) fn clear_resolved_member_files(&self) { + self.reference_index.write().resolved_members.clear(); + } + pub(crate) fn evict_reference_index_uri(&self, uri: &str) { let track_members = !self.member_ref_counts.is_empty(); let mut index = self.reference_index.write(); @@ -645,6 +776,7 @@ fn member_contributions_of_entries(entries: &[(ReferenceIndexKey, bool)]) -> Ato } fn evict_reference_index_uri_locked(index: &mut ReferenceIndexInner, uri: &str) { + index.resolved_members.remove(uri); let Some(keys) = index.uri_keys.remove(uri) else { return; }; diff --git a/src/references/members.rs b/src/references/members.rs index 685cedefc..36e953c6a 100644 --- a/src/references/members.rs +++ b/src/references/members.rs @@ -252,62 +252,118 @@ impl Backend { let snapshot = self.user_file_symbol_maps_for_reference_keys(&candidate_keys); self.begin_request_scan_window(snapshot.len(), "Scanning for member references"); - let mut locations = vec![Vec::new(); queries.len()]; - for (file_uri, symbol_map) in &snapshot { - self.request_scan_file_done(); - + let scan_file = |file_uri: &str, + symbol_map: &Arc| + -> Vec<(usize, Location)> { let mut span_indices = Vec::new(); for member in by_member.keys() { span_indices.extend_from_slice(symbol_map.member_access_indices(member)); } if span_indices.is_empty() { - continue; + return Vec::new(); } span_indices.sort_unstable(); span_indices.dedup(); let Ok(parsed_uri) = Url::parse(file_uri) else { - continue; + return Vec::new(); }; let Some(content) = self.reference_file_content_arc(file_uri) else { - continue; - }; - let _parse_cache_guard = crate::parser::with_parse_cache(&content); - let file_ctx = self.file_context(file_uri); - - // Receiver resolution for a bare variable normally walks its - // enclosing body from the start. A batch can contain hundreds of - // accesses from the same file, so build the forward-walked scope - // snapshots once and make every variable lookup below O(log N). - // The guard is per file: offsets from different files must never - // share one thread-local snapshot map. - let _scope_guard = - crate::type_engine::variable::forward_walk::with_diagnostic_scope_cache(); - let class_loader = self.class_loader(&file_ctx); - let function_loader = self.function_loader(&file_ctx); - let constant_loader = self.constant_loader(&file_ctx); - let config_resolver = |key: &str| self.resolve_config_type(key); - let trans_resolver = |key: &str| self.resolve_trans_type(key); - let loaders = crate::type_engine::resolver::Loaders { - function_loader: Some(&function_loader), - constant_loader: Some(&constant_loader), - config_resolver: Some(&config_resolver), - trans_resolver: Some(&trans_resolver), + return Vec::new(); }; - crate::type_engine::variable::forward_walk::build_diagnostic_scopes( - &content, - &file_ctx.classes, - &class_loader, - Some(self), - loaders, - Some(&self.resolved_class_cache), - ); + let needs_receiver = prepared.iter().any(|query| query.hierarchy.is_some()); + let resolved_file = needs_receiver.then(|| { + self.resolved_member_file(file_uri, symbol_map) + .unwrap_or_else(|| { + let _parse_cache_guard = crate::parser::with_parse_cache(&content); + let file_ctx = self.file_context(file_uri); + let all_access_indices: Vec<_> = symbol_map + .spans + .iter() + .enumerate() + .filter_map(|(index, span)| { + matches!(span.kind, SymbolKind::MemberAccess { .. }) + .then_some(index) + }) + .collect(); + + // Build variable scopes once, then resolve every + // member access while those snapshots are hot. + // Later declaration names reuse this packed + // per-file result without reopening the PHP file. + let _scope_guard = + crate::type_engine::variable::forward_walk::with_diagnostic_scope_cache( + ); + let needs_variable_scopes = all_access_indices.iter().any(|&span_index| { + let SymbolKind::MemberAccess { subject_text, .. } = + &symbol_map.spans[span_index].kind + else { + return false; + }; + let subject = subject_text.as_str(&content).trim_start(); + subject.starts_with('$') && !subject.starts_with("$this") + }); + if needs_variable_scopes { + let class_loader = self.class_loader(&file_ctx); + let function_loader = self.function_loader(&file_ctx); + let constant_loader = self.constant_loader(&file_ctx); + let config_resolver = |key: &str| self.resolve_config_type(key); + let trans_resolver = |key: &str| self.resolve_trans_type(key); + let loaders = crate::type_engine::resolver::Loaders { + function_loader: Some(&function_loader), + constant_loader: Some(&constant_loader), + config_resolver: Some(&config_resolver), + trans_resolver: Some(&trans_resolver), + }; + crate::type_engine::variable::forward_walk::build_diagnostic_scopes( + &content, + &file_ctx.classes, + &class_loader, + Some(self), + loaders, + Some(&self.resolved_class_cache), + ); + } + let _chain_guard = + crate::type_engine::resolver::with_chain_resolution_cache(); + let _resolver_guard = + crate::type_engine::call_resolution::activate_type_engine_caches(); + let resolved = all_access_indices + .into_iter() + .filter_map(|span_index| { + let span = &symbol_map.spans[span_index]; + let SymbolKind::MemberAccess { + subject_text, + is_static, + .. + } = &span.kind + else { + return None; + }; + let targets = self + .resolve_subject_to_fqns( + subject_text.as_str(&content), + *is_static, + &file_ctx, + span.start, + &content, + ) + .into_iter() + .map(|target| crate::atom::atom(&target)) + .collect(); + Some((span_index, targets)) + }) + .collect(); + self.cache_resolved_member_file(file_uri, Arc::clone(symbol_map), resolved) + }) + }); + + let mut matches = Vec::new(); for span_index in span_indices { let span = &symbol_map.spans[span_index]; let SymbolKind::MemberAccess { member_name, - subject_text, is_static, .. } = &span.kind @@ -318,18 +374,9 @@ impl Backend { continue; }; - let needs_receiver = query_indices - .iter() - .any(|&index| prepared[index].hierarchy.is_some()); - let subject_fqns = needs_receiver.then(|| { - self.resolve_subject_to_fqns( - subject_text.as_str(&content), - *is_static, - &file_ctx, - span.start, - &content, - ) - }); + let subject_fqns = resolved_file + .as_ref() + .map_or(&[][..], |file| file.targets_for_span(span_index)); let range = Range::new( offset_to_position(&content, span.start as usize), offset_to_position(&content, span.end as usize), @@ -339,8 +386,8 @@ impl Backend { let query = &prepared[query_index]; if let Some(hierarchy) = &query.hierarchy { if !subject_fqns - .as_ref() - .is_some_and(|fqns| fqns.iter().any(|fqn| hierarchy.contains(fqn))) + .iter() + .any(|fqn| hierarchy.contains(fqn.as_str())) { continue; } @@ -348,11 +395,69 @@ impl Backend { continue; } - locations[query_index].push(Location { - uri: parsed_uri.clone(), - range, - }); + matches.push(( + query_index, + Location { + uri: parsed_uri.clone(), + range, + }, + )); + } + } + matches + }; + + let mut locations = vec![Vec::new(); queries.len()]; + if snapshot.len() <= 2 { + for (file_uri, symbol_map) in &snapshot { + self.request_scan_file_done(); + for (query_index, location) in scan_file(file_uri, symbol_map) { + locations[query_index].push(location); + } + } + } else { + let next = std::sync::atomic::AtomicUsize::new(0); + let thread_count = std::thread::available_parallelism() + .map(std::num::NonZeroUsize::get) + .unwrap_or(4) + .min(snapshot.len()); + let worker_results = std::thread::scope(|scope| { + let mut handles = Vec::with_capacity(thread_count); + for _ in 0..thread_count { + let next = &next; + let snapshot = &snapshot; + let scan_file = &scan_file; + handles.push( + std::thread::Builder::new() + .stack_size(crate::PARSE_WORKER_STACK_SIZE) + .spawn_scoped(scope, move || { + let mut matches = Vec::new(); + loop { + let index = + next.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let Some((file_uri, symbol_map)) = snapshot.get(index) else { + break; + }; + self.request_scan_file_done(); + matches.extend(scan_file(file_uri, symbol_map)); + } + matches + }) + .expect("spawn member reference worker"), + ); } + handles + .into_iter() + .flat_map(|handle| { + handle.join().unwrap_or_else(|_| { + tracing::error!("member reference worker panicked"); + Vec::new() + }) + }) + .collect::>() + }); + for (query_index, location) in worker_results { + locations[query_index].push(location); } } From f4bea428a6fa752211f933df38c5770aa7d686d3 Mon Sep 17 00:00:00 2001 From: sidux Date: Thu, 27 Aug 2026 19:56:37 +0200 Subject: [PATCH 07/17] fix(references): keep member batching framework-neutral --- src/references/members.rs | 22 +--------------------- src/references/mod.rs | 11 +++++++++++ 2 files changed, 12 insertions(+), 21 deletions(-) diff --git a/src/references/members.rs b/src/references/members.rs index 36e953c6a..94e4bf6bc 100644 --- a/src/references/members.rs +++ b/src/references/members.rs @@ -461,27 +461,7 @@ impl Backend { } } - for (query, query_locations) in prepared.iter().zip(&mut locations) { - for location in - self.framework_member_reference_locations(&query.member, query.hierarchy.as_ref()) - { - push_unique_location( - query_locations, - &location.uri, - location.range.start, - location.range.end, - ); - } - for location in - self.framework_property_reference_locations(&query.member, query.hierarchy.as_ref()) - { - push_unique_location( - query_locations, - &location.uri, - location.range.start, - location.range.end, - ); - } + for query_locations in &mut locations { sort_locations_for_references(query_locations); } diff --git a/src/references/mod.rs b/src/references/mod.rs index 00c49feec..56b1f2fbe 100644 --- a/src/references/mod.rs +++ b/src/references/mod.rs @@ -216,6 +216,17 @@ pub(super) fn is_constructor_name(name: &str) -> bool { name.eq_ignore_ascii_case("__construct") } +fn sort_locations_for_references(locations: &mut Vec) { + locations.sort_by(|a, b| { + a.uri + .as_str() + .cmp(b.uri.as_str()) + .then(a.range.start.line.cmp(&b.range.start.line)) + .then(a.range.start.character.cmp(&b.range.start.character)) + }); + locations.dedup(); +} + /// Check whether a resolved class name matches the target FQN. /// /// Two names match if their fully-qualified forms are equal, or if both From d06fdfe5568fdc11fab1cd40ae1a1b232b54aa36 Mon Sep 17 00:00:00 2001 From: sidux Date: Tue, 25 Aug 2026 23:22:40 +0200 Subject: [PATCH 08/17] feat(navigation): Navigate PHP symbols in YAML and XML Resolve fully-qualified classes and Class::member references from arbitrary YAML and XML positions without schema-specific rules. --- docs/ARCHITECTURE.md | 1 + docs/CHANGELOG.md | 1 + src/definition/member/mod.rs | 28 +++ src/lib.rs | 1 + src/resource_navigation.rs | 229 ++++++++++++++++++ src/server.rs | 41 +++- .../integration/definition_resource_files.rs | 166 +++++++++++++ tests/integration/main.rs | 1 + 8 files changed, 466 insertions(+), 2 deletions(-) create mode 100644 src/resource_navigation.rs create mode 100644 tests/integration/definition_resource_files.rs diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 003b137d7..b91547b51 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -98,6 +98,7 @@ src/ │ # LSP features (one module each) ├── hover/ # Hover: symbol-map dispatch, type/signature/docblock formatting ├── definition/ # Go-to-definition (resolve, member, variable/, implementation, type_definition) +├── resource_navigation.rs # Schema-free PHP class/member navigation in YAML/XML ├── references/, rename/, highlight/ ├── signature_help.rs, semantic_tokens.rs, inlay_hints.rs, folding.rs, code_lens.rs ├── document_symbols.rs, document_links.rs, workspace_symbols.rs, formatting.rs diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 8959623ed..981448e35 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Fully-qualified PHP classes navigate from YAML and XML.** Ctrl+Click a class name in any YAML key or value, or any XML attribute or text node, and PHPantom opens its PHP declaration without needing to know that file's schema. `Class::member` references navigate too. Unknown and unqualified strings are left alone. Contributed by @sidux. - **Reference CodeLens.** PHP declarations show clickable exact reference counts. Declarations with no indexed uses are answered immediately, while semantic member locations are cached in a bounded background index so opening a large file does not fan out into an expensive resolve request per lens. Clients that support CodeLens refresh receive only ready, fully resolved member lenses. Contributed by @sidux. - **`analyze` takes more than one path.** `phpantom_lsp analyze app/ lib/Helper.php tests/` scans the union of everything named, mixing directories and single files freely, so a pre-commit hook or a CI step can hand it exactly the paths that changed instead of running the whole project or invoking the binary once per path. Overlapping arguments are reported once, and a path that does not exist still stops the run with exit code 2. Naming no path scans the entire project, as before. diff --git a/src/definition/member/mod.rs b/src/definition/member/mod.rs index a9dae1a67..51b997817 100644 --- a/src/definition/member/mod.rs +++ b/src/definition/member/mod.rs @@ -96,6 +96,34 @@ pub(super) enum MemberAccessHint { impl Backend { // ─── Member Definition Resolution ─────────────────────────────────────── + /// Resolve a member named by external project metadata, where the class + /// is already a fully-qualified name and there is no PHP source context + /// to extract a subject expression from. + pub(crate) fn class_member_declaration_location( + &self, + class_fqn: &str, + member_name: &str, + ) -> Option { + let target_class = self.find_or_load_class(class_fqn)?; + let class_loader = |name: &str| self.find_or_load_class(name); + let (declaring_class, declaring_fqn) = + Self::find_declaring_class(&target_class, member_name, &class_loader)?; + let member_kind = + Self::classify_member(&declaring_class, member_name, MemberAccessHint::Unknown)?; + let (class_uri, class_content) = self.find_class_file_content(&declaring_fqn, "", "")?; + let member_position = Self::find_member_position( + &class_content, + member_name, + member_kind, + declaring_class.member_name_offset(member_name, member_kind.as_str()), + )?; + + Some(point_location( + Url::parse(&class_uri).ok()?, + member_position, + )) + } + /// Resolve a member access to its definition using pre-extracted context. /// /// The caller provides a [`MemberDefinitionCtx`] bundling the subject diff --git a/src/lib.rs b/src/lib.rs index f213c29e6..e5c88316e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -266,6 +266,7 @@ mod reference_index; mod references; mod rename; mod resolution; +mod resource_navigation; pub(crate) mod return_collection; pub(crate) mod scope_collector; mod selection_range; diff --git a/src/resource_navigation.rs b/src/resource_navigation.rs new file mode 100644 index 000000000..3091cc411 --- /dev/null +++ b/src/resource_navigation.rs @@ -0,0 +1,229 @@ +//! PHP symbol navigation from non-PHP resource files. +//! +//! YAML and XML often carry fully-qualified PHP class names in arbitrary +//! keys, values, attributes, and text. This module recognises those names +//! without knowing the schema of the file that contains them, then delegates +//! declaration lookup to the normal PHP class loader. + +use tower_lsp::lsp_types::{Location, Position}; + +use crate::Backend; + +#[derive(Debug, PartialEq, Eq)] +enum ResourceSymbol { + Class(String), + Member { + class_fqn: String, + member_name: String, + }, +} + +/// Whether `uri` names a YAML or XML document that can carry PHP symbols. +pub(crate) fn is_resource_document(uri: &str) -> bool { + let path = uri + .split(['?', '#']) + .next() + .unwrap_or(uri) + .to_ascii_lowercase(); + + [ + ".yaml", + ".yml", + ".xml", + ".yaml.dist", + ".yml.dist", + ".xml.dist", + ] + .iter() + .any(|suffix| path.ends_with(suffix)) +} + +impl Backend { + /// Resolve the fully-qualified PHP class or `Class::member` under the + /// cursor in a YAML/XML document. + pub(crate) fn resolve_resource_definition( + &self, + content: &str, + position: Position, + ) -> Option { + match symbol_at(content, position)? { + ResourceSymbol::Class(fqn) => self.class_declaration_location(&fqn), + ResourceSymbol::Member { + class_fqn, + member_name, + } => self.class_member_declaration_location(&class_fqn, &member_name), + } + } +} + +fn symbol_at(content: &str, position: Position) -> Option { + let offset = crate::text_position::position_to_offset(content, position) as usize; + let previous_offset = offset.checked_sub(1); + let bytes = content.as_bytes(); + let mut cursor = 0usize; + + while cursor < bytes.len() { + if !is_name_start(bytes[cursor]) || (cursor > 0 && is_name_char(bytes[cursor - 1])) { + cursor += 1; + continue; + } + + let class_start = cursor; + let mut class_end = cursor + 1; + while class_end < bytes.len() && is_name_char(bytes[class_end]) { + class_end += 1; + } + + let raw_name = &content[class_start..class_end]; + if raw_name.contains('\\') && !raw_name.ends_with('\\') { + let fqn = normalize_fqn(raw_name); + if is_class_fqn(&fqn) { + if contains_cursor(class_start, class_end, offset, previous_offset) { + return Some(ResourceSymbol::Class(fqn)); + } + + if bytes.get(class_end) == Some(&b':') && bytes.get(class_end + 1) == Some(&b':') { + let member_start = class_end + 2; + let member_end = scan_identifier(bytes, member_start); + if member_end > member_start + && contains_cursor(member_start, member_end, offset, previous_offset) + { + return Some(ResourceSymbol::Member { + class_fqn: fqn, + member_name: content[member_start..member_end].to_string(), + }); + } + } + } + } + + cursor = class_end; + } + + None +} + +fn contains_cursor(start: usize, end: usize, offset: usize, previous: Option) -> bool { + (start..end).contains(&offset) || previous.is_some_and(|offset| (start..end).contains(&offset)) +} + +fn is_name_start(byte: u8) -> bool { + byte == b'\\' || byte == b'_' || byte.is_ascii_alphabetic() || !byte.is_ascii() +} + +fn is_name_char(byte: u8) -> bool { + byte == b'\\' || byte == b'_' || byte.is_ascii_alphanumeric() || !byte.is_ascii() +} + +fn scan_identifier(bytes: &[u8], start: usize) -> usize { + if !bytes + .get(start) + .is_some_and(|byte| *byte == b'_' || byte.is_ascii_alphabetic() || !byte.is_ascii()) + { + return start; + } + + let mut end = start + 1; + while end < bytes.len() + && (bytes[end] == b'_' || bytes[end].is_ascii_alphanumeric() || !bytes[end].is_ascii()) + { + end += 1; + } + end +} + +fn normalize_fqn(raw: &str) -> String { + let mut normalized = String::with_capacity(raw.len()); + let mut previous_was_separator = false; + + for character in raw.trim_matches('\\').chars() { + if character == '\\' { + if !previous_was_separator { + normalized.push(character); + } + previous_was_separator = true; + } else { + normalized.push(character); + previous_was_separator = false; + } + } + + normalized +} + +fn is_class_fqn(name: &str) -> bool { + let mut segments = name.split('\\'); + let Some(first) = segments.next() else { + return false; + }; + if !is_identifier(first) { + return false; + } + + let mut has_namespace = false; + for segment in segments { + has_namespace = true; + if !is_identifier(segment) { + return false; + } + } + has_namespace +} + +fn is_identifier(value: &str) -> bool { + let mut characters = value.chars(); + let Some(first) = characters.next() else { + return false; + }; + (first == '_' || first.is_alphabetic()) + && characters.all(|character| character == '_' || character.is_alphanumeric()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn scans_classes_without_knowing_yaml_keys() { + let content = "anything: App\\UseCase\\Run\n"; + assert_eq!( + symbol_at(content, Position::new(0, 18)), + Some(ResourceSymbol::Class("App\\UseCase\\Run".to_string())) + ); + } + + #[test] + fn scans_xml_text_and_attributes() { + let content = r#"App\Handler\Fallback"#; + assert_eq!( + symbol_at(content, Position::new(0, 21)), + Some(ResourceSymbol::Class("App\\Handler\\Run".to_string())) + ); + assert_eq!( + symbol_at(content, Position::new(0, 39)), + Some(ResourceSymbol::Class("App\\Handler\\Fallback".to_string())) + ); + } + + #[test] + fn scans_class_members_and_yaml_escaped_names() { + let content = r#"callback: "App\\Handler\\Run::handle""#; + assert_eq!( + symbol_at(content, Position::new(0, 32)), + Some(ResourceSymbol::Member { + class_fqn: "App\\Handler\\Run".to_string(), + member_name: "handle".to_string(), + }) + ); + } + + #[test] + fn ignores_short_and_malformed_names() { + assert_eq!(symbol_at("handler: Run", Position::new(0, 10)), None); + assert_eq!(symbol_at("path: folder\\-file", Position::new(0, 9)), None); + assert_eq!( + symbol_at("prefix: App\\Handler\\", Position::new(0, 14)), + None + ); + } +} diff --git a/src/server.rs b/src/server.rs index 11d145f12..4a9aabe84 100644 --- a/src/server.rs +++ b/src/server.rs @@ -740,6 +740,14 @@ impl LanguageServer for Backend { .write() .insert(uri.clone(), Arc::clone(&text)); + // Resource documents are not PHP source. Their schema-free symbol + // navigation reads the open buffer directly when requested. + if crate::resource_navigation::is_resource_document(&uri) { + self.log(MessageType::INFO, format!("Opened resource file: {}", uri)) + .await; + return; + } + // Parse and update AST map, use map, and namespace map self.update_ast(&uri, &text); @@ -819,6 +827,10 @@ impl LanguageServer for Backend { .write() .insert(uri.clone(), Arc::clone(&text)); + if crate::resource_navigation::is_resource_document(&uri) { + return; + } + // Re-parse in a blocking background task so typing does not // monopolize the LSP service loop and delay completion requests. // @@ -935,13 +947,20 @@ impl LanguageServer for Backend { async fn did_save(&self, params: DidSaveTextDocumentParams) { let uri = params.text_document.uri.to_string(); + let is_resource = crate::resource_navigation::is_resource_document(&uri); if let Some(text) = params.text { let text = Arc::new(text); self.open_files .write() .insert(uri.clone(), Arc::clone(&text)); - self.update_ast(&uri, &text); + if !is_resource { + self.update_ast(&uri, &text); + } + } + + if is_resource { + return; } // A save is a reliable sync point: re-diagnose the saved file @@ -1012,6 +1031,22 @@ impl LanguageServer for Backend { let backend = self.clone_for_blocking(); let uri_clone = uri.clone(); run_blocking_cancel_safe("goto_definition", move || { + // YAML and XML may name PHP classes under any schema. Resolve + // fully-qualified class and Class::member tokens before entering + // the PHP-only symbol-map path below. + if crate::resource_navigation::is_resource_document(&uri_clone) { + let location = backend.get_file_content(&uri_clone).and_then(|content| { + crate::util::catch_panic_unwind_safe( + "goto_definition", + &uri_clone, + Some(position), + || backend.resolve_resource_definition(&content, position), + ) + .flatten() + }); + return Ok(location.map(GotoDefinitionResponse::Scalar)); + } + // A component tag is HTML, so it has no position in the virtual // PHP `handle_with_position` would swap in below; it is resolved // from the template's own source instead. @@ -2011,7 +2046,9 @@ impl Backend { // map, so a file passes through here once; a file the parser panics // on publishes nothing and is retried, which is the same work its // next keystroke would do anyway. - if !self.symbol_maps.read().contains_key(uri) { + if !crate::resource_navigation::is_resource_document(uri) + && !self.symbol_maps.read().contains_key(uri) + { self.update_ast(uri, &content); } diff --git a/tests/integration/definition_resource_files.rs b/tests/integration/definition_resource_files.rs new file mode 100644 index 000000000..99558c8c7 --- /dev/null +++ b/tests/integration/definition_resource_files.rs @@ -0,0 +1,166 @@ +use crate::common::create_psr4_workspace; +use phpantom_lsp::Backend; +use tower_lsp::LanguageServer; +use tower_lsp::lsp_types::*; + +const COMPOSER: &str = r#"{ + "autoload": { "psr-4": { "App\\": "src/" } } +}"#; + +async fn open_resource(backend: &Backend, uri: Url, language_id: &str, content: &str) { + backend + .did_open(DidOpenTextDocumentParams { + text_document: TextDocumentItem { + uri, + language_id: language_id.to_string(), + version: 1, + text: content.to_string(), + }, + }) + .await; +} + +fn position_in(content: &str, needle: &str, inside: usize) -> Position { + let offset = content.find(needle).expect("needle should exist") + inside; + let prefix = &content[..offset]; + Position::new( + prefix.bytes().filter(|byte| *byte == b'\n').count() as u32, + prefix + .rsplit_once('\n') + .map_or(prefix.len(), |(_, line)| line.len()) as u32, + ) +} + +async fn definition_at( + backend: &Backend, + uri: Url, + content: &str, + needle: &str, + inside: usize, +) -> Option { + backend + .goto_definition(GotoDefinitionParams { + text_document_position_params: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { uri }, + position: position_in(content, needle, inside), + }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + }) + .await + .expect("definition request should succeed") +} + +#[tokio::test] +async fn navigates_php_classes_from_arbitrary_yaml_keys_and_values() { + let php = "App\Handler\Run"#; + let (backend, dir) = create_psr4_workspace( + COMPOSER, + &[("src/Handler/Run.php", php), ("config/arbitrary.xml", xml)], + ); + let xml_uri = Url::from_file_path(dir.path().join("config/arbitrary.xml")).unwrap(); + open_resource(&backend, xml_uri.clone(), "xml", xml).await; + + for needle in ["handler=\"App\\Handler\\Run", ">App\\Handler\\Run"] { + let result = definition_at(&backend, xml_uri.clone(), xml, needle, needle.len() - 2) + .await + .expect("class should resolve from XML"); + let GotoDefinitionResponse::Scalar(location) = result else { + panic!("expected one class definition"); + }; + assert!(location.uri.path().ends_with("/src/Handler/Run.php")); + } +} + +#[tokio::test] +async fn navigates_class_members_and_yaml_escaped_class_names() { + let php = concat!( + " Date: Thu, 27 Aug 2026 01:10:46 +0200 Subject: [PATCH 09/17] feat(navigation): Index YAML and XML PHP references Feed schema-free class and member occurrences into Find References and CodeLens, including transparent-proxy metadata aliases. --- docs/ARCHITECTURE.md | 6 +- docs/CHANGELOG.md | 2 +- src/code_lens.rs | 16 +- src/indexing/preload.rs | 51 +++++- src/indexing/watch.rs | 44 ++++- src/reference_index.rs | 25 ++- src/references/classes.rs | 7 +- src/references/mod.rs | 39 ++++- src/resource_navigation.rs | 160 ++++++++++++++++-- src/server.rs | 39 ++++- .../integration/definition_resource_files.rs | 150 ++++++++++++++++ 11 files changed, 486 insertions(+), 53 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b91547b51..91f295fd6 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -98,7 +98,7 @@ src/ │ # LSP features (one module each) ├── hover/ # Hover: symbol-map dispatch, type/signature/docblock formatting ├── definition/ # Go-to-definition (resolve, member, variable/, implementation, type_definition) -├── resource_navigation.rs # Schema-free PHP class/member navigation in YAML/XML +├── resource_navigation.rs # Schema-free PHP class/member indexing and navigation in YAML/XML ├── references/, rename/, highlight/ ├── signature_help.rs, semantic_tokens.rs, inlay_hints.rs, folding.rs, code_lens.rs ├── document_symbols.rs, document_links.rs, workspace_symbols.rs, formatting.rs @@ -878,9 +878,9 @@ When the user invokes "Find All References", PHPantom scans all user files for o Before scanning, `ensure_workspace_indexed` ensures all user files have symbol maps: 1. **Phase 1: fqn_uri_index files (user only)** — files already known from `update_ast` calls. Vendor and stub URIs are skipped. -2. **Phase 2: `.gitignore`-aware workspace walk** — uses the `ignore` crate's `WalkBuilder` to recursively discover PHP files under the workspace root, respecting `.gitignore` rules (including nested and global gitignore files). This automatically skips generated/cached directories like `storage/framework/views/` (Laravel blade cache), `var/cache/` (Symfony), and `node_modules/`. The vendor directory is always skipped regardless of `.gitignore` content. Hidden directories are skipped by default. +2. **Phase 2: `.gitignore`-aware workspace walk** — uses the `ignore` crate's `WalkBuilder` to recursively discover PHP plus YAML/XML resource files under the workspace root, respecting `.gitignore` rules (including nested and global gitignore files). This automatically skips generated/cached directories like `storage/framework/views/` (Laravel blade cache), `var/cache/` (Symfony), and `node_modules/`. The vendor directory is always skipped regardless of `.gitignore` content. Hidden directories are skipped by default. -Both phases parse files in parallel using `std::thread::scope`. The work is split into chunks (one per CPU core) and each thread reads a file from disk and calls `update_ast`, which acquires write locks briefly to store results while the expensive parsing step runs without any locks held. Batches of 2 or fewer files skip threading overhead. +PHP files are parsed in parallel using `std::thread::scope`. The work is split into chunks (one per CPU core) and each thread reads a file from disk and calls `update_ast`, which acquires write locks briefly to store results while the expensive parsing step runs without any locks held. Batches of 2 or fewer files skip threading overhead. YAML/XML files take the lightweight schema-free scanner and publish synthetic class/member symbol maps into the same reference index. Parsed files stay cached in `uri_classes_index`, `symbol_maps`, `file_imports`, and `file_namespaces` after the scan completes. There is no post-scan eviction; keeping the entries means subsequent operations (a second find-references call, go-to-definition on a cross-file symbol) benefit from the work already done. diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 981448e35..5422610fd 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- **Fully-qualified PHP classes navigate from YAML and XML.** Ctrl+Click a class name in any YAML key or value, or any XML attribute or text node, and PHPantom opens its PHP declaration without needing to know that file's schema. `Class::member` references navigate too. Unknown and unqualified strings are left alone. Contributed by @sidux. +- **Fully-qualified PHP classes navigate from YAML and XML.** Ctrl+Click a class name in any YAML key or value, or any XML attribute or text node, and PHPantom opens its PHP declaration without needing to know that file's schema. `Class::member` references navigate too. The same occurrences feed Find References and declaration CodeLens through the workspace reference index. Unknown and unqualified strings are left alone. Contributed by @sidux. - **Reference CodeLens.** PHP declarations show clickable exact reference counts. Declarations with no indexed uses are answered immediately, while semantic member locations are cached in a bounded background index so opening a large file does not fan out into an expensive resolve request per lens. Clients that support CodeLens refresh receive only ready, fully resolved member lenses. Contributed by @sidux. - **`analyze` takes more than one path.** `phpantom_lsp analyze app/ lib/Helper.php tests/` scans the union of everything named, mixing directories and single files freely, so a pre-commit hook or a CI step can hand it exactly the paths that changed instead of running the whole project or invoking the binary once per path. Overlapping arguments are reported once, and a path that does not exist still stops the run with exit code 2. Naming no path scans the entire project, as before. diff --git a/src/code_lens.rs b/src/code_lens.rs index be9176924..c4996ee85 100644 --- a/src/code_lens.rs +++ b/src/code_lens.rs @@ -242,11 +242,17 @@ impl Backend { if declaration_offset == 0 { return None; } - let key = ReferenceIndexKey::Member { - name: member.to_string(), - is_static, - }; - let candidate_count = self.indexed_reference_count(&key)?; + let member_name = member.to_string(); + let candidate_count = self.indexed_reference_count_for_keys(&[ + ReferenceIndexKey::Member { + name: member_name.clone(), + is_static, + }, + ReferenceIndexKey::Member { + name: member_name, + is_static: !is_static, + }, + ])?; let origin_url = Url::parse(origin_uri).ok()?; let position = offset_to_position(content, declaration_offset as usize); let range = Range::new( diff --git a/src/indexing/preload.rs b/src/indexing/preload.rs index f51939c7e..a58f2a60a 100644 --- a/src/indexing/preload.rs +++ b/src/indexing/preload.rs @@ -282,20 +282,24 @@ impl Backend { // the initial pass and reuse it. let workspace_root = self.workspace.workspace_root.read().clone(); let phase1_uri_set: HashSet<&str> = phase1_uris.iter().map(|uri| uri.as_str()).collect(); - let phase2_work = if let Some(root) = workspace_root.clone() { + let (phase2_work, resource_work) = if let Some(root) = workspace_root.clone() { let vendor_dir_paths = self.workspace.vendor_dir_paths.lock().clone(); self.report_workspace_index_progress(progress, 3, "Scanning workspace files"); let walk_start = std::time::Instant::now(); - let php_files = - crate::references::collect_php_files_gitignore(&root, &vendor_dir_paths); + let (php_files, resource_files) = + crate::references::collect_workspace_index_files_gitignore( + &root, + &vendor_dir_paths, + ); tracing::info!( - "ensure_workspace_indexed: Phase 2 disk walk found {} PHP files in {:?}", + "ensure_workspace_indexed: Phase 2 disk walk found {} PHP and {} resource files in {:?}", php_files.len(), + resource_files.len(), walk_start.elapsed() ); - php_files + let php_work = php_files .into_iter() .filter_map(|path| { let uri = crate::util::path_to_uri(&path); @@ -305,12 +309,20 @@ impl Backend { Some((uri, path)) } }) - .collect() + .collect(); + let resource_work = resource_files + .into_iter() + .filter_map(|path| { + let uri = crate::util::path_to_uri(&path); + (!existing_uris.contains(&uri)).then_some((uri, path)) + }) + .collect(); + (php_work, resource_work) } else { - Vec::new() + (Vec::new(), Vec::new()) }; - let total_to_parse = phase1_uris.len() + phase2_work.len(); + let total_to_parse = phase1_uris.len() + phase2_work.len() + resource_work.len(); let phase1_units: u64 = phase1_uris .iter() .map(|uri| self.index_progress_weight_for_uri(uri, None)) @@ -319,7 +331,14 @@ impl Backend { .iter() .map(|(_, path)| index_progress_weight_for_path(path)) .sum(); - let total_parse_units = phase1_units.saturating_add(phase2_units).max(1); + let resource_units: u64 = resource_work + .iter() + .map(|(_, path)| index_progress_weight_for_path(path)) + .sum(); + let total_parse_units = phase1_units + .saturating_add(phase2_units) + .saturating_add(resource_units) + .max(1); self.report_workspace_index_progress( progress, 5, @@ -376,6 +395,20 @@ impl Backend { }), ); } + if !resource_work.is_empty() { + self.report_workspace_index_progress( + progress, + workspace_parse_percentage( + phase1_units.saturating_add(phase2_units), + total_parse_units, + ), + format!( + "Indexing resource references ({}/{total_to_parse})", + phase1_uris.len() + phase2_work.len() + ), + ); + self.index_resource_paths_batch(&resource_work); + } self.report_workspace_index_progress(progress, 99, "Finalizing workspace index"); // Release pairs with the Acquire loads in // `reference_candidate_uris_for_keys` and `find_implementors`. diff --git a/src/indexing/watch.rs b/src/indexing/watch.rs index 7cb43c053..4073e6aeb 100644 --- a/src/indexing/watch.rs +++ b/src/indexing/watch.rs @@ -23,10 +23,10 @@ const GLOBAL_CONFIG_POLL_INTERVAL: Duration = Duration::from_secs(2); impl Backend { /// Apply a `workspace/didChangeWatchedFiles` batch to the indexes. /// - /// Returns `true` if any PHP file, composer file, or the project's own - /// `.phpantom.toml` was acted on (so the caller can ask the editor to - /// re-pull diagnostics). Runs entirely on a blocking thread; it parses - /// no files on the async runtime. + /// Returns `true` if any PHP/resource file, composer file, or the + /// project's own `.phpantom.toml` was acted on (so the caller can ask the + /// editor to refresh affected features). Runs entirely on a blocking + /// thread; it parses no files on the async runtime. /// /// Editors cannot watch the filesystem while the window is unfocused, so /// on refocus they resynchronise by reporting the *entire* workspace as @@ -52,6 +52,7 @@ impl Backend { let mut schema_full_rebuild = false; let mut migration_changes: Vec<(PathBuf, FileChangeType)> = Vec::new(); let mut php_changes: Vec<(String, PathBuf, FileChangeType)> = Vec::new(); + let mut resource_changes: Vec<(String, PathBuf, FileChangeType)> = Vec::new(); let mut migration_discovery = crate::virtual_members::laravel::database_schema::MigrationDiscovery::default(); let is_laravel = self.resolved_class_cache.read().is_laravel(); @@ -59,6 +60,7 @@ impl Backend { { let open = self.open_files.read(); let parsed = self.parsed_uris.read(); + let indexed = self.symbol_maps.read(); let laravel_config = self.config().laravel; for change in ¶ms.changes { let path_str = change.uri.path(); @@ -103,12 +105,30 @@ impl Backend { } continue; } + let uri_str = change.uri.to_string(); + if crate::resource_navigation::is_resource_document(path_str) { + if open.contains_key(&uri_str) { + continue; + } + let Ok(file_path) = change.uri.to_file_path() else { + continue; + }; + if change.typ == FileChangeType::CHANGED { + let canonical_uri = crate::util::path_to_uri(&file_path); + if !indexed.contains_key(&uri_str) + && !indexed.contains_key(canonical_uri.as_str()) + { + continue; + } + } + resource_changes.push((uri_str, file_path, change.typ)); + continue; + } if !path_str.ends_with(".php") { continue; } // Open files are already tracked via did_open/did_change. - let uri_str = change.uri.to_string(); if open.contains_key(&uri_str) { continue; } @@ -133,6 +153,7 @@ impl Backend { } if php_changes.is_empty() + && resource_changes.is_empty() && !composer_changed && !config_changed && !schema_full_rebuild @@ -175,6 +196,19 @@ impl Backend { self.rescan_composer_indexes(root); } + if !resource_changes.is_empty() { + tracing::info!( + "PHPantom: {} watched YAML/XML file(s) changed on disk, refreshing references", + resource_changes.len() + ); + for (uri, path, change_type) in &resource_changes { + if *change_type == FileChangeType::DELETED { + self.clear_file_maps(uri); + } else if let Ok(content) = std::fs::read_to_string(path) { + self.update_resource_symbol_index(uri, &content); + } + } + } if schema_full_rebuild { tracing::info!("PHPantom: Laravel schema files changed, reloading schema index"); self.reload_laravel_schema_index(root); diff --git a/src/reference_index.rs b/src/reference_index.rs index c71f5d58f..0759a4c74 100644 --- a/src/reference_index.rs +++ b/src/reference_index.rs @@ -319,16 +319,25 @@ impl Backend { /// filtering can remove name matches, but it cannot create a reference /// that the symbol map did not index. pub(crate) fn indexed_reference_count(&self, key: &ReferenceIndexKey) -> Option { + self.indexed_reference_count_for_keys(std::slice::from_ref(key)) + } + + /// Number of indexed occurrences across several alternative keys. + pub(crate) fn indexed_reference_count_for_keys( + &self, + keys: &[ReferenceIndexKey], + ) -> Option { if self.skip_reference_index || !self.workspace_indexed.load(Ordering::Acquire) { return None; } + let index = self.reference_index.read(); Some( - self.reference_index - .read() - .get(key) - .map(|entries| entries.values().map(|&count| count as usize).sum()) - .unwrap_or(0), + keys.iter() + .filter_map(|key| index.get(key)) + .flat_map(HashMap::values) + .map(|&count| count as usize) + .sum(), ) } @@ -560,6 +569,12 @@ impl Backend { ) -> Vec<(ReferenceIndexKey, bool)> { match &span.kind { SymbolKind::ClassReference { name, is_fqn, .. } => { + if *is_fqn && crate::resource_navigation::is_resource_document(uri) { + return vec![( + ReferenceIndexKey::class_owned(normalize_symbol_name(name)), + true, + )]; + } let resolved = if *is_fqn { normalize_symbol_name(name) } else if let Some(fqn) = self.resolved_name_at(uri, span.start) { diff --git a/src/references/classes.rs b/src/references/classes.rs index 21ef8c6fb..8a21341cd 100644 --- a/src/references/classes.rs +++ b/src/references/classes.rs @@ -45,6 +45,9 @@ impl Backend { let resolved_names = self.resolved_names.read().get(file_uri).cloned(); let file_namespace = self.first_file_namespace(file_uri); let file_use_map = std::cell::OnceCell::new(); + let class_matches = |resolved: &str| { + class_names_match(strip_fqn_prefix(resolved), target, target_short) + }; // First pass: resolved-name check to avoid unnecessary content work. // Aliased imports (`use Foo as Bar; new Bar`) must still reach the @@ -68,7 +71,7 @@ impl Backend { }); Self::resolve_to_fqn(name, use_map, &file_namespace) }; - class_names_match(strip_fqn_prefix(&resolved), target, target_short) + class_matches(&resolved) } } SymbolKind::ClassDeclaration { name } => { @@ -109,7 +112,7 @@ impl Backend { }); Self::resolve_to_fqn(name, use_map, &file_namespace) }; - class_names_match(strip_fqn_prefix(&resolved), target, target_short) + class_matches(&resolved) } SymbolKind::ClassDeclaration { name } if include_declaration => { if !name.eq_ignore_ascii_case(target_short) { diff --git a/src/references/mod.rs b/src/references/mod.rs index 56b1f2fbe..ed922ca48 100644 --- a/src/references/mod.rs +++ b/src/references/mod.rs @@ -327,9 +327,40 @@ pub(crate) fn collect_php_files_gitignore( root: &Path, vendor_dir_paths: &[PathBuf], ) -> Vec { + let mut result = Vec::new(); + visit_workspace_files_gitignore(root, vendor_dir_paths, |path| { + if path.extension().is_some_and(|extension| extension == "php") { + result.push(path.to_path_buf()); + } + }); + result +} + +/// Collect the PHP and schema-free YAML/XML inputs used by the full workspace +/// index in one `.gitignore`-aware walk. +pub(crate) fn collect_workspace_index_files_gitignore( + root: &Path, + vendor_dir_paths: &[PathBuf], +) -> (Vec, Vec) { + let mut php_files = Vec::new(); + let mut resource_files = Vec::new(); + visit_workspace_files_gitignore(root, vendor_dir_paths, |path| { + if path.extension().is_some_and(|extension| extension == "php") { + php_files.push(path.to_path_buf()); + } else if crate::resource_navigation::is_resource_path(path) { + resource_files.push(path.to_path_buf()); + } + }); + (php_files, resource_files) +} + +fn visit_workspace_files_gitignore( + root: &Path, + vendor_dir_paths: &[PathBuf], + mut visit: impl FnMut(&Path), +) { use ignore::WalkBuilder; - let mut result = Vec::new(); let vendor_paths_owned: Vec = vendor_dir_paths.to_vec(); let walker = WalkBuilder::new(root) @@ -357,12 +388,10 @@ pub(crate) fn collect_php_files_gitignore( for entry in walker.flatten() { let path = entry.path(); - if path.is_file() && path.extension().is_some_and(|ext| ext == "php") { - result.push(path.to_path_buf()); + if path.is_file() { + visit(path); } } - - result } /// Push a location only if it is not already present (deduplication). diff --git a/src/resource_navigation.rs b/src/resource_navigation.rs index 3091cc411..bf4018bd7 100644 --- a/src/resource_navigation.rs +++ b/src/resource_navigation.rs @@ -5,9 +5,14 @@ //! without knowing the schema of the file that contains them, then delegates //! declaration lookup to the normal PHP class loader. +use std::path::{Path, PathBuf}; +use std::sync::Arc; + use tower_lsp::lsp_types::{Location, Position}; use crate::Backend; +use crate::atom::{AtomMap, atom}; +use crate::symbol_map::{ClassRefContext, SubjectText, SymbolKind, SymbolMap, SymbolSpan}; #[derive(Debug, PartialEq, Eq)] enum ResourceSymbol { @@ -18,6 +23,14 @@ enum ResourceSymbol { }, } +#[derive(Debug, PartialEq, Eq)] +struct ScannedResourceSymbol { + class_fqn: String, + class_start: usize, + class_end: usize, + member: Option<(String, usize, usize)>, +} + /// Whether `uri` names a YAML or XML document that can carry PHP symbols. pub(crate) fn is_resource_document(uri: &str) -> bool { let path = uri @@ -38,6 +51,13 @@ pub(crate) fn is_resource_document(uri: &str) -> bool { .any(|suffix| path.ends_with(suffix)) } +/// Whether a filesystem path is a YAML/XML resource document. +pub(crate) fn is_resource_path(path: &Path) -> bool { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(is_resource_document) +} + impl Backend { /// Resolve the fully-qualified PHP class or `Class::member` under the /// cursor in a YAML/XML document. @@ -54,13 +74,118 @@ impl Backend { } => self.class_member_declaration_location(&class_fqn, &member_name), } } + + /// Replace one resource document's synthetic symbol map and reference + /// contributions. + pub(crate) fn update_resource_symbol_index(&self, uri: &str, content: &str) { + let symbol_map = Arc::new(self.resource_symbol_map(content)); + self.symbol_maps + .write() + .insert(uri.to_string(), Arc::clone(&symbol_map)); + self.reindex_references_for_symbol_maps_batch(vec![(uri.to_string(), symbol_map)]); + } + + /// Index resource files discovered during the workspace walk. + pub(crate) fn index_resource_paths_batch(&self, files: &[(String, PathBuf)]) { + let maps: Vec<(String, Arc)> = files + .iter() + .filter_map(|(uri, path)| { + let content = std::fs::read_to_string(path).ok()?; + Some((uri.clone(), Arc::new(self.resource_symbol_map(&content)))) + }) + .collect(); + if maps.is_empty() { + return; + } + + { + let mut symbol_maps = self.symbol_maps.write(); + for (uri, map) in &maps { + symbol_maps.insert(uri.clone(), Arc::clone(map)); + } + } + self.reindex_references_for_symbol_maps_batch(maps); + } + + fn resource_symbol_map(&self, content: &str) -> SymbolMap { + let mut spans = Vec::new(); + for symbol in scan_symbols(content) { + spans.push(SymbolSpan { + start: symbol.class_start as u32, + end: symbol.class_end as u32, + kind: SymbolKind::ClassReference { + name: atom(&symbol.class_fqn), + is_fqn: true, + context: ClassRefContext::Other, + }, + }); + + if let Some((member_name, member_start, member_end)) = symbol.member { + spans.push(SymbolSpan { + start: member_start as u32, + end: member_end as u32, + kind: SymbolKind::MemberAccess { + subject_text: SubjectText::owned(symbol.class_fqn), + member_name: atom(&member_name), + is_static: false, + is_method_call: true, + docblock_ref: crate::symbol_map::DocblockMemberRef::No, + is_array_callable: false, + is_nullsafe: false, + }, + }); + } + } + spans.sort_by_key(|span| span.start); + + let mut member_access_indices = AtomMap::default(); + for (index, span) in spans.iter().enumerate() { + if let SymbolKind::MemberAccess { member_name, .. } = &span.kind { + member_access_indices + .entry(*member_name) + .or_insert_with(Vec::new) + .push(index); + } + } + + SymbolMap { + spans, + member_access_indices, + source_len: u32::try_from(content.len()).unwrap_or(u32::MAX), + ..SymbolMap::default() + } + } } fn symbol_at(content: &str, position: Position) -> Option { let offset = crate::text_position::position_to_offset(content, position) as usize; let previous_offset = offset.checked_sub(1); + + for symbol in scan_symbols(content) { + if contains_cursor( + symbol.class_start, + symbol.class_end, + offset, + previous_offset, + ) { + return Some(ResourceSymbol::Class(symbol.class_fqn)); + } + if let Some((member_name, member_start, member_end)) = symbol.member + && contains_cursor(member_start, member_end, offset, previous_offset) + { + return Some(ResourceSymbol::Member { + class_fqn: symbol.class_fqn, + member_name, + }); + } + } + None +} + +fn scan_symbols(content: &str) -> Vec { let bytes = content.as_bytes(); let mut cursor = 0usize; + let mut symbols = Vec::new(); while cursor < bytes.len() { if !is_name_start(bytes[cursor]) || (cursor > 0 && is_name_char(bytes[cursor - 1])) { @@ -78,29 +203,36 @@ fn symbol_at(content: &str, position: Position) -> Option { if raw_name.contains('\\') && !raw_name.ends_with('\\') { let fqn = normalize_fqn(raw_name); if is_class_fqn(&fqn) { - if contains_cursor(class_start, class_end, offset, previous_offset) { - return Some(ResourceSymbol::Class(fqn)); - } - - if bytes.get(class_end) == Some(&b':') && bytes.get(class_end + 1) == Some(&b':') { + let member = if bytes.get(class_end) == Some(&b':') + && bytes.get(class_end + 1) == Some(&b':') + { let member_start = class_end + 2; let member_end = scan_identifier(bytes, member_start); - if member_end > member_start - && contains_cursor(member_start, member_end, offset, previous_offset) - { - return Some(ResourceSymbol::Member { - class_fqn: fqn, - member_name: content[member_start..member_end].to_string(), - }); + if member_end > member_start { + Some(( + content[member_start..member_end].to_string(), + member_start, + member_end, + )) + } else { + None } - } + } else { + None + }; + symbols.push(ScannedResourceSymbol { + class_fqn: fqn, + class_start, + class_end, + member, + }); } } cursor = class_end; } - None + symbols } fn contains_cursor(start: usize, end: usize, offset: usize, previous: Option) -> bool { diff --git a/src/server.rs b/src/server.rs index 4a9aabe84..260e00783 100644 --- a/src/server.rs +++ b/src/server.rs @@ -574,6 +574,14 @@ impl LanguageServer for Backend { glob_pattern: GlobPattern::String("**/*.php".to_string()), kind: Some(WatchKind::Create | WatchKind::Change | WatchKind::Delete), }, + FileSystemWatcher { + glob_pattern: GlobPattern::String("**/*.{yaml,yml,xml}".to_string()), + kind: Some(WatchKind::Create | WatchKind::Change | WatchKind::Delete), + }, + FileSystemWatcher { + glob_pattern: GlobPattern::String("**/*.{yaml,yml,xml}.dist".to_string()), + kind: Some(WatchKind::Create | WatchKind::Change | WatchKind::Delete), + }, FileSystemWatcher { glob_pattern: GlobPattern::String("**/composer.json".to_string()), kind: Some(WatchKind::Change), @@ -740,9 +748,11 @@ impl LanguageServer for Backend { .write() .insert(uri.clone(), Arc::clone(&text)); - // Resource documents are not PHP source. Their schema-free symbol - // navigation reads the open buffer directly when requested. + // Resource documents are not PHP source. Build a lightweight symbol + // map so navigation, references, rename, and PHP declaration lenses + // all consume the same indexed occurrences. if crate::resource_navigation::is_resource_document(&uri) { + self.update_resource_symbol_index(&uri, &text); self.log(MessageType::INFO, format!("Opened resource file: {}", uri)) .await; return; @@ -828,6 +838,12 @@ impl LanguageServer for Backend { .insert(uri.clone(), Arc::clone(&text)); if crate::resource_navigation::is_resource_document(&uri) { + self.update_resource_symbol_index(&uri, &text); + if self.supports_code_lens_refresh.load(Ordering::Acquire) + && let Some(ref client) = self.client + { + let _ = client.code_lens_refresh().await; + } return; } @@ -936,7 +952,15 @@ impl LanguageServer for Backend { self.blade_injected_vars.write().remove(&uri); } - self.clear_file_maps(&uri); + if crate::resource_navigation::is_resource_document(&uri) { + if let Some(content) = self.get_file_content(&uri) { + self.update_resource_symbol_index(&uri, &content); + } else { + self.clear_file_maps(&uri); + } + } else { + self.clear_file_maps(&uri); + } // Clear diagnostics so stale warnings don't linger after the file is closed self.clear_diagnostics_for_file(&uri).await; @@ -954,7 +978,9 @@ impl LanguageServer for Backend { self.open_files .write() .insert(uri.clone(), Arc::clone(&text)); - if !is_resource { + if is_resource { + self.update_resource_symbol_index(&uri, &text); + } else { self.update_ast(&uri, &text); } } @@ -1014,6 +1040,11 @@ impl LanguageServer for Backend { // (or missing ones) are corrected. if did_work { self.request_diagnostic_refresh().await; + if self.supports_code_lens_refresh.load(Ordering::Acquire) + && let Some(ref client) = self.client + { + let _ = client.code_lens_refresh().await; + } } } diff --git a/tests/integration/definition_resource_files.rs b/tests/integration/definition_resource_files.rs index 99558c8c7..113dfc13c 100644 --- a/tests/integration/definition_resource_files.rs +++ b/tests/integration/definition_resource_files.rs @@ -146,6 +146,156 @@ async fn navigates_class_members_and_yaml_escaped_class_names() { assert_eq!(member_location.range.start.line, 3); } +#[tokio::test] +async fn resource_classes_feed_find_references_and_code_lens() { + let php = concat!(""#; + let (backend, dir) = create_psr4_workspace( + COMPOSER, + &[ + ("src/Domain/Widget.php", php), + ("config/widgets.yaml", yaml), + ("config/widgets.xml", xml), + ], + ); + let php_uri = Url::from_file_path(dir.path().join("src/Domain/Widget.php")).unwrap(); + open_resource(&backend, php_uri.clone(), "php", php).await; + + let references = backend + .references(ReferenceParams { + text_document_position: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { + uri: php_uri.clone(), + }, + position: Position::new(2, 8), + }, + context: ReferenceContext { + include_declaration: false, + }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + }) + .await + .expect("reference request should succeed") + .expect("resource references should be found"); + assert_eq!(references.len(), 3); + assert_eq!( + references + .iter() + .filter(|location| location.uri.path().ends_with("/config/widgets.yaml")) + .count(), + 2 + ); + assert_eq!( + references + .iter() + .filter(|location| location.uri.path().ends_with("/config/widgets.xml")) + .count(), + 1 + ); + + let lenses = backend + .code_lens(CodeLensParams { + text_document: TextDocumentIdentifier { uri: php_uri }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + }) + .await + .expect("code lens request should succeed") + .expect("class reference lens should be present"); + let lens = lenses + .into_iter() + .find(|lens| lens.range.start.line == 2 && lens.data.is_some()) + .expect("class declaration should have an unresolved reference lens"); + let resolved = backend + .code_lens_resolve(lens) + .await + .expect("class reference lens should resolve"); + assert_eq!( + resolved + .command + .as_ref() + .map(|command| command.title.as_str()), + Some("3 references") + ); +} + +#[tokio::test] +async fn resource_class_members_feed_find_references_and_code_lens() { + let php = concat!( + " Date: Mon, 6 Jul 2026 10:25:55 +0200 Subject: [PATCH 10/17] feat(frameworks): Add Symfony and Doctrine resource navigation Index semantic framework relationships alongside generic YAML and XML class references. --- src/code_lens.rs | 819 ++++++++++++++- src/definition/resolve.rs | 55 +- src/framework.rs | 1168 ++++++++++++++++++++++ src/highlight/mod.rs | 4 +- src/indexing/watch.rs | 25 + src/lib.rs | 11 + src/references/classes.rs | 13 +- src/references/dispatch.rs | 68 ++ src/references/members.rs | 246 ++++- src/references/mod.rs | 1 + src/references/tests.rs | 22 +- src/rename/namespace.rs | 15 +- src/rename/prepare.rs | 146 ++- src/server.rs | 68 +- tests/integration/code_lens.rs | 214 +++- tests/integration/framework_resources.rs | 336 +++++++ tests/integration/main.rs | 1 + 17 files changed, 3149 insertions(+), 63 deletions(-) create mode 100644 src/framework.rs create mode 100644 tests/integration/framework_resources.rs diff --git a/src/code_lens.rs b/src/code_lens.rs index c4996ee85..fb96bf117 100644 --- a/src/code_lens.rs +++ b/src/code_lens.rs @@ -1,16 +1,20 @@ //! Code Lens (`textDocument/codeLens`) support. //! -//! Shows reference counts plus override/implement annotations. +//! Shows reference counts plus clickable inheritance, implementation, Symfony, +//! and Doctrine relationship annotations. +use std::collections::HashSet; use tower_lsp::lsp_types::*; use crate::Backend; use crate::atom::Atom; +use crate::class_lookup::find_class_at_offset; use crate::definition::member::MemberKind; use crate::reference_index::ReferenceIndexKey; use crate::symbol_map::SymbolKind; use crate::text_position::offset_to_position; use crate::types::{ClassInfo, ClassLikeKind, MAX_INHERITANCE_DEPTH, Visibility}; +use crate::util::short_name; fn line_indent(content: &str, byte_offset: usize) -> u32 { let line_start = content[..byte_offset] @@ -38,8 +42,8 @@ struct Prototype { impl Backend { /// Handle a `textDocument/codeLens` request. /// - /// Returns reference lenses for PHP declarations and navigation lenses - /// for methods that override or implement an ancestor declaration. + /// Returns reference, inheritance, implementation, and indexed framework + /// relationship lenses for PHP declarations. pub fn handle_code_lens(&self, uri: &str, content: &str) -> Option> { let classes = { let map = self.symbols.uri_classes_index.read(); @@ -47,6 +51,9 @@ impl Backend { }; let mut lenses = Vec::new(); + let mut seen = HashSet::new(); + let ctx = self.file_context(uri); + let class_loader = self.class_loader(&ctx); for class in &classes { let class_fqn = class.fqn(); @@ -63,7 +70,13 @@ impl Backend { if let Some(lens) = self.build_covers_lens(class, uri, content) { lenses.push(lens); } - + self.push_framework_class_lenses( + uri, + content, + class, + &class_loader, + (&mut lenses, &mut seen), + ); for method in &class.methods { if method.name_offset == 0 || method.is_virtual @@ -110,12 +123,25 @@ impl Backend { let command = self.build_code_lens_command(title, target_uri, proto.position); - lenses.push(CodeLens { - range, - command: Some(command), - data: None, - }); + push_unique_lens( + &mut lenses, + &mut seen, + CodeLens { + range, + command: Some(command), + data: None, + }, + ); } + + self.push_framework_method_lenses( + uri, + content, + class, + method.name.as_str(), + method.name_offset, + (&mut lenses, &mut seen), + ); } for property in &class.properties { @@ -177,6 +203,25 @@ impl Backend { } } + self.push_symfony_route_attribute_lenses(uri, content, &classes, &mut lenses, &mut seen); + self.push_doctrine_get_repository_lenses( + uri, + content, + &ctx, + &class_loader, + &mut lenses, + &mut seen, + ); + + lenses.sort_by(|a, b| { + a.range + .start + .line + .cmp(&b.range.start.line) + .then(a.range.start.character.cmp(&b.range.start.character)) + .then(lens_title(a).cmp(&lens_title(b))) + }); + if lenses.is_empty() { None } else { @@ -536,6 +581,322 @@ impl Backend { }) } + fn push_framework_class_lenses( + &self, + uri: &str, + content: &str, + class: &ClassInfo, + class_loader: &dyn Fn(&str) -> Option>, + output: (&mut Vec, &mut HashSet), + ) { + let Some(source_pos) = class_lens_position(content, class) else { + return; + }; + let class_fqn = class.fqn(); + + let config_locations = self.framework_class_reference_locations(class_fqn.as_str()); + if !config_locations.is_empty() { + let title = if config_locations.len() == 1 { + "Symfony/Doctrine config: 1 ref".to_string() + } else { + format!("Symfony/Doctrine config: {} refs", config_locations.len()) + }; + self.push_locations_lens(uri, source_pos, title, config_locations, output.0, output.1); + } + + for repo_fqn in self + .doctrine_repository_fqns_for_entity(class_fqn.as_str(), class_loader) + .into_iter() + .filter(|fqn| !is_builtin_doctrine_repository_fqn(fqn)) + { + if let Some(location) = self.class_location(&repo_fqn, uri, content) { + let title = format!("Doctrine repository: {}", short_name(&repo_fqn)); + self.push_locations_lens( + uri, + source_pos, + title, + vec![location], + output.0, + output.1, + ); + } + } + + for entity_fqn in self.doctrine_entities_for_repository(class_fqn.as_str(), class_loader) { + if let Some(location) = self.class_location(&entity_fqn, uri, content) { + let title = format!("Doctrine entity: {}", short_name(&entity_fqn)); + self.push_locations_lens( + uri, + source_pos, + title, + vec![location], + output.0, + output.1, + ); + } + } + } + + fn push_framework_method_lenses( + &self, + uri: &str, + content: &str, + class: &ClassInfo, + method_name: &str, + name_offset: u32, + output: (&mut Vec, &mut HashSet), + ) { + let pos = offset_to_position(content, name_offset as usize); + let mut hierarchy = HashSet::new(); + hierarchy.insert(class.fqn().to_string()); + for fqn in self.class_hierarchy_names(class) { + hierarchy.insert(fqn); + } + + let route_locations = + self.framework_member_reference_locations(method_name, Some(&hierarchy)); + if route_locations.is_empty() { + return; + } + + let title = if route_locations.len() == 1 { + "Symfony route config: 1 ref".to_string() + } else { + format!("Symfony route config: {} refs", route_locations.len()) + }; + self.push_locations_lens(uri, pos, title, route_locations, output.0, output.1); + } + + fn push_symfony_route_attribute_lenses( + &self, + uri: &str, + content: &str, + classes: &[std::sync::Arc], + lenses: &mut Vec, + seen: &mut HashSet, + ) { + let declarations = code_lens_declarations(content, classes); + if declarations.is_empty() { + return; + } + + for attr in route_attributes(content) { + let Some(decl) = declarations + .iter() + .filter(|decl| decl.offset > attr.end) + .min_by_key(|decl| decl.offset) + else { + continue; + }; + if decl.offset.saturating_sub(attr.end) > 1024 { + continue; + } + let between = &content[attr.end..decl.offset]; + if between.contains(';') || between.contains('{') || between.contains('}') { + continue; + } + let Some(title) = route_attribute_lens_title(&attr, decl.kind) else { + continue; + }; + + let source_pos = decl.position; + let Ok(parsed_uri) = Url::parse(uri) else { + continue; + }; + let location = Location { + uri: parsed_uri, + range: Range { + start: source_pos, + end: source_pos, + }, + }; + self.push_locations_lens(uri, source_pos, title, vec![location], lenses, seen); + } + } + + fn push_doctrine_get_repository_lenses( + &self, + uri: &str, + content: &str, + ctx: &crate::types::FileContext, + class_loader: &dyn Fn(&str) -> Option>, + lenses: &mut Vec, + seen: &mut HashSet, + ) { + for call in get_repository_calls(content) { + let Some(entity_fqn) = class_expr_arg_to_fqn( + &call.first_arg, + &ctx.use_map, + &ctx.namespace, + &ctx.classes, + call.offset as u32, + ) else { + continue; + }; + let mut locations = Vec::new(); + let mut title = None; + + for repo_fqn in self + .doctrine_repository_fqns_for_entity(&entity_fqn, class_loader) + .into_iter() + .filter(|fqn| !is_builtin_doctrine_repository_fqn(fqn)) + { + if let Some(location) = self.class_location(&repo_fqn, uri, content) { + title = Some(format!("Doctrine repository: {}", short_name(&repo_fqn))); + locations.push(location); + break; + } + } + + if locations.is_empty() + && let Some(location) = self.class_location(&entity_fqn, uri, content) + { + title = Some(format!("Doctrine entity: {}", short_name(&entity_fqn))); + locations.push(location); + } + + let Some(title) = title else { + continue; + }; + let pos = offset_to_position(content, call.offset); + self.push_locations_lens(uri, pos, title, locations, lenses, seen); + } + } + + fn push_locations_lens( + &self, + origin_uri: &str, + source_pos: Position, + title: String, + locations: Vec, + lenses: &mut Vec, + seen: &mut HashSet, + ) { + if locations.is_empty() { + return; + } + let Ok(origin_url) = Url::parse(origin_uri) else { + return; + }; + let range = Range { + start: Position { + line: source_pos.line, + character: 0, + }, + end: Position { + line: source_pos.line, + character: 0, + }, + }; + let command = + self.build_code_lens_locations_command(title, origin_url, source_pos, locations); + push_unique_lens( + lenses, + seen, + CodeLens { + range, + command: Some(command), + data: None, + }, + ); + } + + fn class_location( + &self, + fqn: &str, + current_uri: &str, + current_content: &str, + ) -> Option { + let class_info = self.find_or_load_class(fqn)?; + let class_fqn = class_info.fqn(); + let (file_uri, file_content) = + self.find_class_file_content(&class_fqn, current_uri, current_content)?; + let offset = class_info + .keyword_offset + .max(class_info.decl_start_offset) + .min(file_content.len() as u32); + if offset == 0 { + return None; + } + let uri = Url::parse(&file_uri).ok()?; + let pos = offset_to_position(&file_content, offset as usize); + Some(Location { + uri, + range: Range { + start: pos, + end: pos, + }, + }) + } + + fn doctrine_entities_for_repository( + &self, + repository_fqn: &str, + class_loader: &dyn Fn(&str) -> Option>, + ) -> Vec { + let mut out = self.framework_doctrine_entity_fqns_for_repository(repository_fqn); + let repository = normalize_class_name(repository_fqn); + + let mut candidates: Vec = Vec::new(); + { + let index = self.symbols.fqn_class_index.read(); + candidates.extend(index.keys().map(|key| key.to_string())); + } + { + let uri_index = self.symbols.uri_classes_index.read(); + for classes in uri_index.values() { + for class in classes { + candidates.push(class.fqn().to_string()); + } + } + } + candidates.sort(); + candidates.dedup_by(|a, b| a.eq_ignore_ascii_case(b)); + + for entity_fqn in candidates { + if entity_fqn.eq_ignore_ascii_case(&repository) { + continue; + } + if !looks_like_doctrine_entity_name(&entity_fqn) { + continue; + } + let repos = self.doctrine_repository_fqns_for_entity(&entity_fqn, class_loader); + if repos + .iter() + .any(|repo| normalize_class_name(repo).eq_ignore_ascii_case(&repository)) + && !out + .iter() + .any(|known| known.eq_ignore_ascii_case(&entity_fqn)) + { + out.push(entity_fqn); + } + } + + out + } + + fn class_hierarchy_names(&self, class: &ClassInfo) -> Vec { + let mut out = Vec::new(); + let mut current = class.clone(); + for _ in 0..MAX_INHERITANCE_DEPTH { + let Some(parent_name) = current.parent_class else { + break; + }; + let parent_fqn = parent_name.to_string(); + if !out + .iter() + .any(|known: &String| known.eq_ignore_ascii_case(&parent_fqn)) + { + out.push(parent_fqn.clone()); + } + let Some(parent) = self.find_or_load_class(&parent_name) else { + break; + }; + current = ClassInfo::clone(&parent); + } + out + } + /// Search the inheritance hierarchy for the closest ancestor that /// declares a method with the given name. /// @@ -791,6 +1152,20 @@ impl Backend { } } + fn build_code_lens_locations_command( + &self, + title: String, + origin_uri: Url, + origin_position: Position, + locations: Vec, + ) -> Command { + let (uri, position) = locations + .first() + .map(|location| (location.uri.clone(), location.range.start)) + .unwrap_or((origin_uri, origin_position)); + self.build_code_lens_command(title, uri, position) + } + /// Build a `Prototype` by locating the method's position in the /// ancestor's source file. fn build_prototype( @@ -826,3 +1201,429 @@ impl Backend { }) } } + +#[derive(Clone, Copy)] +struct CodeLensDeclaration { + offset: usize, + position: Position, + kind: RouteDeclarationKind, +} + +#[derive(Clone, Copy)] +enum RouteDeclarationKind { + Class, + Method, +} + +struct RouteAttribute { + end: usize, + path: Option, + name: Option, + methods: Vec, +} + +struct GetRepositoryCall { + offset: usize, + first_arg: String, +} + +fn class_lens_position(content: &str, class: &ClassInfo) -> Option { + let offset = if class.keyword_offset > 0 { + class.keyword_offset + } else { + class.decl_start_offset + }; + if offset == 0 || offset as usize > content.len() { + None + } else { + Some(offset_to_position(content, offset as usize)) + } +} + +fn code_lens_declarations( + content: &str, + classes: &[std::sync::Arc], +) -> Vec { + let mut declarations = Vec::new(); + for class in classes { + if let Some(position) = class_lens_position(content, class) { + let offset = if class.keyword_offset > 0 { + class.keyword_offset + } else { + class.decl_start_offset + }; + declarations.push(CodeLensDeclaration { + offset: offset as usize, + position, + kind: RouteDeclarationKind::Class, + }); + } + for method in &class.methods { + if method.name_offset == 0 || method.is_virtual { + continue; + } + declarations.push(CodeLensDeclaration { + offset: method.name_offset as usize, + position: offset_to_position(content, method.name_offset as usize), + kind: RouteDeclarationKind::Method, + }); + } + } + declarations.sort_by_key(|decl| decl.offset); + declarations +} + +fn route_attributes(content: &str) -> Vec { + let mut attributes = Vec::new(); + let mut search = 0usize; + while let Some(rel) = content[search..].find("#[") { + let start = search + rel; + let Some(end) = find_attribute_end(content, start) else { + break; + }; + let attr = &content[start..end]; + if !is_route_attribute(attr) { + search = end; + continue; + } + let args = attr + .find('(') + .zip(attr.rfind(')')) + .and_then(|(open, close)| (close > open).then_some(&attr[open + 1..close])) + .unwrap_or(""); + let path = find_named_string_arg(args, "path").or_else(|| first_string_literal(args)); + let name = find_named_string_arg(args, "name"); + let methods = find_methods_arg(args); + attributes.push(RouteAttribute { + end, + path, + name, + methods, + }); + search = end; + } + attributes +} + +fn route_attribute_lens_title( + attr: &RouteAttribute, + decl_kind: RouteDeclarationKind, +) -> Option { + let mut title = String::new(); + match decl_kind { + RouteDeclarationKind::Class => title.push_str("Symfony route prefix"), + RouteDeclarationKind::Method => title.push_str("Symfony route"), + } + + let mut parts = Vec::new(); + if !attr.methods.is_empty() { + parts.push(attr.methods.join("|")); + } + if let Some(path) = &attr.path + && !path.is_empty() + { + parts.push(path.clone()); + } + if let Some(name) = &attr.name + && !name.is_empty() + { + parts.push(format!("({name})")); + } + + if parts.is_empty() { + None + } else { + title.push_str(": "); + title.push_str(&parts.join(" ")); + Some(title) + } +} + +fn find_attribute_end(content: &str, start: usize) -> Option { + let bytes = content.as_bytes(); + let mut i = start + 2; + let mut depth = 1usize; + let mut quote: Option = None; + while i < bytes.len() { + let byte = bytes[i]; + if let Some(q) = quote { + if byte == b'\\' { + i += 2; + continue; + } + if byte == q { + quote = None; + } + i += 1; + continue; + } + match byte { + b'\'' | b'"' => quote = Some(byte), + b'[' => depth += 1, + b']' => { + depth = depth.saturating_sub(1); + if depth == 0 { + return Some(i + 1); + } + } + _ => {} + } + i += 1; + } + None +} + +fn is_route_attribute(attr: &str) -> bool { + let lower = attr.to_ascii_lowercase(); + lower.starts_with("#[route") + || lower.starts_with("#[\\symfony\\component\\routing\\attribute\\route") + || lower.starts_with("#[symfony\\component\\routing\\attribute\\route") + || lower.starts_with("#[\\symfony\\component\\routing\\annotation\\route") + || lower.starts_with("#[symfony\\component\\routing\\annotation\\route") +} + +fn find_named_string_arg(args: &str, name: &str) -> Option { + let pattern = format!("{name}:"); + let mut search = 0usize; + while let Some(rel) = args[search..].find(&pattern) { + let start = search + rel; + if start > 0 { + let prev = args.as_bytes()[start - 1]; + if prev == b'_' || prev.is_ascii_alphanumeric() { + search = start + pattern.len(); + continue; + } + } + let value_start = start + pattern.len(); + return first_string_literal(&args[value_start..]); + } + None +} + +fn first_string_literal(text: &str) -> Option { + let bytes = text.as_bytes(); + let mut i = 0usize; + while i < bytes.len() { + let quote = bytes[i]; + if quote != b'\'' && quote != b'"' { + i += 1; + continue; + } + let mut value = String::new(); + i += 1; + while i < bytes.len() { + if bytes[i] == b'\\' && i + 1 < bytes.len() { + value.push(bytes[i + 1] as char); + i += 2; + continue; + } + if bytes[i] == quote { + return Some(value); + } + value.push(bytes[i] as char); + i += 1; + } + return None; + } + None +} + +fn find_methods_arg(args: &str) -> Vec { + let Some(start) = args.find("methods:") else { + return Vec::new(); + }; + let tail = &args[start + "methods:".len()..]; + let end = tail.find("]").map(|idx| idx + 1).unwrap_or_else(|| { + tail.find(',') + .or_else(|| tail.find(')')) + .unwrap_or(tail.len()) + }); + let segment = &tail[..end]; + let mut out = Vec::new(); + let mut search = 0usize; + while let Some(method) = first_string_literal(&segment[search..]) { + let Some(pos) = segment[search..].find(&method) else { + break; + }; + let method_len = method.len(); + if !out.iter().any(|known: &String| known == &method) { + out.push(method); + } + search += pos + method_len + 1; + if search >= segment.len() { + break; + } + } + out +} + +fn get_repository_calls(content: &str) -> Vec { + let mut calls = Vec::new(); + let mut search = 0usize; + while let Some(rel) = content[search..].find("getRepository") { + let name_start = search + rel; + let name_end = name_start + "getRepository".len(); + if name_start > 0 && is_ident_byte(content.as_bytes()[name_start - 1]) { + search = name_end; + continue; + } + if content + .as_bytes() + .get(name_end) + .is_some_and(|byte| is_ident_byte(*byte)) + { + search = name_end; + continue; + } + let Some(open) = content[name_end..].find('(').map(|open| name_end + open) else { + break; + }; + if !content[name_end..open].trim().is_empty() { + search = name_end; + continue; + } + let Some(close) = find_matching_paren(content, open) else { + break; + }; + let args = &content[open + 1..close]; + if let Some(first_arg) = split_first_arg(args) { + calls.push(GetRepositoryCall { + offset: name_start, + first_arg: first_arg.to_string(), + }); + } + search = close + 1; + } + calls +} + +fn class_expr_arg_to_fqn( + first_arg: &str, + use_map: &std::collections::HashMap, + namespace: &Option, + local_classes: &[std::sync::Arc], + access_offset: u32, +) -> Option { + let class_expr = first_arg.trim().strip_suffix("::class")?.trim(); + let class_expr = class_expr.trim_start_matches('\\'); + if class_expr.is_empty() { + return None; + } + match class_expr { + "self" | "static" => { + find_class_at_offset(local_classes, access_offset).map(|class| class.fqn().to_string()) + } + "parent" => find_class_at_offset(local_classes, access_offset) + .and_then(|class| class.parent_class.map(|parent| parent.to_string())), + _ => Some(Backend::resolve_to_fqn(class_expr, use_map, namespace)), + } +} + +fn find_matching_paren(content: &str, open: usize) -> Option { + let bytes = content.as_bytes(); + let mut depth = 0usize; + let mut quote: Option = None; + let mut i = open; + while i < bytes.len() { + let byte = bytes[i]; + if let Some(q) = quote { + if byte == b'\\' { + i += 2; + continue; + } + if byte == q { + quote = None; + } + i += 1; + continue; + } + match byte { + b'\'' | b'"' => quote = Some(byte), + b'(' => depth += 1, + b')' => { + depth = depth.saturating_sub(1); + if depth == 0 { + return Some(i); + } + } + _ => {} + } + i += 1; + } + None +} + +fn split_first_arg(args: &str) -> Option<&str> { + let bytes = args.as_bytes(); + let mut quote: Option = None; + let mut paren_depth = 0usize; + let mut bracket_depth = 0usize; + for (i, byte) in bytes.iter().enumerate() { + if let Some(q) = quote { + if *byte == b'\\' { + continue; + } + if *byte == q { + quote = None; + } + continue; + } + match *byte { + b'\'' | b'"' => quote = Some(*byte), + b'(' => paren_depth += 1, + b')' => paren_depth = paren_depth.saturating_sub(1), + b'[' => bracket_depth += 1, + b']' => bracket_depth = bracket_depth.saturating_sub(1), + b',' if paren_depth == 0 && bracket_depth == 0 => return Some(args[..i].trim()), + _ => {} + } + } + let trimmed = args.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } +} + +fn is_ident_byte(byte: u8) -> bool { + byte == b'_' || byte.is_ascii_alphanumeric() +} + +fn is_builtin_doctrine_repository_fqn(fqn: &str) -> bool { + let normalized = normalize_class_name(fqn); + normalized.starts_with("Doctrine\\") + || matches!( + short_name(&normalized), + "ServiceEntityRepository" | "EntityRepository" | "ObjectRepository" + ) +} + +fn looks_like_doctrine_entity_name(fqn: &str) -> bool { + let normalized = normalize_class_name(fqn); + normalized.contains("\\Entity\\") + || normalized.contains("\\Entities\\") + || short_name(&normalized).ends_with("Entity") +} + +fn normalize_class_name(name: &str) -> String { + name.trim().trim_start_matches('\\').to_string() +} + +fn push_unique_lens(lenses: &mut Vec, seen: &mut HashSet, lens: CodeLens) { + let title = lens_title(&lens); + let key = format!( + "{}:{}:{}", + lens.range.start.line, lens.range.start.character, title + ); + if seen.insert(key) { + lenses.push(lens); + } +} + +fn lens_title(lens: &CodeLens) -> String { + lens.command + .as_ref() + .map(|command| command.title.clone()) + .unwrap_or_default() +} diff --git a/src/definition/resolve.rs b/src/definition/resolve.rs index 2ade50c39..25b7d7a08 100644 --- a/src/definition/resolve.rs +++ b/src/definition/resolve.rs @@ -24,6 +24,7 @@ use super::point_location; use crate::Backend; use crate::class_lookup::find_class_at_offset; use crate::composer; +use crate::framework::FrameworkReferenceKind; use crate::symbol_map::{SelfStaticParentKind, SymbolKind}; use crate::text_position::position_to_offset; use crate::types::{AccessKind, ClassInfo, MAX_INHERITANCE_DEPTH}; @@ -75,11 +76,63 @@ impl Backend { // Path helpers: `base_path('routes/web.php')` and friends name a file // under a conventional directory of the project root. - laravel::resolve_path_helper_definition(self, content, position) + if let Some(loc) = laravel::resolve_path_helper_definition(self, content, position) { + return vec![loc]; + } + + self.resolve_framework_resource_definition(uri, content, position) .into_iter() .collect() } + fn resolve_framework_resource_definition( + &self, + uri: &str, + content: &str, + position: Position, + ) -> Option { + let reference = self.framework_reference_at_position(uri, content, position)?; + match reference.kind { + FrameworkReferenceKind::Class { fqn } => { + self.resolve_class_reference(uri, content, &fqn, true, reference.start) + } + FrameworkReferenceKind::Method { + class_fqn, + member_name, + } => self.resolve_framework_member_definition(uri, content, &class_fqn, &member_name), + FrameworkReferenceKind::Namespace { .. } | FrameworkReferenceKind::Path { .. } => None, + } + } + + fn resolve_framework_member_definition( + &self, + uri: &str, + content: &str, + class_fqn: &str, + member_name: &str, + ) -> Option { + let ctx = self.file_context(uri); + let class_loader = self.class_loader(&ctx); + let raw_class = class_loader(class_fqn)?; + let resolved = crate::virtual_members::resolve_class_fully_maybe_cached( + &raw_class, + &class_loader, + Some(&self.resolved_class_cache), + ); + let (declaring_class, declaring_fqn) = + Self::find_declaring_class(&resolved, member_name, &class_loader) + .unwrap_or_else(|| (resolved.as_ref().clone(), class_fqn.to_string())); + let (class_uri, class_content) = + self.find_class_file_content(&declaring_fqn, uri, content)?; + let position = Self::find_member_position( + &class_content, + member_name, + MemberKind::Method, + declaring_class.member_name_offset(member_name, "method"), + )?; + Some(point_location(Url::parse(&class_uri).ok()?, position)) + } + /// Look up the symbol at the given byte offset in the precomputed /// symbol map for `uri`. /// diff --git a/src/framework.rs b/src/framework.rs new file mode 100644 index 000000000..9fd050f54 --- /dev/null +++ b/src/framework.rs @@ -0,0 +1,1168 @@ +//! Symfony and Doctrine resource-file reference indexing. +//! +//! PHPantom's normal [`SymbolMap`](crate::symbol_map::SymbolMap) is built from +//! PHP ASTs, so YAML/XML framework resources need a parallel lightweight index +//! if they are going to participate in go-to-definition, find-references, +//! rename, and namespace/folder refactors. + +use std::collections::{HashMap, HashSet}; +use std::path::{Component, Path, PathBuf}; +use std::sync::Arc; + +use parking_lot::RwLock; +use tower_lsp::lsp_types::{ + DocumentHighlight, DocumentHighlightKind, Location, Position, Range, TextEdit, Url, +}; + +use crate::Backend; +use crate::references::push_unique_location; +use crate::text_position::{offset_to_position, position_to_offset}; +use crate::util::strip_fqn_prefix; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum FrameworkReferenceKind { + /// A fully-qualified class/interface/trait/enum reference. + Class { fqn: String }, + /// A member reference encoded in a framework string, e.g. + /// `App\Controller\HomeController::index`. + Method { + class_fqn: String, + member_name: String, + }, + /// A namespace-prefix key, e.g. `App\:` in `services.yaml`. + Namespace { prefix: String }, + /// A path-like scalar used by Symfony resource/exclude imports. + Path { value: String }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct FrameworkReference { + pub(crate) uri: String, + pub(crate) start: u32, + pub(crate) end: u32, + pub(crate) kind: FrameworkReferenceKind, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct DoctrineRepositoryMapping { + pub(crate) uri: String, + pub(crate) entity_fqn: String, + pub(crate) entity_start: u32, + pub(crate) entity_end: u32, + pub(crate) repository_fqn: String, + pub(crate) repository_start: u32, + pub(crate) repository_end: u32, +} + +pub(crate) type FrameworkReferenceIndex = + Arc>>>>; + +pub(crate) fn new_framework_reference_index() -> FrameworkReferenceIndex { + Arc::new(RwLock::new(HashMap::new())) +} + +pub(crate) fn is_framework_resource_uri(uri: &str) -> bool { + let path = uri + .strip_prefix("file://") + .unwrap_or(uri) + .split('?') + .next() + .unwrap_or(uri); + let path_lower = path.to_ascii_lowercase(); + path_lower.ends_with(".yaml") || path_lower.ends_with(".yml") || path_lower.ends_with(".xml") +} + +fn is_framework_resource_path(path: &Path) -> bool { + matches!( + path.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()), + Some(ext) if matches!(ext.as_str(), "yaml" | "yml" | "xml") + ) +} + +fn is_skipped_resource_path(path: &Path) -> bool { + path.components().any(|component| match component { + Component::Normal(name) => { + let name = name.to_string_lossy(); + matches!( + name.as_ref(), + "vendor" | "node_modules" | ".git" | "var" | "cache" + ) + } + _ => false, + }) +} + +impl Backend { + /// Scan all YAML/XML framework resources under the workspace root. + pub(crate) fn index_framework_workspace(&self) -> usize { + let Some(root) = self.workspace.workspace_root.read().clone() else { + return 0; + }; + + let mut indexed = HashMap::new(); + for entry in ignore::WalkBuilder::new(&root) + .hidden(false) + .build() + .filter_map(Result::ok) + { + let path = entry.path(); + if !entry.file_type().is_some_and(|ft| ft.is_file()) { + continue; + } + if !is_framework_resource_path(path) || is_skipped_resource_path(path) { + continue; + } + let Ok(content) = std::fs::read_to_string(path) else { + continue; + }; + let uri = crate::util::path_to_uri(path); + let refs = scan_framework_references(&uri, &content); + if !refs.is_empty() { + indexed.insert(uri, Arc::new(refs)); + } + } + + let count = indexed.len(); + *self.framework_references.write() = indexed; + count + } + + pub(crate) fn index_framework_uri_content(&self, uri: &str, content: &str) { + if !is_framework_resource_uri(uri) { + return; + } + let refs = scan_framework_references(uri, content); + let mut index = self.framework_references.write(); + if refs.is_empty() { + index.remove(uri); + } else { + index.insert(uri.to_string(), Arc::new(refs)); + } + } + + pub(crate) fn reindex_framework_uri_from_disk(&self, uri: &str) { + if !is_framework_resource_uri(uri) { + return; + } + let content = self.get_file_content(uri).or_else(|| { + Url::parse(uri) + .ok() + .and_then(|u| u.to_file_path().ok()) + .and_then(|p| std::fs::read_to_string(p).ok()) + }); + match content { + Some(content) => self.index_framework_uri_content(uri, &content), + None => { + self.framework_references.write().remove(uri); + } + } + } + + pub(crate) fn remove_framework_uri(&self, uri: &str) { + self.framework_references.write().remove(uri); + } + + pub(crate) fn apply_framework_file_change( + &self, + uri: &str, + path: &Path, + change_type: tower_lsp::lsp_types::FileChangeType, + ) -> bool { + if !is_framework_resource_path(path) || is_skipped_resource_path(path) { + return false; + } + + match change_type { + tower_lsp::lsp_types::FileChangeType::DELETED => { + self.remove_framework_uri(uri); + true + } + tower_lsp::lsp_types::FileChangeType::CREATED + | tower_lsp::lsp_types::FileChangeType::CHANGED => { + let Ok(content) = std::fs::read_to_string(path) else { + self.remove_framework_uri(uri); + return true; + }; + self.index_framework_uri_content(uri, &content); + true + } + _ => false, + } + } + + pub(crate) fn framework_reference_at_position( + &self, + uri: &str, + content: &str, + position: Position, + ) -> Option { + if !is_framework_resource_uri(uri) { + return None; + } + + let offset = position_to_offset(content, position); + let refs = self + .framework_references + .read() + .get(uri) + .cloned() + .unwrap_or_else(|| Arc::new(scan_framework_references(uri, content))); + + refs.iter() + .find(|reference| { + offset >= reference.start + && (offset < reference.end + || (offset == reference.end && offset > reference.start)) + }) + .cloned() + .or_else(|| { + offset.checked_sub(1).and_then(|prev| { + refs.iter() + .find(|reference| prev >= reference.start && prev < reference.end) + .cloned() + }) + }) + } + + pub(crate) fn framework_class_reference_locations(&self, target_fqn: &str) -> Vec { + let target = normalize_framework_fqn(target_fqn); + let mut locations = Vec::new(); + + for (uri, refs) in self.framework_references.read().iter() { + let Ok(parsed_uri) = Url::parse(uri) else { + continue; + }; + let Some(content) = self.get_file_content_arc(uri) else { + continue; + }; + for reference in refs.iter() { + let FrameworkReferenceKind::Class { fqn } = &reference.kind else { + continue; + }; + if normalize_framework_fqn(fqn).eq_ignore_ascii_case(&target) { + let start = offset_to_position(&content, reference.start as usize); + let end = offset_to_position(&content, reference.end as usize); + push_unique_location(&mut locations, &parsed_uri, start, end); + } + } + } + + sort_locations(&mut locations); + locations + } + + pub(crate) fn framework_member_reference_locations( + &self, + target_member: &str, + hierarchy: Option<&HashSet>, + ) -> Vec { + let mut locations = Vec::new(); + for (uri, refs) in self.framework_references.read().iter() { + let Ok(parsed_uri) = Url::parse(uri) else { + continue; + }; + let Some(content) = self.get_file_content_arc(uri) else { + continue; + }; + for reference in refs.iter() { + let FrameworkReferenceKind::Method { + class_fqn, + member_name, + } = &reference.kind + else { + continue; + }; + if member_name != target_member { + continue; + } + if let Some(hierarchy) = hierarchy { + let class_fqn = normalize_framework_fqn(class_fqn); + if !hierarchy.iter().any(|h| h.eq_ignore_ascii_case(&class_fqn)) { + continue; + } + } + let start = offset_to_position(&content, reference.start as usize); + let end = offset_to_position(&content, reference.end as usize); + push_unique_location(&mut locations, &parsed_uri, start, end); + } + } + sort_locations(&mut locations); + locations + } + + pub(crate) fn framework_doctrine_repository_fqns_for_entity( + &self, + entity_fqn: &str, + ) -> Vec { + let target = normalize_framework_fqn(entity_fqn); + let mut out = Vec::new(); + for mapping in self.framework_doctrine_repository_mappings() { + if normalize_framework_fqn(&mapping.entity_fqn).eq_ignore_ascii_case(&target) { + push_unique_string(&mut out, normalize_framework_fqn(&mapping.repository_fqn)); + } + } + out + } + + pub(crate) fn framework_doctrine_entity_fqns_for_repository( + &self, + repository_fqn: &str, + ) -> Vec { + let target = normalize_framework_fqn(repository_fqn); + let mut out = Vec::new(); + for mapping in self.framework_doctrine_repository_mappings() { + if normalize_framework_fqn(&mapping.repository_fqn).eq_ignore_ascii_case(&target) { + push_unique_string(&mut out, normalize_framework_fqn(&mapping.entity_fqn)); + } + } + out + } + + pub(crate) fn framework_doctrine_repository_mappings(&self) -> Vec { + let uris: Vec = self.framework_references.read().keys().cloned().collect(); + let mut mappings = Vec::new(); + for uri in uris { + let Some(content) = self.get_file_content_arc(&uri) else { + continue; + }; + mappings.extend(scan_doctrine_repository_mappings(&uri, &content)); + } + mappings.sort_by(|a, b| { + a.uri + .cmp(&b.uri) + .then(a.entity_start.cmp(&b.entity_start)) + .then(a.repository_start.cmp(&b.repository_start)) + }); + mappings.dedup_by(|a, b| { + a.uri == b.uri + && normalize_framework_fqn(&a.entity_fqn) + .eq_ignore_ascii_case(&normalize_framework_fqn(&b.entity_fqn)) + && normalize_framework_fqn(&a.repository_fqn) + .eq_ignore_ascii_case(&normalize_framework_fqn(&b.repository_fqn)) + }); + mappings + } + + pub(crate) fn framework_highlights( + &self, + uri: &str, + content: &str, + position: Position, + ) -> Option> { + let reference = self.framework_reference_at_position(uri, content, position)?; + let refs = self + .framework_references + .read() + .get(uri) + .cloned() + .unwrap_or_else(|| Arc::new(scan_framework_references(uri, content))); + + let mut highlights = Vec::new(); + for candidate in refs.iter() { + let matched = + match (&reference.kind, &candidate.kind) { + ( + FrameworkReferenceKind::Class { fqn: lhs }, + FrameworkReferenceKind::Class { fqn: rhs }, + ) => normalize_framework_fqn(lhs) + .eq_ignore_ascii_case(&normalize_framework_fqn(rhs)), + ( + FrameworkReferenceKind::Method { + class_fqn: lhs_class, + member_name: lhs_name, + }, + FrameworkReferenceKind::Method { + class_fqn: rhs_class, + member_name: rhs_name, + }, + ) => { + lhs_name == rhs_name + && normalize_framework_fqn(lhs_class) + .eq_ignore_ascii_case(&normalize_framework_fqn(rhs_class)) + } + ( + FrameworkReferenceKind::Namespace { prefix: lhs }, + FrameworkReferenceKind::Namespace { prefix: rhs }, + ) => normalize_framework_fqn(lhs) + .eq_ignore_ascii_case(&normalize_framework_fqn(rhs)), + ( + FrameworkReferenceKind::Path { value: lhs }, + FrameworkReferenceKind::Path { value: rhs }, + ) => lhs == rhs, + _ => false, + }; + if matched { + highlights.push(DocumentHighlight { + range: Range { + start: offset_to_position(content, candidate.start as usize), + end: offset_to_position(content, candidate.end as usize), + }, + kind: Some(DocumentHighlightKind::READ), + }); + } + } + + if highlights.is_empty() { + None + } else { + highlights.sort_by(|a, b| { + a.range + .start + .line + .cmp(&b.range.start.line) + .then(a.range.start.character.cmp(&b.range.start.character)) + }); + Some(highlights) + } + } + + pub(crate) fn collect_framework_namespace_edits( + &self, + old_prefix: &str, + new_prefix: &str, + changes: &mut HashMap>, + ) { + let old_prefix = normalize_framework_fqn(old_prefix); + let old_prefix_lower = old_prefix.to_ascii_lowercase(); + + for (uri, refs) in self.framework_references.read().iter() { + let Ok(parsed_uri) = Url::parse(uri) else { + continue; + }; + let Some(content) = self.get_file_content_arc(uri) else { + continue; + }; + for reference in refs.iter() { + let Some(name) = framework_reference_class_or_namespace(&reference.kind) else { + continue; + }; + let normalized = normalize_framework_fqn(name); + let normalized_lower = normalized.to_ascii_lowercase(); + if normalized_lower != old_prefix_lower + && !normalized_lower.starts_with(&format!("{}\\", old_prefix_lower)) + { + continue; + } + + let replacement = if normalized.len() == old_prefix.len() { + new_prefix.to_string() + } else { + format!("{}{}", new_prefix, &normalized[old_prefix.len()..]) + }; + let source = content + .get(reference.start as usize..reference.end as usize) + .unwrap_or(""); + let new_text = rewrite_framework_fqn_literal(source, &replacement); + changes + .entry(parsed_uri.clone()) + .or_default() + .push(TextEdit { + range: Range { + start: offset_to_position(&content, reference.start as usize), + end: offset_to_position(&content, reference.end as usize), + }, + new_text, + }); + } + } + } + + pub(crate) fn collect_framework_path_edits_for_directory_renames( + &self, + directory_renames: &[(Url, Url)], + changes: &mut HashMap>, + ) { + if directory_renames.is_empty() { + return; + } + + let workspace_root = self.workspace.workspace_root.read().clone(); + let renames: Vec<(PathBuf, PathBuf)> = directory_renames + .iter() + .filter_map(|(old_uri, new_uri)| { + let old_path = old_uri.to_file_path().ok()?; + let new_path = new_uri.to_file_path().ok()?; + Some((normalize_path(old_path), normalize_path(new_path))) + }) + .collect(); + + if renames.is_empty() { + return; + } + + for (uri, refs) in self.framework_references.read().iter() { + let Ok(parsed_uri) = Url::parse(uri) else { + continue; + }; + let Ok(file_path) = parsed_uri.to_file_path() else { + continue; + }; + let Some(file_dir) = file_path.parent() else { + continue; + }; + let Some(content) = self.get_file_content_arc(uri) else { + continue; + }; + + for reference in refs.iter() { + let FrameworkReferenceKind::Path { value } = &reference.kind else { + continue; + }; + let Some(rewritten) = rewrite_framework_path_for_directory_renames( + value, + file_dir, + workspace_root.as_deref(), + &renames, + ) else { + continue; + }; + if rewritten == *value { + continue; + } + + changes + .entry(parsed_uri.clone()) + .or_default() + .push(TextEdit { + range: Range { + start: offset_to_position(&content, reference.start as usize), + end: offset_to_position(&content, reference.end as usize), + }, + new_text: rewritten, + }); + } + } + } +} + +fn framework_reference_class_or_namespace(kind: &FrameworkReferenceKind) -> Option<&str> { + match kind { + FrameworkReferenceKind::Class { fqn } => Some(fqn), + FrameworkReferenceKind::Namespace { prefix } => Some(prefix), + FrameworkReferenceKind::Method { .. } | FrameworkReferenceKind::Path { .. } => None, + } +} + +fn scan_framework_references(uri: &str, content: &str) -> Vec { + let mut refs = Vec::new(); + scan_class_like_tokens(uri, content, &mut refs); + scan_path_scalars(uri, content, &mut refs); + refs.sort_by(|a, b| a.start.cmp(&b.start).then(a.end.cmp(&b.end))); + refs.dedup(); + refs +} + +fn scan_doctrine_repository_mappings(uri: &str, content: &str) -> Vec { + let mut mappings = Vec::new(); + scan_doctrine_yaml_repository_mappings(uri, content, &mut mappings); + scan_doctrine_xml_repository_mappings(uri, content, &mut mappings); + mappings +} + +fn scan_doctrine_yaml_repository_mappings( + uri: &str, + content: &str, + mappings: &mut Vec, +) { + let lines = line_offsets(content); + for (idx, (line_start, line)) in lines.iter().enumerate() { + let Some((entity_fqn, entity_start, entity_end, entity_indent)) = + yaml_doctrine_entity_key(line, *line_start) + else { + continue; + }; + + for (child_start, child_line) in lines.iter().skip(idx + 1) { + let trimmed = child_line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + let child_indent = leading_spaces(child_line); + if child_indent <= entity_indent { + break; + } + + if let Some((repository_fqn, repository_start, repository_end)) = + yaml_repository_class_value(child_line, *child_start) + { + mappings.push(DoctrineRepositoryMapping { + uri: uri.to_string(), + entity_fqn: entity_fqn.clone(), + entity_start: entity_start as u32, + entity_end: entity_end as u32, + repository_fqn, + repository_start: repository_start as u32, + repository_end: repository_end as u32, + }); + break; + } + } + } +} + +fn scan_doctrine_xml_repository_mappings( + uri: &str, + content: &str, + mappings: &mut Vec, +) { + let mut search = 0usize; + let lower = content.to_ascii_lowercase(); + while let Some(rel_start) = lower[search..].find("') else { + break; + }; + let tag_end = tag_start + rel_end + 1; + let tag = &content[tag_start..tag_end]; + + let entity = xml_attr_value(tag, tag_start, &["name", "class"]); + let repository = xml_attr_value(tag, tag_start, &["repository-class", "repositoryclass"]); + if let ( + Some((entity_fqn, entity_start, entity_end)), + Some((repo_fqn, repo_start, repo_end)), + ) = (entity, repository) + && valid_framework_name(&normalize_framework_fqn(&entity_fqn)) + && valid_framework_name(&normalize_framework_fqn(&repo_fqn)) + { + mappings.push(DoctrineRepositoryMapping { + uri: uri.to_string(), + entity_fqn: normalize_framework_fqn(&entity_fqn), + entity_start: entity_start as u32, + entity_end: entity_end as u32, + repository_fqn: normalize_framework_fqn(&repo_fqn), + repository_start: repo_start as u32, + repository_end: repo_end as u32, + }); + } + + search = tag_end; + } +} + +fn yaml_doctrine_entity_key( + line: &str, + line_start: usize, +) -> Option<(String, usize, usize, usize)> { + let indent = leading_spaces(line); + let trimmed = line[indent..].trim_end(); + if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with('-') { + return None; + } + + let colon = trimmed.find(':')?; + let raw_key = trimmed[..colon].trim(); + let (key, quote_adjust) = strip_yaml_quotes(raw_key); + let normalized = normalize_framework_fqn(key); + if !normalized.contains('\\') || !valid_framework_name(&normalized) { + return None; + } + + let raw_start = line[indent..].find(raw_key)? + indent; + let start = line_start + raw_start + quote_adjust.0; + let end = line_start + raw_start + raw_key.len().saturating_sub(quote_adjust.1); + Some((normalized, start, end, indent)) +} + +fn yaml_repository_class_value(line: &str, line_start: usize) -> Option<(String, usize, usize)> { + let colon = line.find(':')?; + let raw_key = line[..colon].trim(); + let (key, _) = strip_yaml_quotes(raw_key); + if !matches!( + key, + "repositoryClass" | "repository-class" | "repository_class" + ) { + return None; + } + + let raw = line[colon + 1..].trim_start(); + let value_offset = line[colon + 1..].len() - raw.len(); + let (value, start, end) = scalar_value(raw, line_start + colon + 1 + value_offset)?; + let normalized = normalize_framework_fqn(value); + if normalized.contains('\\') && valid_framework_name(&normalized) { + Some((normalized, start, end)) + } else { + None + } +} + +fn xml_attr_value(tag: &str, tag_start: usize, names: &[&str]) -> Option<(String, usize, usize)> { + let bytes = tag.as_bytes(); + let mut i = 0usize; + while i < bytes.len() { + while i < bytes.len() && bytes[i].is_ascii_whitespace() { + i += 1; + } + let name_start = i; + while i < bytes.len() + && (bytes[i] == b'-' || bytes[i] == b'_' || bytes[i].is_ascii_alphanumeric()) + { + i += 1; + } + if i == name_start { + i += 1; + continue; + } + let attr_name = tag[name_start..i].to_ascii_lowercase(); + while i < bytes.len() && bytes[i].is_ascii_whitespace() { + i += 1; + } + if bytes.get(i) != Some(&b'=') { + continue; + } + i += 1; + while i < bytes.len() && bytes[i].is_ascii_whitespace() { + i += 1; + } + let quote = *bytes.get(i)?; + if quote != b'\'' && quote != b'"' { + continue; + } + let value_start = i + 1; + i = value_start; + while i < bytes.len() && bytes[i] != quote { + i += 1; + } + if i >= bytes.len() { + return None; + } + if names + .iter() + .any(|name| attr_name == name.to_ascii_lowercase()) + { + let value = tag[value_start..i].to_string(); + return Some((value, tag_start + value_start, tag_start + i)); + } + i += 1; + } + None +} + +fn strip_yaml_quotes(raw: &str) -> (&str, (usize, usize)) { + let bytes = raw.as_bytes(); + if bytes.len() >= 2 + && ((bytes[0] == b'\'' && bytes[bytes.len() - 1] == b'\'') + || (bytes[0] == b'"' && bytes[bytes.len() - 1] == b'"')) + { + (&raw[1..raw.len() - 1], (1, 1)) + } else { + (raw, (0, 0)) + } +} + +fn leading_spaces(line: &str) -> usize { + line.bytes().take_while(|b| *b == b' ').count() +} + +fn push_unique_string(out: &mut Vec, value: String) { + if !out.iter().any(|known| known.eq_ignore_ascii_case(&value)) { + out.push(value); + } +} + +fn scan_class_like_tokens(uri: &str, content: &str, refs: &mut Vec) { + let bytes = content.as_bytes(); + let mut i = 0usize; + while i < bytes.len() { + if !is_token_start(bytes[i]) || (i > 0 && is_token_char(bytes[i - 1])) { + i += 1; + continue; + } + + let start = i; + let mut end = i + 1; + while end < bytes.len() && is_token_char(bytes[end]) { + end += 1; + } + + let token = &content[start..end]; + let normalized = normalize_framework_fqn(token); + let token_has_namespace_separator = token.contains('\\'); + if token_has_namespace_separator && valid_framework_name(&normalized) { + if token.ends_with('\\') || token.ends_with("\\\\") { + let prefix = normalized.trim_end_matches('\\').to_string(); + if !prefix.is_empty() && valid_framework_name(&prefix) { + refs.push(FrameworkReference { + uri: uri.to_string(), + start: start as u32, + end: end as u32, + kind: FrameworkReferenceKind::Namespace { prefix }, + }); + } + } else { + refs.push(FrameworkReference { + uri: uri.to_string(), + start: start as u32, + end: end as u32, + kind: FrameworkReferenceKind::Class { + fqn: normalized.clone(), + }, + }); + + if bytes.get(end) == Some(&b':') && bytes.get(end + 1) == Some(&b':') { + let method_start = end + 2; + let method_end = scan_identifier(bytes, method_start); + if method_end > method_start { + refs.push(FrameworkReference { + uri: uri.to_string(), + start: method_start as u32, + end: method_end as u32, + kind: FrameworkReferenceKind::Method { + class_fqn: normalized, + member_name: content[method_start..method_end].to_string(), + }, + }); + } + } + } + } + + i = end; + } +} + +fn scan_path_scalars(uri: &str, content: &str, refs: &mut Vec) { + for (line_start, line) in line_offsets(content) { + let Some(colon) = line.find(':') else { + continue; + }; + let key = line[..colon].trim(); + if !matches!( + key, + "resource" | "exclude" | "path" | "paths" | "dir" | "directory" + ) { + continue; + } + let raw = line[colon + 1..].trim_start(); + let value_offset = line[colon + 1..].len() - raw.len(); + if let Some((value, start, end)) = scalar_value(raw, line_start + colon + 1 + value_offset) + && looks_like_path_value(value) + { + refs.push(FrameworkReference { + uri: uri.to_string(), + start: start as u32, + end: end as u32, + kind: FrameworkReferenceKind::Path { + value: value.to_string(), + }, + }); + } + } + + for attr in ["resource", "exclude", "path", "dir", "directory"] { + let mut search = 0usize; + let pattern = format!("{attr}="); + while let Some(pos) = content[search..].find(&pattern) { + let attr_start = search + pos + pattern.len(); + if let Some((value, start, end)) = quoted_value_at(content, attr_start) + && looks_like_path_value(value) + { + refs.push(FrameworkReference { + uri: uri.to_string(), + start: start as u32, + end: end as u32, + kind: FrameworkReferenceKind::Path { + value: value.to_string(), + }, + }); + } + search = attr_start.saturating_add(1); + } + } +} + +fn line_offsets(content: &str) -> Vec<(usize, &str)> { + let mut out = Vec::new(); + let mut offset = 0usize; + for line in content.lines() { + out.push((offset, line)); + offset += line.len() + 1; + } + out +} + +fn scalar_value(raw: &str, absolute_start: usize) -> Option<(&str, usize, usize)> { + if raw.is_empty() || raw.starts_with('#') { + return None; + } + let bytes = raw.as_bytes(); + if matches!(bytes.first(), Some(b'"' | b'\'')) { + let quote = bytes[0]; + let mut i = 1usize; + while i < bytes.len() { + if bytes[i] == quote { + return Some((&raw[1..i], absolute_start + 1, absolute_start + i)); + } + i += 1; + } + return None; + } + let end = raw.find('#').unwrap_or(raw.len()); + let value = raw[..end].trim_end(); + if value.is_empty() { + None + } else { + Some((value, absolute_start, absolute_start + value.len())) + } +} + +fn quoted_value_at(content: &str, offset: usize) -> Option<(&str, usize, usize)> { + let bytes = content.as_bytes(); + let quote = *bytes.get(offset)?; + if quote != b'\'' && quote != b'"' { + return None; + } + let mut i = offset + 1; + while i < bytes.len() { + if bytes[i] == quote { + return Some((&content[offset + 1..i], offset + 1, i)); + } + i += 1; + } + None +} + +fn looks_like_path_value(value: &str) -> bool { + value.contains('/') + && !value.contains("://") + && (value.starts_with('.') + || value.starts_with('/') + || value.contains("src/") + || value.contains("%kernel.project_dir%")) +} + +fn is_token_start(byte: u8) -> bool { + byte == b'\\' || byte == b'_' || byte.is_ascii_alphabetic() +} + +fn is_token_char(byte: u8) -> bool { + byte == b'\\' || byte == b'_' || byte.is_ascii_alphanumeric() +} + +fn scan_identifier(bytes: &[u8], start: usize) -> usize { + if !bytes + .get(start) + .is_some_and(|b| *b == b'_' || b.is_ascii_alphabetic()) + { + return start; + } + let mut end = start + 1; + while end < bytes.len() && (bytes[end] == b'_' || bytes[end].is_ascii_alphanumeric()) { + end += 1; + } + end +} + +pub(crate) fn normalize_framework_fqn(name: &str) -> String { + let mut out = String::new(); + let mut prev_backslash = false; + for ch in strip_fqn_prefix(name.trim()).chars() { + if ch == '\\' { + if !prev_backslash { + out.push('\\'); + } + prev_backslash = true; + } else { + out.push(ch); + prev_backslash = false; + } + } + out.trim_end_matches('\\').to_string() +} + +fn valid_framework_name(name: &str) -> bool { + let name = name.trim_matches('\\'); + if name.is_empty() { + return false; + } + name.split('\\').all(valid_framework_segment) +} + +fn valid_framework_segment(segment: &str) -> bool { + let mut chars = segment.chars(); + let Some(first) = chars.next() else { + return false; + }; + (first == '_' || first.is_ascii_alphabetic()) + && chars.all(|c| c == '_' || c.is_ascii_alphanumeric()) +} + +pub(crate) fn short_segment_range(source: &str, absolute_start: u32) -> (u32, u32) { + let trimmed = source.trim_end_matches('\\'); + let short_start = trimmed.rfind('\\').map(|idx| idx + 1).unwrap_or(0); + let start = absolute_start + short_start as u32; + let end = absolute_start + trimmed.len() as u32; + (start, end) +} + +pub(crate) fn namespace_segment_range_at_offset( + source: &str, + absolute_start: u32, + cursor: u32, +) -> Option<(usize, u32, u32)> { + let normalized_source = source.trim_end_matches('\\'); + let mut offset = absolute_start; + for (idx, segment) in normalized_source.split('\\').enumerate() { + if segment.is_empty() { + offset += 1; + continue; + } + let end = offset + segment.len() as u32; + if cursor >= offset && cursor <= end { + return Some((idx, offset, end)); + } + offset = end + 1; + } + None +} + +fn rewrite_framework_fqn_literal(source: &str, replacement: &str) -> String { + let mut out = replacement.to_string(); + if source.starts_with('\\') && !out.starts_with('\\') { + out.insert(0, '\\'); + } + if source.contains("\\\\") { + out = out.replace('\\', "\\\\"); + } + if source.ends_with('\\') || source.ends_with("\\\\") { + out.push('\\'); + if source.ends_with("\\\\") { + out.push('\\'); + } + } + out +} + +fn rewrite_framework_path_for_directory_renames( + value: &str, + file_dir: &Path, + workspace_root: Option<&Path>, + renames: &[(PathBuf, PathBuf)], +) -> Option { + let resolved = resolve_framework_path_value(value, file_dir, workspace_root)?; + for (old_dir, new_dir) in renames { + if !resolved.starts_with(old_dir) { + continue; + } + + let suffix = resolved.strip_prefix(old_dir).ok()?; + let target = normalize_path(new_dir.join(suffix)); + return format_rewritten_framework_path(value, file_dir, workspace_root, &target); + } + None +} + +fn resolve_framework_path_value( + value: &str, + file_dir: &Path, + workspace_root: Option<&Path>, +) -> Option { + let value = value.trim(); + if value.is_empty() { + return None; + } + + if let Some(root) = workspace_root + && let Some(rest) = value.strip_prefix("%kernel.project_dir%") + { + let rest = rest.trim_start_matches(['/', '\\']); + return Some(normalize_path(root.join(rest))); + } + + let path = PathBuf::from(value); + if path.is_absolute() { + Some(normalize_path(path)) + } else { + Some(normalize_path(file_dir.join(path))) + } +} + +fn format_rewritten_framework_path( + original: &str, + file_dir: &Path, + workspace_root: Option<&Path>, + target: &Path, +) -> Option { + let mut rewritten = if original.trim().starts_with("%kernel.project_dir%") { + let root = workspace_root?; + let relative = target.strip_prefix(root).ok()?; + let relative = path_to_slash(relative); + if relative.is_empty() { + "%kernel.project_dir%".to_string() + } else { + format!("%kernel.project_dir%/{relative}") + } + } else if Path::new(original.trim()).is_absolute() { + path_to_slash(target) + } else { + let relative = relative_path(file_dir, target)?; + path_to_slash(&relative) + }; + + if (original.ends_with('/') || original.ends_with('\\')) && !rewritten.ends_with('/') { + rewritten.push('/'); + } + Some(rewritten) +} + +fn relative_path(from_dir: &Path, target: &Path) -> Option { + let from_dir = normalize_path(from_dir.to_path_buf()); + let target = normalize_path(target.to_path_buf()); + let from_components: Vec> = from_dir.components().collect(); + let target_components: Vec> = target.components().collect(); + + let mut common_len = 0usize; + while common_len < from_components.len() + && common_len < target_components.len() + && from_components[common_len] == target_components[common_len] + { + common_len += 1; + } + + if common_len == 0 && (from_dir.is_absolute() || target.is_absolute()) { + return None; + } + + let mut relative = PathBuf::new(); + for component in &from_components[common_len..] { + if matches!(component, Component::Normal(_)) { + relative.push(".."); + } + } + for component in &target_components[common_len..] { + relative.push(component.as_os_str()); + } + if relative.as_os_str().is_empty() { + relative.push("."); + } + Some(relative) +} + +fn path_to_slash(path: &Path) -> String { + path.to_string_lossy() + .replace(std::path::MAIN_SEPARATOR, "/") +} + +fn sort_locations(locations: &mut Vec) { + locations.sort_by(|a, b| { + a.uri + .as_str() + .cmp(b.uri.as_str()) + .then(a.range.start.line.cmp(&b.range.start.line)) + .then(a.range.start.character.cmp(&b.range.start.character)) + }); + locations.dedup(); +} + +fn normalize_path(path: PathBuf) -> PathBuf { + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + normalized.pop(); + } + other => normalized.push(other.as_os_str()), + } + } + normalized +} diff --git a/src/highlight/mod.rs b/src/highlight/mod.rs index 82cc6ad84..4109201c7 100644 --- a/src/highlight/mod.rs +++ b/src/highlight/mod.rs @@ -36,7 +36,9 @@ impl Backend { ) -> Option> { // Look up the symbol span at the cursor (retries one byte // earlier for end-of-token edge cases). - let span = self.lookup_symbol_at_position(uri, content, position)?; + let Some(span) = self.lookup_symbol_at_position(uri, content, position) else { + return self.framework_highlights(uri, content, position); + }; let maps = self.symbol_maps.read(); let symbol_map = maps.get(uri)?; diff --git a/src/indexing/watch.rs b/src/indexing/watch.rs index 4073e6aeb..ff8975bfe 100644 --- a/src/indexing/watch.rs +++ b/src/indexing/watch.rs @@ -57,6 +57,7 @@ impl Backend { crate::virtual_members::laravel::database_schema::MigrationDiscovery::default(); let is_laravel = self.resolved_class_cache.read().is_laravel(); let config_path = root.join(crate::config::CONFIG_FILE_NAME); + let mut framework_changes: Vec<(String, PathBuf, FileChangeType)> = Vec::new(); { let open = self.open_files.read(); let parsed = self.parsed_uris.read(); @@ -121,10 +122,23 @@ impl Backend { continue; } } + if crate::framework::is_framework_resource_uri(&uri_str) { + framework_changes.push((uri_str.clone(), file_path.clone(), change.typ)); + } resource_changes.push((uri_str, file_path, change.typ)); continue; } if !path_str.ends_with(".php") { + if crate::framework::is_framework_resource_uri(change.uri.as_ref()) { + let uri_str = change.uri.to_string(); + if open.contains_key(&uri_str) { + continue; + } + let Ok(file_path) = change.uri.to_file_path() else { + continue; + }; + framework_changes.push((uri_str, file_path, change.typ)); + } continue; } @@ -158,6 +172,7 @@ impl Backend { && !config_changed && !schema_full_rebuild && migration_changes.is_empty() + && framework_changes.is_empty() { return false; } @@ -220,6 +235,16 @@ impl Backend { self.update_laravel_migrations(&migration_changes); } + if !framework_changes.is_empty() { + tracing::info!( + "PHPantom: {} Symfony/Doctrine resource file(s) changed on disk", + framework_changes.len() + ); + for (uri, path, typ) in &framework_changes { + self.apply_framework_file_change(uri, path, *typ); + } + } + true } diff --git a/src/lib.rs b/src/lib.rs index e5c88316e..dd1baaf95 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -239,6 +239,7 @@ mod document_symbols; pub mod fix; mod folding; mod formatting; +mod framework; mod highlight; mod hover; mod indexing; @@ -561,6 +562,13 @@ pub struct Backend { /// variables, function calls, etc.). Consulted by `resolve_definition` /// to replace character-level backward-walking with a binary search. pub(crate) symbol_maps: Arc>>>, + /// Per-file Symfony/Doctrine YAML/XML references. + /// + /// PHP files are represented by [`symbol_maps`]. Framework resource files + /// are not PHP ASTs, so class names, namespace-prefix service keys, + /// controller method strings, and path-like resource imports are indexed + /// here and queried by definition, references, rename, and highlights. + pub(crate) framework_references: framework::FrameworkReferenceIndex, /// Cross-file candidate index for find-references. /// /// Maintained from each file's [`symbol_maps`] entry during parsing. @@ -1082,6 +1090,7 @@ impl Backend { client_name: Mutex::new(String::new()), open_files: Arc::new(RwLock::new(HashMap::new())), symbol_maps: Arc::new(RwLock::new(HashMap::new())), + framework_references: framework::new_framework_reference_index(), reference_index: reference_index::new_reference_index(), skip_reference_index: false, symbols: SymbolIndex::new(), @@ -1193,6 +1202,7 @@ impl Backend { client_name: Mutex::new(String::new()), open_files: Arc::new(RwLock::new(HashMap::new())), symbol_maps: Arc::new(RwLock::new(HashMap::new())), + framework_references: framework::new_framework_reference_index(), reference_index: reference_index::new_reference_index(), skip_reference_index: false, symbols: SymbolIndex::new(), @@ -1847,6 +1857,7 @@ impl Backend { client_name: Mutex::new(self.client_name.lock().clone()), open_files: Arc::clone(&self.open_files), symbol_maps: Arc::clone(&self.symbol_maps), + framework_references: Arc::clone(&self.framework_references), reference_index: Arc::clone(&self.reference_index), skip_reference_index: self.skip_reference_index, symbols: self.symbols.clone(), diff --git a/src/references/classes.rs b/src/references/classes.rs index 8a21341cd..0b4217791 100644 --- a/src/references/classes.rs +++ b/src/references/classes.rs @@ -155,15 +155,10 @@ impl Backend { } } - locations.sort_by(|a, b| { - a.uri - .as_str() - .cmp(b.uri.as_str()) - .then(a.range.start.line.cmp(&b.range.start.line)) - .then(a.range.start.character.cmp(&b.range.start.character)) - }); - - locations.dedup(); + for loc in self.framework_class_reference_locations(target) { + push_unique_location(&mut locations, &loc.uri, loc.range.start, loc.range.end); + } + sort_locations_for_references(&mut locations); locations } diff --git a/src/references/dispatch.rs b/src/references/dispatch.rs index b3252b1a1..f1f5614b1 100644 --- a/src/references/dispatch.rs +++ b/src/references/dispatch.rs @@ -140,6 +140,18 @@ impl Backend { return Some(locations); } + if let Some(locations) = + self.find_framework_references_at(uri, content, position, include_declaration, mode) + && !locations.is_empty() + { + tracing::info!("Find References: found Symfony/Doctrine resource references"); + tracing::info!( + "Find References: total time (framework path): {:?}", + start_total.elapsed() + ); + return Some(locations); + } + tracing::info!( "Find References: no references found in {:?}", start_total.elapsed() @@ -147,6 +159,62 @@ impl Backend { None } + pub(crate) fn find_framework_references_for_rename( + &self, + uri: &str, + content: &str, + position: Position, + include_declaration: bool, + ) -> Option> { + self.find_framework_references_at( + uri, + content, + position, + include_declaration, + ReferenceSearchMode::Rename, + ) + } + + fn find_framework_references_at( + &self, + uri: &str, + content: &str, + position: Position, + include_declaration: bool, + mode: ReferenceSearchMode, + ) -> Option> { + let reference = self.framework_reference_at_position(uri, content, position)?; + let locations = match reference.kind { + FrameworkReferenceKind::Class { fqn } => { + self.find_class_references(&fqn, include_declaration) + } + FrameworkReferenceKind::Method { + class_fqn, + member_name, + } => { + let hierarchy = self + .collect_member_receiver_scope( + std::slice::from_ref(&class_fqn), + &member_name, + false, + mode.include_declaring_interfaces(), + ) + .unwrap_or_else(|| self.collect_hierarchy_for_fqns(&[class_fqn])); + self.find_member_references( + &member_name, + false, + include_declaration, + Some(&hierarchy), + Some(&hierarchy), + ) + } + FrameworkReferenceKind::Namespace { .. } | FrameworkReferenceKind::Path { .. } => { + Vec::new() + } + }; + Some(locations) + } + /// Dispatch a symbol-map hit to the appropriate reference finder. fn dispatch_symbol_references( &self, diff --git a/src/references/members.rs b/src/references/members.rs index 94e4bf6bc..787ec9913 100644 --- a/src/references/members.rs +++ b/src/references/members.rs @@ -714,13 +714,10 @@ impl Backend { } } - locations.sort_by(|a, b| { - a.uri - .as_str() - .cmp(b.uri.as_str()) - .then(a.range.start.line.cmp(&b.range.start.line)) - .then(a.range.start.character.cmp(&b.range.start.character)) - }); + for loc in self.framework_member_reference_locations(target_member, hierarchy) { + push_unique_location(&mut locations, &loc.uri, loc.range.start, loc.range.end); + } + sort_locations_for_references(&mut locations); locations } @@ -867,6 +864,17 @@ impl Backend { function_loader: &function_loader, }; + let doctrine_repository_fqns = self.resolve_doctrine_repository_subject_to_fqns( + subject_text, + ctx, + access_offset, + content, + &class_loader, + ); + if !doctrine_repository_fqns.is_empty() { + return doctrine_repository_fqns; + } + match crate::type_engine::subject_resolution::resolve_subject_type( subject_text, is_static, @@ -900,6 +908,130 @@ impl Backend { } } + fn resolve_doctrine_repository_subject_to_fqns( + &self, + subject_text: &str, + ctx: &crate::types::FileContext, + access_offset: u32, + content: &str, + class_loader: &dyn Fn(&str) -> Option>, + ) -> Vec { + let expr = crate::type_engine::subject_expr::SubjectExpr::parse(subject_text); + let mut candidates = self.doctrine_repository_fqns_from_expr( + &expr, + &ctx.use_map, + &ctx.namespace, + &ctx.classes, + access_offset, + class_loader, + ); + + if candidates.is_empty() + && let crate::type_engine::subject_expr::SubjectExpr::Variable(var_name) = &expr + && let Some(assigned_expr) = + last_assignment_expression_before(content, access_offset, var_name) + { + let assigned = crate::type_engine::subject_expr::SubjectExpr::parse(assigned_expr); + candidates = self.doctrine_repository_fqns_from_expr( + &assigned, + &ctx.use_map, + &ctx.namespace, + &ctx.classes, + access_offset, + class_loader, + ); + } + + candidates + } + + fn doctrine_repository_fqns_from_expr( + &self, + expr: &crate::type_engine::subject_expr::SubjectExpr, + use_map: &HashMap, + namespace: &Option, + local_classes: &[Arc], + access_offset: u32, + class_loader: &dyn Fn(&str) -> Option>, + ) -> Vec { + let crate::type_engine::subject_expr::SubjectExpr::CallExpr { callee, args_text } = expr + else { + return Vec::new(); + }; + let crate::type_engine::subject_expr::SubjectExpr::MethodCall { method, .. } = + callee.as_ref() + else { + return Vec::new(); + }; + if !method.eq_ignore_ascii_case("getRepository") { + return Vec::new(); + } + + let Some(entity_fqn) = doctrine_repository_entity_arg( + args_text, + use_map, + namespace, + local_classes, + access_offset, + ) else { + return Vec::new(); + }; + + self.doctrine_repository_fqns_for_entity(&entity_fqn, class_loader) + } + + pub(crate) fn doctrine_repository_fqns_for_entity( + &self, + entity_fqn: &str, + class_loader: &dyn Fn(&str) -> Option>, + ) -> Vec { + let entity = normalize_fqn(entity_fqn); + let entity_short = crate::util::short_name(&entity); + let repository_short = doctrine_repository_short_name(entity_short); + let mut candidate_fqns = self.framework_doctrine_repository_fqns_for_entity(&entity); + candidate_fqns.extend(doctrine_repository_convention_candidates( + &entity, + &repository_short, + )); + + { + let class_index = self.symbols.fqn_class_index.read(); + for (class_fqn, class_info) in class_index.iter() { + if crate::util::short_name(class_fqn).eq_ignore_ascii_case(&repository_short) + && looks_like_doctrine_repository(class_info) + { + candidate_fqns.push(normalize_fqn(class_fqn)); + } + } + } + + for fallback in [ + "Doctrine\\Bundle\\DoctrineBundle\\Repository\\ServiceEntityRepository", + "Doctrine\\ORM\\EntityRepository", + "Doctrine\\Persistence\\ObjectRepository", + "ServiceEntityRepository", + "EntityRepository", + "ObjectRepository", + ] { + candidate_fqns.push(fallback.to_string()); + } + + let mut resolved = Vec::new(); + for candidate in candidate_fqns { + let normalized = normalize_fqn(&candidate); + if resolved + .iter() + .any(|known: &String| known.eq_ignore_ascii_case(&normalized)) + { + continue; + } + if let Some(class_info) = class_loader(&normalized) { + resolved.push(normalize_fqn(&class_info.fqn())); + } + } + resolved + } + fn resolve_static_laravel_builder_subject_to_fqns( &self, subject_text: &str, @@ -945,7 +1077,7 @@ impl Backend { /// - All ancestor FQNs (parent chain, interfaces, traits) /// - All descendant FQNs (classes that extend/implement any class in /// the hierarchy) - fn collect_hierarchy_for_fqns(&self, seed_fqns: &[String]) -> HashSet { + pub(super) fn collect_hierarchy_for_fqns(&self, seed_fqns: &[String]) -> HashSet { let mut hierarchy = HashSet::new(); let class_loader = |name: &str| -> Option> { self.find_or_load_class(name) }; @@ -1046,7 +1178,7 @@ impl Backend { hierarchy } - fn collect_member_receiver_scope( + pub(super) fn collect_member_receiver_scope( &self, seed_fqns: &[String], member_name: &str, @@ -1379,3 +1511,99 @@ impl Backend { } } } + +fn doctrine_repository_entity_arg( + args_text: &str, + use_map: &HashMap, + namespace: &Option, + local_classes: &[Arc], + access_offset: u32, +) -> Option { + let first_arg = crate::type_engine::conditional_resolution::split_text_args(args_text) + .into_iter() + .next()? + .trim(); + let class_expr = first_arg.strip_suffix("::class")?.trim(); + let class_expr = class_expr.trim_start_matches('\\'); + if class_expr.is_empty() { + return None; + } + + match class_expr { + "self" | "static" => { + let current = find_class_at_offset(local_classes, access_offset)?; + Some(current.fqn().to_string()) + } + "parent" => { + let current = find_class_at_offset(local_classes, access_offset)?; + current.parent_class.map(|parent| parent.to_string()) + } + _ => Some(Backend::resolve_to_fqn(class_expr, use_map, namespace)), + } +} + +fn doctrine_repository_short_name(entity_short: &str) -> String { + let stem = entity_short + .strip_suffix("Entity") + .or_else(|| entity_short.strip_suffix("Impl")) + .unwrap_or(entity_short); + format!("{stem}Repository") +} + +fn doctrine_repository_convention_candidates( + entity_fqn: &str, + repository_short: &str, +) -> Vec { + let mut candidates = Vec::new(); + if let Some((entity_ns, _)) = entity_fqn.rsplit_once('\\') { + candidates.push(format!("{entity_ns}\\{repository_short}")); + + for marker in ["\\Entity\\", "\\Entities\\", "\\Model\\", "\\Models\\"] { + if let Some((root, _tail)) = entity_fqn.rsplit_once(marker) { + candidates.push(format!("{root}\\Repository\\{repository_short}")); + candidates.push(format!("{root}\\Repositories\\{repository_short}")); + } + } + + for suffix in ["\\Entity", "\\Entities", "\\Model", "\\Models"] { + if let Some(root) = entity_ns.strip_suffix(suffix) { + candidates.push(format!("{root}\\Repository\\{repository_short}")); + candidates.push(format!("{root}\\Repositories\\{repository_short}")); + } + } + } else { + candidates.push(repository_short.to_string()); + } + + candidates +} + +fn looks_like_doctrine_repository(class_info: &ClassInfo) -> bool { + if class_info.name.to_string().ends_with("Repository") { + return true; + } + class_info.parent_class.as_ref().is_some_and(|parent| { + let short = crate::util::short_name(parent); + matches!( + short, + "ServiceEntityRepository" | "EntityRepository" | "ObjectRepository" + ) + }) +} + +fn last_assignment_expression_before<'a>( + content: &'a str, + access_offset: u32, + var_name: &str, +) -> Option<&'a str> { + let prefix = content.get(..access_offset as usize)?; + let pattern = format!("{var_name} ="); + let assign_start = prefix.rfind(&pattern)?; + let after_equals = prefix[assign_start + pattern.len()..].trim_start(); + let end = after_equals + .find(';') + .or_else(|| after_equals.find('\n')) + .unwrap_or(after_equals.len()); + let expr = after_equals[..end].trim(); + if expr.is_empty() { None } else { Some(expr) } +} diff --git a/src/references/mod.rs b/src/references/mod.rs index ed922ca48..2b83f667f 100644 --- a/src/references/mod.rs +++ b/src/references/mod.rs @@ -44,6 +44,7 @@ use std::sync::Arc; use tower_lsp::lsp_types::{Location, Position, Range, Url}; use crate::Backend; +use crate::framework::FrameworkReferenceKind; use crate::reference_index::ReferenceIndexKey; use crate::symbol_map::SymbolMap; use crate::util::strip_fqn_prefix; diff --git a/src/references/tests.rs b/src/references/tests.rs index a2e1ed923..89b3cdb54 100644 --- a/src/references/tests.rs +++ b/src/references/tests.rs @@ -1437,10 +1437,11 @@ async fn test_overridden_find_excludes_base_repository_and_unresolved_calls() { " $notifications->find(1);\n", // L11 " $base->find(2);\n", // L12 " $users->find(3);\n", // L13 - " $notificationRepository = $managerRegistry->getManager()->getRepository(NotificationImpl::class);\n", // L14 - " $notificationRepository->find(4);\n", // L15 - " $unknown->find(5);\n", // L16 - "}\n", // L17 + " $repo = $managerRegistry->getManager()->getRepository(NotificationImpl::class);\n", // L14 + " $repo->find(4);\n", // L15 + " $managerRegistry->getManager()->getRepository(NotificationImpl::class)->find(6);\n", // L16 + " $unknown->find(5);\n", // L17 + "}\n", // L18 ); open_file(&backend, &uri, text).await; @@ -1459,8 +1460,13 @@ async fn test_overridden_find_excludes_base_repository_and_unresolved_calls() { lines ); assert!( - !lines.contains(&15), - "Should NOT include unresolved $notificationRepository->find() on L15 — receivers are matched by resolved type, never by variable name; got lines: {:?}", + lines.contains(&15), + "Should include $repo->find() typed from getRepository(NotificationImpl::class) on L15; got lines: {:?}", + lines + ); + assert!( + lines.contains(&16), + "Should include inline getRepository(NotificationImpl::class)->find() on L16; got lines: {:?}", lines ); assert!( @@ -1484,8 +1490,8 @@ async fn test_overridden_find_excludes_base_repository_and_unresolved_calls() { lines ); assert!( - !lines.contains(&16), - "Should NOT include unresolved $unknown->find() on L16; got lines: {:?}", + !lines.contains(&17), + "Should NOT include unresolved $unknown->find() on L17; got lines: {:?}", lines ); } diff --git a/src/rename/namespace.rs b/src/rename/namespace.rs index c172547ae..97f426e42 100644 --- a/src/rename/namespace.rs +++ b/src/rename/namespace.rs @@ -168,20 +168,23 @@ impl Backend { } } + self.collect_framework_namespace_edits(old_prefix, new_prefix, &mut changes); + let psr4_rename_ops = self + .build_namespace_psr4_rename_ops(old_prefix, new_prefix) + .unwrap_or_default(); + self.collect_framework_path_edits_for_directory_renames(&psr4_rename_ops, &mut changes); + if changes.is_empty() { return None; } // PSR-4 directory rename: if a mapping exists, emit RenameFile // operations to move the directory. - if let Some(ops) = self.build_namespace_psr4_rename_ops(old_prefix, new_prefix) - && !ops.is_empty() - && self.supports_file_rename.load(Ordering::Acquire) - { + if !psr4_rename_ops.is_empty() && self.supports_file_rename.load(Ordering::Acquire) { let mut doc_ops: Vec = Vec::new(); // Add directory/file rename operations first. - for (old_uri, new_uri) in &ops { + for (old_uri, new_uri) in &psr4_rename_ops { doc_ops.push(DocumentChangeOperation::Op(ResourceOp::Rename( RenameFile { old_uri: old_uri.clone(), @@ -195,7 +198,7 @@ impl Backend { // Convert text edits to document changes. Rewrite URIs // that fall inside a renamed directory. for (uri, edits) in changes { - let target_uri = ops + let target_uri = psr4_rename_ops .iter() .find_map(|(old_u, new_u)| { let old_str = old_u.as_str(); diff --git a/src/rename/prepare.rs b/src/rename/prepare.rs index cac280afa..2c9441ba4 100644 --- a/src/rename/prepare.rs +++ b/src/rename/prepare.rs @@ -11,8 +11,11 @@ use std::collections::HashMap; use tower_lsp::lsp_types::*; use crate::Backend; +use crate::framework::{ + FrameworkReferenceKind, namespace_segment_range_at_offset, short_segment_range, +}; use crate::symbol_map::SymbolKind; -use crate::text_position::offset_to_position; +use crate::text_position::{offset_to_position, position_to_byte_offset}; use crate::util::build_fqn; use super::namespace::find_namespace_segment_at_offset; @@ -82,7 +85,9 @@ impl Backend { content: &str, position: Position, ) -> Option { - let span = self.lookup_symbol_at_position(uri, content, position)?; + let Some(span) = self.lookup_symbol_at_position(uri, content, position) else { + return self.handle_framework_prepare_rename(uri, content, position); + }; // The range below is built from this span's byte offsets, and the // editor shows it as the text about to be replaced. A map that @@ -142,7 +147,9 @@ impl Backend { position: Position, new_name: &str, ) -> Option { - let span = self.lookup_symbol_at_position(uri, content, position)?; + let Some(span) = self.lookup_symbol_at_position(uri, content, position) else { + return self.handle_framework_rename(uri, content, position, new_name); + }; // Every edit below is derived, directly or through find-references, // from this span. If the map it came from predates the buffer the @@ -319,6 +326,96 @@ impl Backend { }) } + fn handle_framework_prepare_rename( + &self, + uri: &str, + content: &str, + position: Position, + ) -> Option { + let reference = self.framework_reference_at_position(uri, content, position)?; + let (start, end, placeholder) = match reference.kind { + FrameworkReferenceKind::Class { fqn } => { + let source = content.get(reference.start as usize..reference.end as usize)?; + let (start, end) = short_segment_range(source, reference.start); + (start, end, crate::util::short_name(&fqn).to_string()) + } + FrameworkReferenceKind::Method { member_name, .. } => { + (reference.start, reference.end, member_name) + } + FrameworkReferenceKind::Namespace { prefix } => { + let source = content.get(reference.start as usize..reference.end as usize)?; + let cursor = position_to_byte_offset(content, position) as u32; + let (segment_idx, start, end) = + namespace_segment_range_at_offset(source, reference.start, cursor)?; + let placeholder = prefix + .split('\\') + .nth(segment_idx) + .unwrap_or(prefix.as_str()) + .to_string(); + (start, end, placeholder) + } + FrameworkReferenceKind::Path { .. } => return None, + }; + + Some(PrepareRenameResponse::RangeWithPlaceholder { + range: Range { + start: offset_to_position(content, start as usize), + end: offset_to_position(content, end as usize), + }, + placeholder, + }) + } + + fn handle_framework_rename( + &self, + uri: &str, + content: &str, + position: Position, + new_name: &str, + ) -> Option { + let reference = self.framework_reference_at_position(uri, content, position)?; + if self.is_vendor_framework_reference(uri, content, position) { + return None; + } + + match reference.kind { + FrameworkReferenceKind::Class { fqn } => { + let locations = + self.find_framework_references_for_rename(uri, content, position, true)?; + self.build_class_rename_edit(&fqn, new_name, &locations) + } + FrameworkReferenceKind::Method { .. } => { + let locations = + self.find_framework_references_for_rename(uri, content, position, true)?; + build_simple_rename_edit(self, uri, content, &locations, new_name) + } + FrameworkReferenceKind::Namespace { prefix } => { + let source = content.get(reference.start as usize..reference.end as usize)?; + let cursor = position_to_byte_offset(content, position) as u32; + let (segment_idx, _start, _end) = + namespace_segment_range_at_offset(source, reference.start, cursor)?; + self.build_namespace_rename_edit(&prefix, segment_idx, new_name) + } + FrameworkReferenceKind::Path { .. } => None, + } + } + + fn is_vendor_framework_reference(&self, uri: &str, content: &str, position: Position) -> bool { + let vendor_prefixes = self.workspace.vendor_uri_prefixes.lock().clone(); + if vendor_prefixes.is_empty() { + return false; + } + + self.resolve_definition(uri, content, position) + .into_iter() + .any(|loc| { + let def_uri = loc.uri.to_string(); + vendor_prefixes + .iter() + .any(|prefix| def_uri.starts_with(prefix.as_str())) + }) + } + /// Extract the renameable symbol name and its source range. /// /// Returns `None` for symbols that cannot be renamed. @@ -426,3 +523,46 @@ impl Backend { } } } + +fn build_simple_rename_edit( + backend: &Backend, + current_uri: &str, + current_content: &str, + locations: &[Location], + new_name: &str, +) -> Option { + if locations.is_empty() { + return None; + } + + let mut changes: HashMap> = HashMap::new(); + for location in locations { + let loc_uri_str = location.uri.to_string(); + let loc_content = if loc_uri_str == current_uri { + Some(current_content.to_string()) + } else { + backend.get_file_content(&loc_uri_str) + }; + if loc_content.is_none() { + continue; + } + + changes + .entry(location.uri.clone()) + .or_default() + .push(TextEdit { + range: location.range, + new_text: new_name.to_string(), + }); + } + + if changes.is_empty() { + None + } else { + Some(WorkspaceEdit { + changes: Some(changes), + document_changes: None, + change_annotations: None, + }) + } +} diff --git a/src/server.rs b/src/server.rs index 260e00783..9162abb4a 100644 --- a/src/server.rs +++ b/src/server.rs @@ -481,6 +481,14 @@ impl LanguageServer for Backend { } } + let framework_count = self.index_framework_workspace(); + if framework_count > 0 { + tracing::info!( + "PHPantom: indexed {} Symfony/Doctrine resource file(s)", + framework_count + ); + } + if let Some(poller) = poller { poller.finish().await; } @@ -607,6 +615,20 @@ impl LanguageServer for Backend { }, ]); } + watchers.extend([ + FileSystemWatcher { + glob_pattern: GlobPattern::String("**/*.yaml".to_string()), + kind: Some(WatchKind::Create | WatchKind::Change | WatchKind::Delete), + }, + FileSystemWatcher { + glob_pattern: GlobPattern::String("**/*.yml".to_string()), + kind: Some(WatchKind::Create | WatchKind::Change | WatchKind::Delete), + }, + FileSystemWatcher { + glob_pattern: GlobPattern::String("**/*.xml".to_string()), + kind: Some(WatchKind::Create | WatchKind::Change | WatchKind::Delete), + }, + ]); registrations.push(Registration { id: "workspace/didChangeWatchedFiles".to_string(), @@ -751,8 +773,15 @@ impl LanguageServer for Backend { // Resource documents are not PHP source. Build a lightweight symbol // map so navigation, references, rename, and PHP declaration lenses // all consume the same indexed occurrences. - if crate::resource_navigation::is_resource_document(&uri) { - self.update_resource_symbol_index(&uri, &text); + let is_resource = crate::resource_navigation::is_resource_document(&uri); + let is_framework_resource = crate::framework::is_framework_resource_uri(&uri); + if is_resource || is_framework_resource { + if is_resource { + self.update_resource_symbol_index(&uri, &text); + } + if is_framework_resource { + self.index_framework_uri_content(&uri, &text); + } self.log(MessageType::INFO, format!("Opened resource file: {}", uri)) .await; return; @@ -837,8 +866,15 @@ impl LanguageServer for Backend { .write() .insert(uri.clone(), Arc::clone(&text)); - if crate::resource_navigation::is_resource_document(&uri) { - self.update_resource_symbol_index(&uri, &text); + let is_resource = crate::resource_navigation::is_resource_document(&uri); + let is_framework_resource = crate::framework::is_framework_resource_uri(&uri); + if is_resource || is_framework_resource { + if is_resource { + self.update_resource_symbol_index(&uri, &text); + } + if is_framework_resource { + self.index_framework_uri_content(&uri, &text); + } if self.supports_code_lens_refresh.load(Ordering::Acquire) && let Some(ref client) = self.client { @@ -952,16 +988,26 @@ impl LanguageServer for Backend { self.blade_injected_vars.write().remove(&uri); } - if crate::resource_navigation::is_resource_document(&uri) { - if let Some(content) = self.get_file_content(&uri) { - self.update_resource_symbol_index(&uri, &content); - } else { - self.clear_file_maps(&uri); + let is_resource = crate::resource_navigation::is_resource_document(&uri); + let is_framework_resource = crate::framework::is_framework_resource_uri(&uri); + if is_resource || is_framework_resource { + if is_resource { + if let Some(content) = self.get_file_content(&uri) { + self.update_resource_symbol_index(&uri, &content); + } else { + self.clear_file_maps(&uri); + } } - } else { - self.clear_file_maps(&uri); + if is_framework_resource { + self.reindex_framework_uri_from_disk(&uri); + } + self.log(MessageType::INFO, format!("Closed resource file: {}", uri)) + .await; + return; } + self.clear_file_maps(&uri); + // Clear diagnostics so stale warnings don't linger after the file is closed self.clear_diagnostics_for_file(&uri).await; diff --git a/tests/integration/code_lens.rs b/tests/integration/code_lens.rs index 3569c6b31..b7c5ed39b 100644 --- a/tests/integration/code_lens.rs +++ b/tests/integration/code_lens.rs @@ -16,12 +16,12 @@ fn lens_titles(lenses: &[CodeLens]) -> Vec<&str> { .collect() } -async fn open_doc(backend: &phpantom_lsp::Backend, uri: Url, text: &str) { +async fn open_doc(backend: &phpantom_lsp::Backend, uri: Url, language_id: &str, text: &str) { backend .did_open(DidOpenTextDocumentParams { text_document: TextDocumentItem { uri, - language_id: "php".to_string(), + language_id: language_id.to_string(), version: 1, text: text.to_string(), }, @@ -74,7 +74,7 @@ final class LargeTestCase { &[("src/LargeTestCase.php", content)], ); let uri = Url::from_file_path(dir.path().join("src/LargeTestCase.php")).unwrap(); - open_doc(&backend, uri.clone(), content).await; + open_doc(&backend, uri.clone(), "php", content).await; // Drive workspace indexing through the public LSP path, as a real client // would before requesting lenses from an index reported as ready. @@ -148,7 +148,7 @@ function persistUnrelated(Unrelated $value): void { &[("src/Order.php", order), ("src/Unrelated.php", unrelated)], ); let uri = Url::from_file_path(dir.path().join("src/Order.php")).unwrap(); - open_doc(&backend, uri.clone(), order).await; + open_doc(&backend, uri.clone(), "php", order).await; backend .references(ReferenceParams { @@ -240,7 +240,7 @@ function persist(Order $order): void { )); let uri = Url::from_file_path(dir.path().join("src/Order.php")).unwrap(); - open_doc(&backend, uri.clone(), content).await; + open_doc(&backend, uri.clone(), "php", content).await; backend .references(ReferenceParams { text_document_position: TextDocumentPositionParams { @@ -309,7 +309,7 @@ makeWidget(); &[("src/functions.php", content)], ); let uri = Url::from_file_path(dir.path().join("src/functions.php")).unwrap(); - open_doc(&backend, uri.clone(), content).await; + open_doc(&backend, uri.clone(), "php", content).await; backend .references(ReferenceParams { text_document_position: TextDocumentPositionParams { @@ -354,6 +354,12 @@ makeWidget(); } } +fn uri_for(dir: &tempfile::TempDir, rel: &str) -> Url { + Url::from_file_path(dir.path().join(rel)).unwrap() +} + +const COMPOSER: &str = r#"{ "autoload": { "psr-4": { "App\\": "src/" } } }"#; + // ─── Basic Override Detection ─────────────────────────────────────────────── #[test] @@ -1168,3 +1174,199 @@ class Consumer { "titles: {titles:?}" ); } + +// ─── Symfony / Doctrine Framework Lenses ─────────────────────────────────── + +#[tokio::test] +async fn symfony_yaml_route_and_config_lenses() { + let controller_php = r#" + + +"#; + let (backend, dir) = create_psr4_workspace( + COMPOSER, + &[ + ("src/Entity/User.php", entity_php), + ("src/Storage/SpecialUserStore.php", repo_php), + ("config/doctrine/User.orm.yaml", doctrine_yaml), + ("config/doctrine/User.orm.xml", doctrine_xml), + ], + ); + + let entity_uri = uri_for(&dir, "src/Entity/User.php"); + let repo_uri = uri_for(&dir, "src/Storage/SpecialUserStore.php"); + open_doc(&backend, entity_uri.clone(), "php", entity_php).await; + open_doc(&backend, repo_uri.clone(), "php", repo_php).await; + open_doc( + &backend, + uri_for(&dir, "config/doctrine/User.orm.yaml"), + "yaml", + doctrine_yaml, + ) + .await; + open_doc( + &backend, + uri_for(&dir, "config/doctrine/User.orm.xml"), + "xml", + doctrine_xml, + ) + .await; + + let entity_lenses = backend + .handle_code_lens(&entity_uri.to_string(), entity_php) + .unwrap_or_default(); + let entity_titles = lens_titles(&entity_lenses); + assert!( + entity_titles.contains(&"Symfony/Doctrine config: 2 refs"), + "expected entity config refs from YAML and XML, got {entity_titles:?}" + ); + assert!( + entity_titles.contains(&"Doctrine repository: SpecialUserStore"), + "expected configured repository lens, got {entity_titles:?}" + ); + + let repo_lenses = backend + .handle_code_lens(&repo_uri.to_string(), repo_php) + .unwrap_or_default(); + let repo_titles = lens_titles(&repo_lenses); + assert!( + repo_titles.contains(&"Symfony/Doctrine config: 2 refs"), + "expected repository config refs from YAML and XML, got {repo_titles:?}" + ); + assert!( + repo_titles.contains(&"Doctrine entity: User"), + "expected reverse entity lens, got {repo_titles:?}" + ); +} + +#[tokio::test] +async fn doctrine_get_repository_lens_uses_repository_class_mapping() { + let entity_php = "em->getRepository(User::class)->find($id); + } +} +"#; + let doctrine_yaml = + "App\\Entity\\User:\n type: entity\n repositoryClass: App\\Storage\\SpecialUserStore\n"; + let (backend, dir) = create_psr4_workspace( + COMPOSER, + &[ + ("src/Entity/User.php", entity_php), + ("src/Storage/SpecialUserStore.php", repo_php), + ("src/Service/UserLookup.php", service_php), + ("config/doctrine/User.orm.yaml", doctrine_yaml), + ], + ); + + let service_uri = uri_for(&dir, "src/Service/UserLookup.php"); + open_doc( + &backend, + uri_for(&dir, "src/Entity/User.php"), + "php", + entity_php, + ) + .await; + open_doc( + &backend, + uri_for(&dir, "src/Storage/SpecialUserStore.php"), + "php", + repo_php, + ) + .await; + open_doc(&backend, service_uri.clone(), "php", service_php).await; + open_doc( + &backend, + uri_for(&dir, "config/doctrine/User.orm.yaml"), + "yaml", + doctrine_yaml, + ) + .await; + + let lenses = backend + .handle_code_lens(&service_uri.to_string(), service_php) + .unwrap_or_default(); + let titles = lens_titles(&lenses); + + assert!( + titles.contains(&"Doctrine repository: SpecialUserStore"), + "expected getRepository lens to use Doctrine mapping, got {titles:?}" + ); +} + +#[test] +fn symfony_route_attribute_lenses() { + let backend = create_test_backend(); + let content = r#" Url { + Url::from_file_path(dir.path().join(rel)).unwrap() +} + +fn edit_texts_for_uri(edit: &WorkspaceEdit, uri: &Url) -> Vec { + edit.changes + .as_ref() + .and_then(|changes| changes.get(uri)) + .map(|edits| edits.iter().map(|edit| edit.new_text.clone()).collect()) + .unwrap_or_default() +} + +#[tokio::test] +async fn symfony_yaml_service_class_goes_to_php_definition() { + let service_php = " + + +"#; + let (backend, dir) = create_psr4_workspace( + COMPOSER, + &[ + ("src/Entity/User.php", user_php), + ("src/Repository/UserRepository.php", repo_php), + ("config/services.yaml", services_yaml), + ("config/doctrine/User.orm.yaml", doctrine_yaml), + ("config/doctrine/User.orm.xml", doctrine_xml), + ], + ); + + let user_uri = uri_for(&dir, "src/Entity/User.php"); + open_doc(&backend, user_uri.clone(), "php", user_php).await; + open_doc( + &backend, + uri_for(&dir, "src/Repository/UserRepository.php"), + "php", + repo_php, + ) + .await; + open_doc( + &backend, + uri_for(&dir, "config/services.yaml"), + "yaml", + services_yaml, + ) + .await; + open_doc( + &backend, + uri_for(&dir, "config/doctrine/User.orm.yaml"), + "yaml", + doctrine_yaml, + ) + .await; + open_doc( + &backend, + uri_for(&dir, "config/doctrine/User.orm.xml"), + "xml", + doctrine_xml, + ) + .await; + + let refs = backend + .references(ReferenceParams { + text_document_position: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { uri: user_uri }, + position: Position::new(2, 7), + }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + context: ReferenceContext { + include_declaration: true, + }, + }) + .await + .unwrap() + .expect("class references should include framework resources"); + + let paths: Vec = refs.iter().map(|loc| loc.uri.path().to_string()).collect(); + assert!( + paths.iter().any(|p| p.ends_with("/config/services.yaml")), + "expected services.yaml reference, got {paths:?}" + ); + assert!( + paths + .iter() + .any(|p| p.ends_with("/config/doctrine/User.orm.yaml")), + "expected Doctrine YAML reference, got {paths:?}" + ); + assert!( + paths + .iter() + .any(|p| p.ends_with("/config/doctrine/User.orm.xml")), + "expected Doctrine XML reference, got {paths:?}" + ); +} + +#[tokio::test] +async fn class_rename_updates_symfony_and_doctrine_resources() { + let user_php = " + + +"#; + let (backend, dir) = create_psr4_workspace( + COMPOSER, + &[ + ("src/Entity/User.php", user_php), + ("config/services.yaml", services_yaml), + ("config/doctrine/User.orm.xml", doctrine_xml), + ], + ); + + let user_uri = uri_for(&dir, "src/Entity/User.php"); + let yaml_uri = uri_for(&dir, "config/services.yaml"); + let xml_uri = uri_for(&dir, "config/doctrine/User.orm.xml"); + open_doc(&backend, user_uri.clone(), "php", user_php).await; + open_doc(&backend, yaml_uri.clone(), "yaml", services_yaml).await; + open_doc(&backend, xml_uri.clone(), "xml", doctrine_xml).await; + + let edit = backend + .rename(RenameParams { + text_document_position: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { uri: user_uri }, + position: Position::new(2, 7), + }, + new_name: "Customer".to_string(), + work_done_progress_params: WorkDoneProgressParams::default(), + }) + .await + .unwrap() + .expect("class rename should produce edits"); + + assert!( + edit_texts_for_uri(&edit, &yaml_uri) + .iter() + .any(|text| text == "App\\Entity\\Customer"), + "expected services.yaml class edit, got {:?}", + edit_texts_for_uri(&edit, &yaml_uri) + ); + assert!( + edit_texts_for_uri(&edit, &xml_uri) + .iter() + .any(|text| text == "App\\Entity\\Customer"), + "expected Doctrine XML class edit, got {:?}", + edit_texts_for_uri(&edit, &xml_uri) + ); +} + +#[tokio::test] +async fn symfony_route_controller_action_resolves_and_renames_method() { + let controller_php = " Date: Wed, 5 Aug 2026 12:36:28 +0200 Subject: [PATCH 11/17] fix(symfony): Preserve framework CodeLens destinations Pass every indexed target to editor-native navigation and ignore empty PHP resource strings. --- docs/CHANGELOG.md | 2 ++ src/code_lens.rs | 14 +++++++++----- tests/integration/code_lens.rs | 13 +++++++++++++ 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 5422610fd..116d58a64 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -238,6 +238,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Code lenses with several targets keep every destination.** Symfony and Doctrine annotations that point to more than one matching declaration now pass the full location list to the editor instead of opening only the first. Contributed by @sidux. +- **Whitespace-only PHP strings no longer crash Symfony indexing.** A string containing only spaces could produce an invalid source range while PHPantom scanned Symfony-aware PHP files, terminating the request instead of ignoring the empty value. Contributed by @sidux. - **The deprecation pass parses a file once, not once per subject.** Deprecation checking recently became precise about which member accesses can share a resolved type, at the cost of resolving many more distinct subjects per file. Each of those resolutions re-parsed the file from scratch, and every `$this` inside a closure body paid for a fresh parse of its own, so diagnostics on a file with many closures ran several times slower than before. The pass now reuses one parsed AST and one chain-resolution cache across the whole file, the same way the unknown-member pass always has, making it faster than it was before the precision fix. - **Go-to-definition on a Blade echo delimiter agrees with its hover.** `{{ }}` compiles to a call to `e()`, which is not written anywhere in the template, and hovering the `{{`/`}}` itself already reflected that by describing the implicit `e()` call. Ctrl+Click on the same character disagreed: it fell through to the underlying PHP expression and landed on whatever the delimiter happened to sit next to, such as `route(...)` in `{{ route('pages.index') }}`. It now targets `e()` too. - **A method chain no longer resolves against another file's `use` import.** The cache that reuses a shared chain prefix (`Pen::make()` in `Pen::make()->write()`) keyed its entries by the chain's text alone, with nothing to tell two files apart. A background scan that walks many files under one cache activation, such as Find References or the reference-count computation behind the inlay hints, could resolve a chain in one file against a same-named class a different file imports under the same alias (`use A\Pen;` in one, `use B\Pen;` in another), undercounting or overcounting references depending on which file the cache was populated from first. Each file's chains are now cached separately. diff --git a/src/code_lens.rs b/src/code_lens.rs index fb96bf117..426d28f59 100644 --- a/src/code_lens.rs +++ b/src/code_lens.rs @@ -1159,11 +1159,15 @@ impl Backend { origin_position: Position, locations: Vec, ) -> Command { - let (uri, position) = locations - .first() - .map(|location| (location.uri.clone(), location.range.start)) - .unwrap_or((origin_uri, origin_position)); - self.build_code_lens_command(title, uri, position) + Command { + title, + command: "editor.action.showReferences".to_string(), + arguments: Some(vec![ + serde_json::json!(origin_uri), + serde_json::json!(origin_position), + serde_json::json!(locations), + ]), + } } /// Build a `Prototype` by locating the method's position in the diff --git a/tests/integration/code_lens.rs b/tests/integration/code_lens.rs index b7c5ed39b..3e86f238f 100644 --- a/tests/integration/code_lens.rs +++ b/tests/integration/code_lens.rs @@ -1266,6 +1266,19 @@ async fn doctrine_mapping_lenses_link_entity_and_configured_repository() { entity_titles.contains(&"Doctrine repository: SpecialUserStore"), "expected configured repository lens, got {entity_titles:?}" ); + let config_lens = entity_lenses + .iter() + .find(|lens| { + lens.command + .as_ref() + .is_some_and(|command| command.title == "Symfony/Doctrine config: 2 refs") + }) + .unwrap(); + let config_command = config_lens.command.as_ref().unwrap(); + assert_eq!(config_command.command, "editor.action.showReferences"); + let args = config_command.arguments.as_ref().unwrap(); + let locations: Vec = serde_json::from_value(args[2].clone()).unwrap(); + assert_eq!(locations.len(), 2); let repo_lenses = backend .handle_code_lens(&repo_uri.to_string(), repo_php) From 1f545946e545c5d3719d1b124ed8d2839b6357e5 Mon Sep 17 00:00:00 2001 From: sidux Date: Thu, 27 Aug 2026 03:05:32 +0200 Subject: [PATCH 12/17] fix(doctrine): Bound reverse relationship CodeLens --- src/code_lens.rs | 17 +++++++++++++---- src/references/members.rs | 15 ++++++++++++++- src/references/mod.rs | 6 +++++- tests/integration/code_lens.rs | 32 ++++++++++++++++++++++++++++++++ 4 files changed, 64 insertions(+), 6 deletions(-) diff --git a/src/code_lens.rs b/src/code_lens.rs index 426d28f59..3a3cc1c1b 100644 --- a/src/code_lens.rs +++ b/src/code_lens.rs @@ -11,6 +11,9 @@ use crate::atom::Atom; use crate::class_lookup::find_class_at_offset; use crate::definition::member::MemberKind; use crate::reference_index::ReferenceIndexKey; +use crate::references::{ + doctrine_repository_matches_entity_convention, looks_like_doctrine_repository, +}; use crate::symbol_map::SymbolKind; use crate::text_position::offset_to_position; use crate::types::{ClassInfo, ClassLikeKind, MAX_INHERITANCE_DEPTH, Visibility}; @@ -836,6 +839,15 @@ impl Backend { ) -> Vec { let mut out = self.framework_doctrine_entity_fqns_for_repository(repository_fqn); let repository = normalize_class_name(repository_fqn); + if !out.is_empty() { + return out; + } + let Some(repository_class) = class_loader(&repository) else { + return out; + }; + if !looks_like_doctrine_repository(&repository_class) { + return out; + } let mut candidates: Vec = Vec::new(); { @@ -860,10 +872,7 @@ impl Backend { if !looks_like_doctrine_entity_name(&entity_fqn) { continue; } - let repos = self.doctrine_repository_fqns_for_entity(&entity_fqn, class_loader); - if repos - .iter() - .any(|repo| normalize_class_name(repo).eq_ignore_ascii_case(&repository)) + if doctrine_repository_matches_entity_convention(&entity_fqn, &repository) && !out .iter() .any(|known| known.eq_ignore_ascii_case(&entity_fqn)) diff --git a/src/references/members.rs b/src/references/members.rs index 787ec9913..54e16aca3 100644 --- a/src/references/members.rs +++ b/src/references/members.rs @@ -1550,6 +1550,19 @@ fn doctrine_repository_short_name(entity_short: &str) -> String { format!("{stem}Repository") } +pub(crate) fn doctrine_repository_matches_entity_convention( + entity_fqn: &str, + repository_fqn: &str, +) -> bool { + let entity = normalize_fqn(entity_fqn); + let repository = normalize_fqn(repository_fqn); + let repository_short = doctrine_repository_short_name(crate::util::short_name(&entity)); + crate::util::short_name(&repository).eq_ignore_ascii_case(&repository_short) + || doctrine_repository_convention_candidates(&entity, &repository_short) + .iter() + .any(|candidate| candidate.eq_ignore_ascii_case(&repository)) +} + fn doctrine_repository_convention_candidates( entity_fqn: &str, repository_short: &str, @@ -1578,7 +1591,7 @@ fn doctrine_repository_convention_candidates( candidates } -fn looks_like_doctrine_repository(class_info: &ClassInfo) -> bool { +pub(crate) fn looks_like_doctrine_repository(class_info: &ClassInfo) -> bool { if class_info.name.to_string().ends_with("Repository") { return true; } diff --git a/src/references/mod.rs b/src/references/mod.rs index 2b83f667f..2ca29c5d0 100644 --- a/src/references/mod.rs +++ b/src/references/mod.rs @@ -36,7 +36,11 @@ mod functions; mod members; mod variables; -pub(crate) use members::MemberDeclarationReferenceQuery; +pub(crate) use members::{ + MemberDeclarationReferenceQuery, doctrine_repository_matches_entity_convention, + looks_like_doctrine_repository, +}; + use std::collections::HashSet; use std::path::{Path, PathBuf}; use std::sync::Arc; diff --git a/tests/integration/code_lens.rs b/tests/integration/code_lens.rs index 3e86f238f..5e5742cf9 100644 --- a/tests/integration/code_lens.rs +++ b/tests/integration/code_lens.rs @@ -1294,6 +1294,38 @@ async fn doctrine_mapping_lenses_link_entity_and_configured_repository() { ); } +#[tokio::test] +async fn doctrine_repository_convention_links_back_to_entity() { + let entity_php = " Date: Thu, 27 Aug 2026 09:54:41 +0200 Subject: [PATCH 13/17] fix(navigation): Preserve Symfony resource fallbacks Generic YAML and XML navigation now returns early only when it resolves a PHP symbol. Otherwise the semantic Symfony resolver still handles aliases, form fields, validation mappings, and configuration keys. --- src/server.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/server.rs b/src/server.rs index 9162abb4a..2e355ee24 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1121,7 +1121,9 @@ impl LanguageServer for Backend { ) .flatten() }); - return Ok(location.map(GotoDefinitionResponse::Scalar)); + if let Some(location) = location { + return Ok(Some(GotoDefinitionResponse::Scalar(location))); + } } // A component tag is HTML, so it has no position in the virtual From 7c9021478fa77aec195492e55d7d51e9c5e81744 Mon Sep 17 00:00:00 2001 From: sidux Date: Thu, 27 Aug 2026 14:53:34 +0200 Subject: [PATCH 14/17] fix(doctrine): Cache repository mapping index Build entity-to-repository pairs alongside the framework resource index and update them per URI. CodeLens and reference lookups now read the derived index instead of reopening and rescanning every YAML/XML resource for each declaration. --- docs/CHANGELOG.md | 1 + src/framework.rs | 105 +++++++++++++++++++++++++++++++--------------- src/lib.rs | 7 ++++ 3 files changed, 79 insertions(+), 34 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 116d58a64..6586e9579 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -57,6 +57,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **An `&&` operand that pins a value to one class outranks a later operand that only lists alternatives.** `$bound instanceof GenericType && ($class === GenericType::class || $bound instanceof TemplateType)` read the two operands as peers and answered `GenericType|TemplateType`, so passing the value on to anything expecting a `GenericType` was reported as a mismatch — even though the first operand alone settles the question. The disjunction can only narrow the value further, never widen it past what was already proven. A disjunction still narrows on its own when nothing in the chain pinned the subject down. - **Reference CodeLens stays responsive in large projects.** PHP member receivers are resolved once per file in parallel and kept in a compact semantic index, so later lens batches filter exact references without reopening or walking source files. The worker drains a request burst before sending one editor refresh, and clients without lens refresh compute only the lens they resolve. Contributed by @sidux. - **Reference and CodeLens annotations reuse the completed workspace index.** Resolving many lenses in a large project no longer starts another full filesystem walk for every declaration. Concurrent annotations share the first indexing pass, while an explicit Find References command still refreshes once so files created without an editor notification remain discoverable. Contributed by @sidux. +- **Doctrine relationship CodeLens stays bounded on large workspaces.** Entity-to-repository pairs are indexed as mapping resources change instead of rescanning every YAML/XML file per lens. Reverse repository lenses use those mappings directly and apply the standard naming convention without repeatedly resolving repository candidates for every indexed class. Contributed by @sidux. - **Argument checks accept the widenings PHP performs and the types the engine admits it does not know.** Four shapes of correct code were reported as type mismatches: a bounded `int<0, max>` passed to a `float` parameter, even though PHP widens an integer to a float on the way in; a `class-string` passed to `non-empty-string`, even though a string that names a class always has content; an `array-key` passed to `int` or to `string`, which is the key type of an array nobody described rather than a value measured to be two things; and a closure body doing `$a & $b` on untyped parameters, which produces a string from two strings just as readily as an int from two numbers. - **`get_class($x) === Foo::class` narrows the same subjects `instanceof` does.** The identity check only pinned a plain variable, so `get_class($this->held) === Sub::class` and `get_class($items[0]) === Sub::class` left the subject at its declared type and every member read past the check was reported as missing. A property fetch, an array element, and a call result are all narrowed now, in the `$x::class === Foo::class` spelling as well. - **A global function written with a leading backslash is the same function.** `\get_class($x) === Foo::class` and `\is_a($x, Foo::class)` narrowed nothing, and `if (!\class_exists('Vendor\Optional\Config')) { return; }` read as an un-negated guard, so it protected the `return;` instead of everything after it and the guarded class was reported as not found. A class named in such a guard with escaped backslashes (`'Vendor\\Optional\\Config'`) is now matched against the reference it guards, too. diff --git a/src/framework.rs b/src/framework.rs index 9fd050f54..8732fc3ef 100644 --- a/src/framework.rs +++ b/src/framework.rs @@ -57,10 +57,17 @@ pub(crate) struct DoctrineRepositoryMapping { pub(crate) type FrameworkReferenceIndex = Arc>>>>; +pub(crate) type DoctrineRepositoryIndex = + Arc>>>>; + pub(crate) fn new_framework_reference_index() -> FrameworkReferenceIndex { Arc::new(RwLock::new(HashMap::new())) } +pub(crate) fn new_doctrine_repository_index() -> DoctrineRepositoryIndex { + Arc::new(RwLock::new(HashMap::new())) +} + pub(crate) fn is_framework_resource_uri(uri: &str) -> bool { let path = uri .strip_prefix("file://") @@ -100,6 +107,7 @@ impl Backend { }; let mut indexed = HashMap::new(); + let mut doctrine_repositories = HashMap::new(); for entry in ignore::WalkBuilder::new(&root) .hidden(false) .build() @@ -116,6 +124,10 @@ impl Backend { continue; }; let uri = crate::util::path_to_uri(path); + let mappings = scan_doctrine_repository_mappings(&uri, &content); + if !mappings.is_empty() { + doctrine_repositories.insert(uri.clone(), Arc::new(mappings)); + } let refs = scan_framework_references(&uri, &content); if !refs.is_empty() { indexed.insert(uri, Arc::new(refs)); @@ -124,6 +136,7 @@ impl Backend { let count = indexed.len(); *self.framework_references.write() = indexed; + *self.framework_doctrine_repositories.write() = doctrine_repositories; count } @@ -132,12 +145,19 @@ impl Backend { return; } let refs = scan_framework_references(uri, content); + let mappings = scan_doctrine_repository_mappings(uri, content); let mut index = self.framework_references.write(); if refs.is_empty() { index.remove(uri); } else { index.insert(uri.to_string(), Arc::new(refs)); } + let mut doctrine_repositories = self.framework_doctrine_repositories.write(); + if mappings.is_empty() { + doctrine_repositories.remove(uri); + } else { + doctrine_repositories.insert(uri.to_string(), Arc::new(mappings)); + } } pub(crate) fn reindex_framework_uri_from_disk(&self, uri: &str) { @@ -152,14 +172,13 @@ impl Backend { }); match content { Some(content) => self.index_framework_uri_content(uri, &content), - None => { - self.framework_references.write().remove(uri); - } + None => self.remove_framework_uri(uri), } } pub(crate) fn remove_framework_uri(&self, uri: &str) { self.framework_references.write().remove(uri); + self.framework_doctrine_repositories.write().remove(uri); } pub(crate) fn apply_framework_file_change( @@ -296,9 +315,11 @@ impl Backend { ) -> Vec { let target = normalize_framework_fqn(entity_fqn); let mut out = Vec::new(); - for mapping in self.framework_doctrine_repository_mappings() { - if normalize_framework_fqn(&mapping.entity_fqn).eq_ignore_ascii_case(&target) { - push_unique_string(&mut out, normalize_framework_fqn(&mapping.repository_fqn)); + for mappings in self.framework_doctrine_repositories.read().values() { + for mapping in mappings.iter() { + if normalize_framework_fqn(&mapping.entity_fqn).eq_ignore_ascii_case(&target) { + push_unique_string(&mut out, normalize_framework_fqn(&mapping.repository_fqn)); + } } } out @@ -310,39 +331,16 @@ impl Backend { ) -> Vec { let target = normalize_framework_fqn(repository_fqn); let mut out = Vec::new(); - for mapping in self.framework_doctrine_repository_mappings() { - if normalize_framework_fqn(&mapping.repository_fqn).eq_ignore_ascii_case(&target) { - push_unique_string(&mut out, normalize_framework_fqn(&mapping.entity_fqn)); + for mappings in self.framework_doctrine_repositories.read().values() { + for mapping in mappings.iter() { + if normalize_framework_fqn(&mapping.repository_fqn).eq_ignore_ascii_case(&target) { + push_unique_string(&mut out, normalize_framework_fqn(&mapping.entity_fqn)); + } } } out } - pub(crate) fn framework_doctrine_repository_mappings(&self) -> Vec { - let uris: Vec = self.framework_references.read().keys().cloned().collect(); - let mut mappings = Vec::new(); - for uri in uris { - let Some(content) = self.get_file_content_arc(&uri) else { - continue; - }; - mappings.extend(scan_doctrine_repository_mappings(&uri, &content)); - } - mappings.sort_by(|a, b| { - a.uri - .cmp(&b.uri) - .then(a.entity_start.cmp(&b.entity_start)) - .then(a.repository_start.cmp(&b.repository_start)) - }); - mappings.dedup_by(|a, b| { - a.uri == b.uri - && normalize_framework_fqn(&a.entity_fqn) - .eq_ignore_ascii_case(&normalize_framework_fqn(&b.entity_fqn)) - && normalize_framework_fqn(&a.repository_fqn) - .eq_ignore_ascii_case(&normalize_framework_fqn(&b.repository_fqn)) - }); - mappings - } - pub(crate) fn framework_highlights( &self, uri: &str, @@ -1166,3 +1164,42 @@ fn normalize_path(path: PathBuf) -> PathBuf { } normalized } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn doctrine_repository_index_updates_with_framework_resource() { + let backend = Backend::new_test(); + let uri = "file:///project/config/doctrine/User.orm.yaml"; + backend.index_framework_uri_content( + uri, + "App\\Entity\\User:\n repositoryClass: App\\Repository\\UserRepository\n", + ); + assert_eq!( + backend.framework_doctrine_repository_fqns_for_entity("App\\Entity\\User"), + vec!["App\\Repository\\UserRepository"] + ); + + backend.index_framework_uri_content( + uri, + "App\\Entity\\User:\n repositoryClass: App\\Storage\\UserStore\n", + ); + assert_eq!( + backend.framework_doctrine_repository_fqns_for_entity("App\\Entity\\User"), + vec!["App\\Storage\\UserStore"] + ); + assert!( + backend + .framework_doctrine_entity_fqns_for_repository("App\\Repository\\UserRepository") + .is_empty() + ); + + backend.remove_framework_uri(uri); + assert!( + backend + .framework_doctrine_repository_fqns_for_entity("App\\Entity\\User") + .is_empty() + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index dd1baaf95..14995c0f9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -569,6 +569,10 @@ pub struct Backend { /// controller method strings, and path-like resource imports are indexed /// here and queried by definition, references, rename, and highlights. pub(crate) framework_references: framework::FrameworkReferenceIndex, + /// Doctrine entity-to-repository pairs derived alongside framework + /// resources, keyed by source URI so CodeLens lookups never rescan every + /// YAML/XML file and watched changes can update one entry at a time. + pub(crate) framework_doctrine_repositories: framework::DoctrineRepositoryIndex, /// Cross-file candidate index for find-references. /// /// Maintained from each file's [`symbol_maps`] entry during parsing. @@ -1091,6 +1095,7 @@ impl Backend { open_files: Arc::new(RwLock::new(HashMap::new())), symbol_maps: Arc::new(RwLock::new(HashMap::new())), framework_references: framework::new_framework_reference_index(), + framework_doctrine_repositories: framework::new_doctrine_repository_index(), reference_index: reference_index::new_reference_index(), skip_reference_index: false, symbols: SymbolIndex::new(), @@ -1203,6 +1208,7 @@ impl Backend { open_files: Arc::new(RwLock::new(HashMap::new())), symbol_maps: Arc::new(RwLock::new(HashMap::new())), framework_references: framework::new_framework_reference_index(), + framework_doctrine_repositories: framework::new_doctrine_repository_index(), reference_index: reference_index::new_reference_index(), skip_reference_index: false, symbols: SymbolIndex::new(), @@ -1858,6 +1864,7 @@ impl Backend { open_files: Arc::clone(&self.open_files), symbol_maps: Arc::clone(&self.symbol_maps), framework_references: Arc::clone(&self.framework_references), + framework_doctrine_repositories: Arc::clone(&self.framework_doctrine_repositories), reference_index: Arc::clone(&self.reference_index), skip_reference_index: self.skip_reference_index, symbols: self.symbols.clone(), From 2c60084aa7fc45d22ccc177a5074b8f6202e78dd Mon Sep 17 00:00:00 2001 From: sidux Date: Thu, 27 Aug 2026 15:19:26 +0200 Subject: [PATCH 15/17] fix(indexing): Index framework reference lookups --- docs/CHANGELOG.md | 1 + src/framework.rs | 266 ++++++++++++++++++++++++++++++-------- src/lib.rs | 6 + src/references/members.rs | 7 + 4 files changed, 228 insertions(+), 52 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 6586e9579..b364c704b 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -56,6 +56,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **A class named `Scalar` or `Numeric` is a class, not a PHPDoc pseudo-type.** `scalar` and `numeric` have no native spelling in PHP, so a project may name a class either of them, and nikic/php-parser does exactly that with `PhpParser\Node\Scalar`. Any capitalised spelling was folded into the pseudo-type instead of being resolved through the file's imports, which left the name unqualified and every check against it failing: passing a `Scalar` to a parameter typed as its own parent was reported as a mismatch, in a native type hint, a `@param`, a `@return`, and a `@implements` type argument alike. The all-lowercase spellings keep their PHPDoc meaning, which is the same rule already applied to `Number`, `Integer`, `Boolean`, `Double`, and `Resource`. - **An `&&` operand that pins a value to one class outranks a later operand that only lists alternatives.** `$bound instanceof GenericType && ($class === GenericType::class || $bound instanceof TemplateType)` read the two operands as peers and answered `GenericType|TemplateType`, so passing the value on to anything expecting a `GenericType` was reported as a mismatch — even though the first operand alone settles the question. The disjunction can only narrow the value further, never widen it past what was already proven. A disjunction still narrows on its own when nothing in the chain pinned the subject down. - **Reference CodeLens stays responsive in large projects.** PHP member receivers are resolved once per file in parallel and kept in a compact semantic index, so later lens batches filter exact references without reopening or walking source files. The worker drains a request burst before sending one editor refresh, and clients without lens refresh compute only the lens they resolve. Contributed by @sidux. +- **Reference CodeLens stays responsive when a project has many framework resources or repeated member accesses.** Symfony and Doctrine class/member links now use an incremental inverted index instead of scanning every YAML/XML reference for each PHP declaration. Exact member searches also reuse one parsed PHP syntax tree per candidate file, avoiding repeated reparses for common methods. Contributed by @sidux. - **Reference and CodeLens annotations reuse the completed workspace index.** Resolving many lenses in a large project no longer starts another full filesystem walk for every declaration. Concurrent annotations share the first indexing pass, while an explicit Find References command still refreshes once so files created without an editor notification remain discoverable. Contributed by @sidux. - **Doctrine relationship CodeLens stays bounded on large workspaces.** Entity-to-repository pairs are indexed as mapping resources change instead of rescanning every YAML/XML file per lens. Reverse repository lenses use those mappings directly and apply the standard naming convention without repeatedly resolving repository candidates for every indexed class. Contributed by @sidux. - **Argument checks accept the widenings PHP performs and the types the engine admits it does not know.** Four shapes of correct code were reported as type mismatches: a bounded `int<0, max>` passed to a `float` parameter, even though PHP widens an integer to a float on the way in; a `class-string` passed to `non-empty-string`, even though a string that names a class always has content; an `array-key` passed to `int` or to `string`, which is the key type of an array nobody described rather than a value measured to be two things; and a closure body doing `$a & $b` on untyped parameters, which produces a string from two strings just as readily as an int from two numbers. diff --git a/src/framework.rs b/src/framework.rs index 8732fc3ef..dc877c068 100644 --- a/src/framework.rs +++ b/src/framework.rs @@ -15,7 +15,6 @@ use tower_lsp::lsp_types::{ }; use crate::Backend; -use crate::references::push_unique_location; use crate::text_position::{offset_to_position, position_to_offset}; use crate::util::strip_fqn_prefix; @@ -60,6 +59,48 @@ pub(crate) type FrameworkReferenceIndex = pub(crate) type DoctrineRepositoryIndex = Arc>>>>; +#[derive(Debug, Clone)] +struct IndexedFrameworkLocation { + uri: Arc, + range: Range, +} + +impl IndexedFrameworkLocation { + fn to_lsp(&self) -> Option { + Some(Location { + uri: Url::parse(&self.uri).ok()?, + range: self.range, + }) + } +} + +#[derive(Debug, Clone)] +struct IndexedFrameworkMemberLocation { + class_fqn: String, + location: IndexedFrameworkLocation, +} + +#[derive(Debug, Default)] +struct FrameworkLookupUriKeys { + classes: HashSet, + methods: HashSet, +} + +/// Inverted locations for class and method references in framework resources. +/// +/// The primary framework index stays keyed by URI for cursor-local features. +/// This derived index makes cross-file lookups proportional to the matching +/// references instead of to every YAML/XML reference in the workspace. The +/// reverse URI map keeps watched-file updates proportional to one resource. +#[derive(Debug, Default)] +pub(crate) struct FrameworkReferenceLookupIndexInner { + classes: HashMap>, + methods: HashMap>, + uri_keys: HashMap, FrameworkLookupUriKeys>, +} + +pub(crate) type FrameworkReferenceLookupIndex = Arc>; + pub(crate) fn new_framework_reference_index() -> FrameworkReferenceIndex { Arc::new(RwLock::new(HashMap::new())) } @@ -68,6 +109,10 @@ pub(crate) fn new_doctrine_repository_index() -> DoctrineRepositoryIndex { Arc::new(RwLock::new(HashMap::new())) } +pub(crate) fn new_framework_reference_lookup_index() -> FrameworkReferenceLookupIndex { + Arc::new(RwLock::new(FrameworkReferenceLookupIndexInner::default())) +} + pub(crate) fn is_framework_resource_uri(uri: &str) -> bool { let path = uri .strip_prefix("file://") @@ -100,6 +145,80 @@ fn is_skipped_resource_path(path: &Path) -> bool { } impl Backend { + fn replace_framework_lookup_uri( + lookup: &mut FrameworkReferenceLookupIndexInner, + uri: &str, + content: &str, + references: &[FrameworkReference], + ) { + Self::remove_framework_lookup_uri(lookup, uri); + + let uri: Arc = Arc::from(uri); + let mut keys = FrameworkLookupUriKeys::default(); + for reference in references { + let location = IndexedFrameworkLocation { + uri: Arc::clone(&uri), + range: Range::new( + offset_to_position(content, reference.start as usize), + offset_to_position(content, reference.end as usize), + ), + }; + match &reference.kind { + FrameworkReferenceKind::Class { fqn } => { + let key = framework_fqn_lookup_key(fqn); + lookup + .classes + .entry(key.clone()) + .or_default() + .push(location); + keys.classes.insert(key); + } + FrameworkReferenceKind::Method { + class_fqn, + member_name, + } => { + lookup.methods.entry(member_name.clone()).or_default().push( + IndexedFrameworkMemberLocation { + class_fqn: framework_fqn_lookup_key(class_fqn), + location, + }, + ); + keys.methods.insert(member_name.clone()); + } + FrameworkReferenceKind::Namespace { .. } | FrameworkReferenceKind::Path { .. } => {} + } + } + + if !keys.classes.is_empty() || !keys.methods.is_empty() { + lookup.uri_keys.insert(uri, keys); + } + } + + fn remove_framework_lookup_uri(lookup: &mut FrameworkReferenceLookupIndexInner, uri: &str) { + let Some(keys) = lookup.uri_keys.remove(uri) else { + return; + }; + + for key in keys.classes { + let remove_key = lookup.classes.get_mut(&key).is_some_and(|locations| { + locations.retain(|location| location.uri.as_ref() != uri); + locations.is_empty() + }); + if remove_key { + lookup.classes.remove(&key); + } + } + for key in keys.methods { + let remove_key = lookup.methods.get_mut(&key).is_some_and(|locations| { + locations.retain(|entry| entry.location.uri.as_ref() != uri); + locations.is_empty() + }); + if remove_key { + lookup.methods.remove(&key); + } + } + } + /// Scan all YAML/XML framework resources under the workspace root. pub(crate) fn index_framework_workspace(&self) -> usize { let Some(root) = self.workspace.workspace_root.read().clone() else { @@ -108,6 +227,7 @@ impl Backend { let mut indexed = HashMap::new(); let mut doctrine_repositories = HashMap::new(); + let mut lookup = FrameworkReferenceLookupIndexInner::default(); for entry in ignore::WalkBuilder::new(&root) .hidden(false) .build() @@ -130,6 +250,7 @@ impl Backend { } let refs = scan_framework_references(&uri, &content); if !refs.is_empty() { + Self::replace_framework_lookup_uri(&mut lookup, &uri, &content, &refs); indexed.insert(uri, Arc::new(refs)); } } @@ -137,6 +258,7 @@ impl Backend { let count = indexed.len(); *self.framework_references.write() = indexed; *self.framework_doctrine_repositories.write() = doctrine_repositories; + *self.framework_reference_lookup.write() = lookup; count } @@ -147,9 +269,12 @@ impl Backend { let refs = scan_framework_references(uri, content); let mappings = scan_doctrine_repository_mappings(uri, content); let mut index = self.framework_references.write(); + let mut lookup = self.framework_reference_lookup.write(); if refs.is_empty() { index.remove(uri); + Self::remove_framework_lookup_uri(&mut lookup, uri); } else { + Self::replace_framework_lookup_uri(&mut lookup, uri, content, &refs); index.insert(uri.to_string(), Arc::new(refs)); } let mut doctrine_repositories = self.framework_doctrine_repositories.write(); @@ -179,6 +304,7 @@ impl Backend { pub(crate) fn remove_framework_uri(&self, uri: &str) { self.framework_references.write().remove(uri); self.framework_doctrine_repositories.write().remove(uri); + Self::remove_framework_lookup_uri(&mut self.framework_reference_lookup.write(), uri); } pub(crate) fn apply_framework_file_change( @@ -244,27 +370,14 @@ impl Backend { } pub(crate) fn framework_class_reference_locations(&self, target_fqn: &str) -> Vec { - let target = normalize_framework_fqn(target_fqn); - let mut locations = Vec::new(); - - for (uri, refs) in self.framework_references.read().iter() { - let Ok(parsed_uri) = Url::parse(uri) else { - continue; - }; - let Some(content) = self.get_file_content_arc(uri) else { - continue; - }; - for reference in refs.iter() { - let FrameworkReferenceKind::Class { fqn } = &reference.kind else { - continue; - }; - if normalize_framework_fqn(fqn).eq_ignore_ascii_case(&target) { - let start = offset_to_position(&content, reference.start as usize); - let end = offset_to_position(&content, reference.end as usize); - push_unique_location(&mut locations, &parsed_uri, start, end); - } - } - } + let lookup = self.framework_reference_lookup.read(); + let mut locations = lookup + .classes + .get(&framework_fqn_lookup_key(target_fqn)) + .into_iter() + .flatten() + .filter_map(IndexedFrameworkLocation::to_lsp) + .collect(); sort_locations(&mut locations); locations @@ -275,36 +388,20 @@ impl Backend { target_member: &str, hierarchy: Option<&HashSet>, ) -> Vec { - let mut locations = Vec::new(); - for (uri, refs) in self.framework_references.read().iter() { - let Ok(parsed_uri) = Url::parse(uri) else { - continue; - }; - let Some(content) = self.get_file_content_arc(uri) else { - continue; - }; - for reference in refs.iter() { - let FrameworkReferenceKind::Method { - class_fqn, - member_name, - } = &reference.kind - else { - continue; - }; - if member_name != target_member { - continue; - } - if let Some(hierarchy) = hierarchy { - let class_fqn = normalize_framework_fqn(class_fqn); - if !hierarchy.iter().any(|h| h.eq_ignore_ascii_case(&class_fqn)) { - continue; - } - } - let start = offset_to_position(&content, reference.start as usize); - let end = offset_to_position(&content, reference.end as usize); - push_unique_location(&mut locations, &parsed_uri, start, end); - } - } + let hierarchy = hierarchy.map(normalized_framework_hierarchy); + let lookup = self.framework_reference_lookup.read(); + let mut locations = lookup + .methods + .get(target_member) + .into_iter() + .flatten() + .filter(|entry| { + hierarchy + .as_ref() + .is_none_or(|hierarchy| hierarchy.contains(&entry.class_fqn)) + }) + .filter_map(|entry| entry.location.to_lsp()) + .collect(); sort_locations(&mut locations); locations } @@ -967,6 +1064,19 @@ pub(crate) fn normalize_framework_fqn(name: &str) -> String { out.trim_end_matches('\\').to_string() } +fn framework_fqn_lookup_key(name: &str) -> String { + let mut key = normalize_framework_fqn(name); + key.make_ascii_lowercase(); + key +} + +fn normalized_framework_hierarchy(hierarchy: &HashSet) -> HashSet { + hierarchy + .iter() + .map(|fqn| framework_fqn_lookup_key(fqn)) + .collect() +} + fn valid_framework_name(name: &str) -> bool { let name = name.trim_matches('\\'); if name.is_empty() { @@ -1164,6 +1274,7 @@ fn normalize_path(path: PathBuf) -> PathBuf { } normalized } + #[cfg(test)] mod tests { use super::*; @@ -1202,4 +1313,55 @@ mod tests { .is_empty() ); } + + #[test] + fn framework_reference_lookup_updates_and_removes_one_resource() { + let backend = Backend::new_test(); + let uri = "file:///project/config/routes.yaml"; + backend.index_framework_uri_content( + uri, + "home:\n path: /\n controller: App\\Controller\\HomeController::index\n", + ); + + assert_eq!( + backend + .framework_class_reference_locations("app\\controller\\homecontroller") + .len(), + 1 + ); + assert_eq!( + backend + .framework_member_reference_locations("index", None) + .len(), + 1 + ); + + backend.index_framework_uri_content( + uri, + "admin:\n path: /admin\n controller: App\\Controller\\AdminController::dashboard\n", + ); + assert!( + backend + .framework_member_reference_locations("index", None) + .is_empty() + ); + assert_eq!( + backend + .framework_member_reference_locations("dashboard", None) + .len(), + 1 + ); + + backend.remove_framework_uri(uri); + assert!( + backend + .framework_class_reference_locations("App\\Controller\\AdminController") + .is_empty() + ); + assert!( + backend + .framework_member_reference_locations("dashboard", None) + .is_empty() + ); + } } diff --git a/src/lib.rs b/src/lib.rs index 14995c0f9..5a70b0ca7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -569,6 +569,9 @@ pub struct Backend { /// controller method strings, and path-like resource imports are indexed /// here and queried by definition, references, rename, and highlights. pub(crate) framework_references: framework::FrameworkReferenceIndex, + /// Cross-file framework class/member locations derived while resources + /// are scanned, with a reverse URI map for incremental watched updates. + pub(crate) framework_reference_lookup: framework::FrameworkReferenceLookupIndex, /// Doctrine entity-to-repository pairs derived alongside framework /// resources, keyed by source URI so CodeLens lookups never rescan every /// YAML/XML file and watched changes can update one entry at a time. @@ -1095,6 +1098,7 @@ impl Backend { open_files: Arc::new(RwLock::new(HashMap::new())), symbol_maps: Arc::new(RwLock::new(HashMap::new())), framework_references: framework::new_framework_reference_index(), + framework_reference_lookup: framework::new_framework_reference_lookup_index(), framework_doctrine_repositories: framework::new_doctrine_repository_index(), reference_index: reference_index::new_reference_index(), skip_reference_index: false, @@ -1208,6 +1212,7 @@ impl Backend { open_files: Arc::new(RwLock::new(HashMap::new())), symbol_maps: Arc::new(RwLock::new(HashMap::new())), framework_references: framework::new_framework_reference_index(), + framework_reference_lookup: framework::new_framework_reference_lookup_index(), framework_doctrine_repositories: framework::new_doctrine_repository_index(), reference_index: reference_index::new_reference_index(), skip_reference_index: false, @@ -1864,6 +1869,7 @@ impl Backend { open_files: Arc::clone(&self.open_files), symbol_maps: Arc::clone(&self.symbol_maps), framework_references: Arc::clone(&self.framework_references), + framework_reference_lookup: Arc::clone(&self.framework_reference_lookup), framework_doctrine_repositories: Arc::clone(&self.framework_doctrine_repositories), reference_index: Arc::clone(&self.reference_index), skip_reference_index: self.skip_reference_index, diff --git a/src/references/members.rs b/src/references/members.rs index 54e16aca3..4271d8ae3 100644 --- a/src/references/members.rs +++ b/src/references/members.rs @@ -549,6 +549,11 @@ impl Backend { let mut file_content: Option> = None; + // Receiver resolution may visit the parsed AST once per matching + // access. Keep one AST alive for this candidate file so common + // member names do not reparse the whole source for every access. + let parse_cache_guard = std::cell::OnceCell::new(); + // Lazily resolved file context — only computed when we need // to check a candidate's subject against the hierarchy. let file_ctx_cell: std::cell::OnceCell = @@ -581,6 +586,8 @@ impl Backend { let Some(ref content) = file_content else { break; }; + parse_cache_guard + .get_or_init(|| crate::parser::with_parse_cache(content)); let ctx = file_ctx_cell.get_or_init(|| self.file_context(file_uri)); let subject_fqns = self.resolve_subject_to_fqns( From 6106c789644f4be00cf6716a6d8627001881e0bf Mon Sep 17 00:00:00 2001 From: sidux Date: Thu, 27 Aug 2026 20:40:07 +0200 Subject: [PATCH 16/17] test(frameworks): avoid redundant URI allocations --- tests/integration/code_lens.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/integration/code_lens.rs b/tests/integration/code_lens.rs index 5e5742cf9..023ecd393 100644 --- a/tests/integration/code_lens.rs +++ b/tests/integration/code_lens.rs @@ -1201,7 +1201,7 @@ class HomeController { open_doc(&backend, routes_uri, "yaml", routes_yaml).await; let lenses = backend - .handle_code_lens(&controller_uri.to_string(), controller_php) + .handle_code_lens(controller_uri.as_ref(), controller_php) .unwrap_or_default(); let titles = lens_titles(&lenses); @@ -1255,7 +1255,7 @@ async fn doctrine_mapping_lenses_link_entity_and_configured_repository() { .await; let entity_lenses = backend - .handle_code_lens(&entity_uri.to_string(), entity_php) + .handle_code_lens(entity_uri.as_ref(), entity_php) .unwrap_or_default(); let entity_titles = lens_titles(&entity_lenses); assert!( @@ -1281,7 +1281,7 @@ async fn doctrine_mapping_lenses_link_entity_and_configured_repository() { assert_eq!(locations.len(), 2); let repo_lenses = backend - .handle_code_lens(&repo_uri.to_string(), repo_php) + .handle_code_lens(repo_uri.as_ref(), repo_php) .unwrap_or_default(); let repo_titles = lens_titles(&repo_lenses); assert!( @@ -1380,7 +1380,7 @@ class UserLookup { .await; let lenses = backend - .handle_code_lens(&service_uri.to_string(), service_php) + .handle_code_lens(service_uri.as_ref(), service_php) .unwrap_or_default(); let titles = lens_titles(&lenses); From 7370ea9b04507d00754201a2f15dbcaeb39e05da Mon Sep 17 00:00:00 2001 From: sidux Date: Thu, 27 Aug 2026 19:47:29 +0200 Subject: [PATCH 17/17] fix(indexing): keep resource startup progress moving --- docs/CHANGELOG.md | 1 + src/framework.rs | 73 +++++++++++++++++++++++++++++++---------- src/indexing/preload.rs | 6 ++-- src/server.rs | 3 +- 4 files changed, 61 insertions(+), 22 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index b364c704b..72ee4881c 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -43,6 +43,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **A value PHP itself leaves open is `mixed`, and the diagnostic says so.** The elements of an `array` nobody described, the result of a `@return mixed` accessor, the entries of a `mixed` variadic, and a member reached through a name only known at runtime all resolve to `mixed` rather than to nothing at all, so a later `instanceof` or `is_string()` guard refines them and completion offers what the guard proved. Where nothing narrows them, a member read off one of them is still unverifiable, but the report now names `mixed` instead of saying the type could not be worked out: the first points at an annotation missing from the codebase, the second at the type engine coming up short, and reading one as the other sends you looking in the wrong place. A subject nothing typed at all still says exactly that. - **Arithmetic on a value nobody typed is not held to both halves of its answer.** `$placeholder['position'] - 1` on an undescribed array is an `int` or a `float` depending on the value, and no annotation on the element can rule either out, so passing it to an `int` parameter was reported as a mismatch on code that is fine. Such a result is now treated the way PHP's own `/` and `**` already were: one half satisfying the target is enough. The relaxation follows the chain, so `strlen($s) + $line - 1` returned from a method declared `int` is accepted as well, and `array_sum()` over an array whose elements nobody typed answers the same way. An operand the code did describe keeps its promise: `$s * 2` on a `string` is still reported, and summing a list of floats still yields a float. - **A `@return mixed` whose body disagrees with itself keeps the declaration.** Reading a body is how PHPantom recovers what an uninformative signature left out, but a body with several `return` statements that produce different types has not decided anything either, and the union it produces is only as complete as the branch analysis behind it. A method returning a schema, an array, or its own `mixed` argument was read as one of two classes, which claimed the array could not happen and then reported the caller that hands the result straight on. A body every `return` agrees on still narrows the declaration, so a `@return mixed` factory whose body plainly returns one class resolves to that class as before. +- **Framework resource indexing no longer appears stuck during startup.** Building reverse navigation for class names in large YAML and XML files now creates one line index per file instead of rescanning from the beginning for every match. Autoload helpers are counted only after they finish parsing, and framework resources have their own counted progress phase, so the startup bar keeps describing the work PHPantom is actually doing. Contributed by @sidux. - **A callable's signature is completed from where it is written, not only from what it declares.** A closure passed to a sorting or mapping function left its parameters untyped, so every member read in its body went unverified: `usort($errors, fn ($a, $b) => $a->getLine() <=> $b->getLine())` now types both parameters from the array's own element type, and `uasort`/`uksort` hand their callback the values or the keys as PHP does. A callback that annotates a return type wider than what its body actually hands back keeps the narrower one, so passing the result to a parameter expecting a subclass is no longer reported as a mismatch. And a doc comment written above the statement a closure is assigned in still types the closure: PHP attaches it to the statement, but its `@param` tags belong to the closure. - **A generic argument is recovered from wherever the call establishes it.** Three sources went unread. A `@template` that only another template's declared bound names (`@template TArray of array` with `@param TArray $array`) now binds from the argument that bound the other one. A `@template` reachable through the class a `class-string` argument names is read off that class's own `@extends`/`@implements`, including through an intermediate generic interface. And the directory-walk idiom `foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir)) as $file)` binds the wrapper to the iterator it was constructed with, so the traversal yields the files it really yields. A by-reference parameter whose declared type still names the callee's own `@template` params no longer overwrites the caller's variable with an array of a name nothing can resolve. - **A trait method inherits the `@param` of the interface method it implements.** A trait has no parent class and no interface list of its own, so a method that restates a native `string` hint lost the `@param class-string` the interface declares, and passing the parameter on to that same interface method was reported as a type mismatch. PHP flattens a trait into the classes that use it, so the declaration is now looked for where those classes provide it. diff --git a/src/framework.rs b/src/framework.rs index dc877c068..8e54f8c78 100644 --- a/src/framework.rs +++ b/src/framework.rs @@ -15,7 +15,7 @@ use tower_lsp::lsp_types::{ }; use crate::Backend; -use crate::text_position::{offset_to_position, position_to_offset}; +use crate::text_position::{LineIndex, offset_to_position, position_to_offset}; use crate::util::strip_fqn_prefix; #[derive(Debug, Clone, PartialEq, Eq)] @@ -154,13 +154,14 @@ impl Backend { Self::remove_framework_lookup_uri(lookup, uri); let uri: Arc = Arc::from(uri); + let line_index = LineIndex::new(content); let mut keys = FrameworkLookupUriKeys::default(); for reference in references { let location = IndexedFrameworkLocation { uri: Arc::clone(&uri), range: Range::new( - offset_to_position(content, reference.start as usize), - offset_to_position(content, reference.end as usize), + line_index.position(reference.start as usize), + line_index.position(reference.end as usize), ), }; match &reference.kind { @@ -220,30 +221,41 @@ impl Backend { } /// Scan all YAML/XML framework resources under the workspace root. - pub(crate) fn index_framework_workspace(&self) -> usize { + pub(crate) fn index_framework_workspace( + &self, + progress: Option<&crate::progress::ScanProgress>, + ) -> usize { let Some(root) = self.workspace.workspace_root.read().clone() else { return 0; }; - let mut indexed = HashMap::new(); - let mut doctrine_repositories = HashMap::new(); - let mut lookup = FrameworkReferenceLookupIndexInner::default(); - for entry in ignore::WalkBuilder::new(&root) + if let Some(progress) = progress { + progress.set_percentage(91, "Discovering framework resources"); + } + let paths: Vec = ignore::WalkBuilder::new(&root) .hidden(false) .build() .filter_map(Result::ok) - { - let path = entry.path(); - if !entry.file_type().is_some_and(|ft| ft.is_file()) { - continue; - } - if !is_framework_resource_path(path) || is_skipped_resource_path(path) { - continue; - } - let Ok(content) = std::fs::read_to_string(path) else { + .filter(|entry| entry.file_type().is_some_and(|ft| ft.is_file())) + .map(|entry| entry.into_path()) + .filter(|path| is_framework_resource_path(path) && !is_skipped_resource_path(path)) + .collect(); + if let Some(progress) = progress { + progress.set_scope(91, 99, "Indexing framework resources"); + progress.add_total(paths.len() as u64); + } + + let mut indexed = HashMap::new(); + let mut doctrine_repositories = HashMap::new(); + let mut lookup = FrameworkReferenceLookupIndexInner::default(); + for path in paths { + let Ok(content) = std::fs::read_to_string(&path) else { + if let Some(progress) = progress { + progress.add_done(1); + } continue; }; - let uri = crate::util::path_to_uri(path); + let uri = crate::util::path_to_uri(&path); let mappings = scan_doctrine_repository_mappings(&uri, &content); if !mappings.is_empty() { doctrine_repositories.insert(uri.clone(), Arc::new(mappings)); @@ -253,6 +265,9 @@ impl Backend { Self::replace_framework_lookup_uri(&mut lookup, &uri, &content, &refs); indexed.insert(uri, Arc::new(refs)); } + if let Some(progress) = progress { + progress.add_done(1); + } } let count = indexed.len(); @@ -1279,6 +1294,28 @@ fn normalize_path(path: PathBuf) -> PathBuf { mod tests { use super::*; + #[test] + fn framework_workspace_index_reports_counted_progress() { + let dir = tempfile::tempdir_in(".").unwrap(); + let config_dir = dir.path().join("config"); + std::fs::create_dir(&config_dir).unwrap(); + std::fs::write( + config_dir.join("routes.yaml"), + "home:\n path: /\n controller: App\\Controller\\HomeController::index\n", + ) + .unwrap(); + + let backend = Backend::new_test(); + *backend.workspace.workspace_root.write() = Some(dir.path().to_path_buf()); + let progress = crate::progress::ScanProgress::new(); + + assert_eq!(backend.index_framework_workspace(Some(&progress)), 1); + assert_eq!( + progress.take_report(), + Some((99, "Indexing framework resources (1/1 files)".to_string())) + ); + } + #[test] fn doctrine_repository_index_updates_with_framework_resource() { let backend = Backend::new_test(); diff --git a/src/indexing/preload.rs b/src/indexing/preload.rs index a58f2a60a..c6a0812a9 100644 --- a/src/indexing/preload.rs +++ b/src/indexing/preload.rs @@ -76,14 +76,14 @@ impl Backend { if i >= file_count { break; } - if let Some(p) = progress { - p.add_done(1); - } let path = pending[i]; if let Ok(content) = std::fs::read_to_string(path) { let uri = crate::util::path_to_uri(path); self.update_ast(&uri, &content); } + if let Some(p) = progress { + p.add_done(1); + } } }) .expect("failed to spawn autoload-preload thread"); diff --git a/src/server.rs b/src/server.rs index 2e355ee24..9110be358 100644 --- a/src/server.rs +++ b/src/server.rs @@ -481,13 +481,14 @@ impl LanguageServer for Backend { } } - let framework_count = self.index_framework_workspace(); + let framework_count = self.index_framework_workspace(Some(&progress)); if framework_count > 0 { tracing::info!( "PHPantom: indexed {} Symfony/Doctrine resource file(s)", framework_count ); } + progress.set_percentage(99, "Finalizing startup indexes"); if let Some(poller) = poller { poller.finish().await;