diff --git a/config-schema.json b/config-schema.json index 3d745adad..6b5ecf2c6 100644 --- a/config-schema.json +++ b/config-schema.json @@ -13,6 +13,31 @@ "type": "string", "description": "Override the detected PHP version (e.g. \"8.3\"). When unset, PHPantom infers from composer.json's platform or require.php.", "pattern": "^\\d+\\.\\d+(\\.\\d+)?$" + }, + "proxies": { + "type": "array", + "description": "Generated transparent-proxy discovery rules. Matching subclasses keep their PHP type, while project metadata is attributed to their real parent class.", + "items": { + "type": "object", + "properties": { + "paths": { + "type": "array", + "description": "Workspace-relative PHP files, directories, or glob patterns to scan for generated proxy subclasses.", + "items": { + "type": "string" + } + }, + "marker-interface": { + "type": "string", + "description": "Fully-qualified interface that a generated subclass must directly implement to be treated as a transparent proxy." + } + }, + "required": [ + "paths", + "marker-interface" + ] + }, + "default": [] } } }, diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4b324a375..f04d7b93c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -67,6 +67,7 @@ src/ │ │ # Class & type resolution ├── resolution.rs # Multi-phase class/function lookup across files (find_or_load_class) +├── proxy_metadata.rs # Transparent proxy → real-class relations for metadata consumers ├── class_lookup.rs # Subtype checks (is_subtype_of_typed) and class-lookup helpers ├── inheritance/ # Parent/trait/mixin member merging, generics substitution ├── virtual_members/ # Synthesized members: phpdoc.rs (@method/@property/@mixin) + laravel/ (one file per Eloquent/framework feature) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 86d5bb3b3..df4d8ca0a 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -27,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Reference CodeLens.** A clickable reference count sits above the classes, functions, methods, properties, and constants a PHP file declares, and following one opens the same list Find References gives. This replaces the count that used to be drawn at the end of the declaration line, which could only be read, so the number appears once and in a place you can act on. A declaration nothing in the workspace names is answered from the index straight away, and the receiver of every member access in a candidate file is resolved once and kept in a compact semantic layer, so opening a large file does not turn into one expensive search per lens. Clients that can be asked to re-pull lenses are shown a member's count once it is ready rather than being made to resolve each one in turn. A lens whose count is being worked out reads `- references` and keeps its line, so editing a file does not shuffle every line in it up and down as counts come and go, and an edit only counts again what it can actually have changed: typing inside a method body leaves every count in the file standing, and a burst of keystrokes is answered once rather than once per keystroke. Contributed by @sidux. - **Implementation CodeLens.** Interfaces and abstract classes, and the methods they declare, show a clickable implementation count, and following one lists every implementation. A method a class inherits unchanged or takes from a trait is listed at the declaration that supplies its body. The count replaces the one that used to be drawn at the end of an interface's or abstract class's declaration line, which could only be read. 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. +- **Generated transparent proxies can be mapped back to their real classes.** Configure opt-in proxy paths and a marker interface under `[[php.proxies]]`; metadata read from YAML or XML then bubbles navigation, references, and member links to the real parent class without changing normal PHP type resolution. Contributed by @sidux. - **Headless consumers can export owned semantic records without starting an LSP transport.** The optional `semantic-export` feature accepts caller-supplied PHP documents, resolves them together, and returns deterministic declarations, occurrences, calls, byte ranges, and document diagnostics through batch or streaming APIs. An independent `offline-stubs` feature guarantees that missing stubs do not trigger a build-time download. Contributed by @aaaaaandrew. - **`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. - **Blade directives a project registers itself.** A directive declared with `Blade::directive('priceTag', …)` or `Blade::if('bakeryOpen', …)` in a service provider is now read off that registration, so a template writing it gets the same treatment as one writing a directive Blade ships: the name is offered while it is being typed, and the expression the directive is handed stays real PHP whose types are checked, instead of the whole thing being masked as markup. `Blade::if()` registers four directives rather than one, and all four (`@bakeryOpen`, `@unlessbakeryOpen`, `@elsebakeryOpen`, `@endbakeryOpen`) are recognised as the block they form. A directive registered while the editor is open applies to the templates already open. diff --git a/docs/configuration.md b/docs/configuration.md index c260811ca..bc7eca711 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -61,6 +61,24 @@ The full schema is at [`config-schema.json`](https://github.com/PHPantom-dev/php | --------- | ------ | --------------------------- | ----------- | | `version` | string | Inferred from composer.json | Override the detected PHP version (e.g. `"8.3"`). | +#### `[[php.proxies]]` + +Declare generated transparent-proxy subclasses so metadata found on the +generated class is attributed to its real parent class. PHPantom scans only +the listed workspace-relative files, directories, or globs. A class must +directly implement `marker-interface`; an ordinary subclass in the same path +is left alone. + +```toml +[[php.proxies]] +paths = ["var/cache/*/generated-proxies/*.php"] +marker-interface = 'ProxyManager\Proxy\AccessInterceptorValueHolderInterface' +``` + +This does not replace the proxy class in PHP type resolution. It gives project +metadata features one shared relation to the parent class; YAML/XML navigation +uses that relation directly. + ### `[diagnostics]` | Key | Type | Default | Description | diff --git a/src/backend/startup.rs b/src/backend/startup.rs index 350ae8add..71078bfc7 100644 --- a/src/backend/startup.rs +++ b/src/backend/startup.rs @@ -349,6 +349,21 @@ impl Backend { self.discover_workspace_symbols(&root, php_version, composer_package, Some(&progress)) .await; + // Generated transparent proxies live in opt-in cache/build paths + // that normal project indexing may ignore. Read their declarations + // into the metadata relation index; they do not enter the type + // engine or the workspace class map. + let proxy_backend = self.clone_for_blocking(); + let proxy_root = root.clone(); + let proxy_count = run_blocking_cancel_safe("index_php_proxies", move || { + proxy_backend.rebuild_configured_proxy_index(&proxy_root) + }) + .await + .unwrap_or(0); + if proxy_count > 0 { + tracing::info!("PHPantom: indexed {} transparent proxies", proxy_count); + } + // Laravel-only startup work. The project classification is // set by the init pass above from composer.json, so it has to // run after it: a Symfony workspace must never pay for the diff --git a/src/config.rs b/src/config.rs index fe20733da..012773595 100644 --- a/src/config.rs +++ b/src/config.rs @@ -146,6 +146,23 @@ pub struct PhpConfig { /// Override the detected PHP version (e.g. `"8.3"`). /// When `None`, PHPantom infers from `composer.json`. pub version: Option, + /// Generated transparent-proxy class rules. + /// + /// Each rule scans opt-in workspace-relative paths for subclasses that + /// directly implement a marker interface. Metadata attached to the + /// generated subclass is then attributed to its parent class. + pub proxies: Vec, +} + +/// One `[[php.proxies]]` transparent-proxy discovery rule. +#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)] +#[serde(default)] +pub struct PhpProxyConfig { + /// Workspace-relative PHP files, directories, or glob patterns to scan. + pub paths: Vec, + /// Interface that proves a generated subclass is a transparent proxy. + #[serde(rename = "marker-interface")] + pub marker_interface: String, } /// `[diagnostics]` section — toggle individual diagnostic providers. @@ -964,6 +981,7 @@ mod tests { fn default_content_parses_successfully() { let config: Config = toml::from_str(DEFAULT_CONFIG_CONTENT).unwrap(); assert!(config.php.version.is_none()); + assert!(config.php.proxies.is_empty()); assert!(!config.diagnostics.unresolved_member_access_enabled()); assert!(!config.diagnostics.extra_arguments_enabled()); assert!(!config.diagnostics.report_magic_properties_enabled()); @@ -1001,6 +1019,7 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let config = load_config(dir.path()).unwrap(); assert!(config.php.version.is_none()); + assert!(config.php.proxies.is_empty()); assert!(!config.diagnostics.unresolved_member_access_enabled()); assert!(!config.diagnostics.extra_arguments_enabled()); assert!(!config.diagnostics.report_magic_properties_enabled()); @@ -1024,6 +1043,7 @@ mod tests { std::fs::write(&path, "").unwrap(); let config = load_config(dir.path()).unwrap(); assert!(config.php.version.is_none()); + assert!(config.php.proxies.is_empty()); assert!(!config.diagnostics.unresolved_member_access_enabled()); assert!(!config.diagnostics.extra_arguments_enabled()); assert!(!config.diagnostics.report_magic_properties_enabled()); @@ -1048,6 +1068,30 @@ mod tests { assert_eq!(config.php.version.as_deref(), Some("8.3")); } + #[test] + fn parses_transparent_proxy_rules() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(CONFIG_FILE_NAME); + std::fs::write( + &path, + r#" +[[php.proxies]] +paths = ["var/cache/*/proxies/*.php"] +marker-interface = 'Acme\Proxy\TransparentProxy' +"#, + ) + .unwrap(); + + let config = load_config(dir.path()).unwrap(); + assert_eq!( + config.php.proxies, + vec![PhpProxyConfig { + paths: vec!["var/cache/*/proxies/*.php".to_string()], + marker_interface: "Acme\\Proxy\\TransparentProxy".to_string(), + }] + ); + } + #[test] fn parses_diagnostics_section() { let dir = tempfile::tempdir().unwrap(); diff --git a/src/indexing/preload.rs b/src/indexing/preload.rs index 51c3f4635..ff8cc2682 100644 --- a/src/indexing/preload.rs +++ b/src/indexing/preload.rs @@ -273,6 +273,7 @@ impl Backend { let phase1_uri_set: HashSet<&str> = phase1_uris.iter().map(|uri| uri.as_str()).collect(); let (phase2_work, resource_work) = if let Some(root) = workspace_root.clone() { let vendor_dir_paths = self.workspace.vendor_dir_paths.lock().clone(); + let proxy_rules = self.config().php.proxies; self.report_workspace_index_progress(progress, 3, "Scanning workspace files"); let walk_start = std::time::Instant::now(); @@ -293,6 +294,9 @@ impl Backend { let php_work = php_files .into_iter() .filter_map(|path| { + if crate::proxy_metadata::is_configured_proxy_path(&root, &path, &proxy_rules) { + return None; + } let uri = crate::util::path_to_uri(&path); if existing_uris.contains(&uri) || phase1_uri_set.contains(uri.as_str()) { None diff --git a/src/indexing/watch.rs b/src/indexing/watch.rs index aef910742..14af40a4b 100644 --- a/src/indexing/watch.rs +++ b/src/indexing/watch.rs @@ -56,6 +56,7 @@ impl Backend { ) -> bool { let mut composer_changed = false; let mut config_changed = false; + let mut proxy_index_rebuild = false; 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(); @@ -63,6 +64,7 @@ impl Backend { let mut migration_discovery = crate::virtual_members::laravel::database_schema::MigrationDiscovery::default(); let is_laravel = self.resolved_class_cache.read().is_laravel(); + let proxy_rules = self.config().php.proxies; let config_path = root.join(crate::config::CONFIG_FILE_NAME); let changes = self.spell_changes_as_indexed(¶ms.changes); { @@ -169,6 +171,16 @@ impl Backend { continue; }; + // Generated proxies are opt-in metadata inputs, not ordinary + // project classes, and usually live in a cache directory the + // exclusions below would drop. Rebuild their small relation + // index rather than parsing them into the workspace symbol + // maps. + if crate::proxy_metadata::is_configured_proxy_path(root, &file_path, &proxy_rules) { + proxy_index_rebuild = true; + continue; + } + // Excluded paths are invisible to indexing; skip their // events the way the workspace scanners skip the files. if filters.is_excluded_path(&file_path, false) { @@ -195,6 +207,7 @@ impl Backend { && resource_changes.is_empty() && !composer_changed && !config_changed + && !proxy_index_rebuild && !schema_full_rebuild && migration_changes.is_empty() { @@ -204,6 +217,7 @@ impl Backend { if config_changed { tracing::info!("PHPantom: .phpantom.toml changed, reloading configuration"); self.reload_config(root); + proxy_index_rebuild = true; // Schema/migration settings live in the same file, and the // cheapest correct response to "something in here changed" is // the same full rebuild a config/database.php or schema file @@ -254,6 +268,12 @@ impl Backend { self.rescan_composer_indexes(root); } + if proxy_index_rebuild { + let count = self.rebuild_configured_proxy_index(root); + tracing::info!("PHPantom: indexed {} transparent proxies", count); + self.refresh_indexed_resource_symbols(); + } + if !resource_changes.is_empty() { tracing::info!( "PHPantom: {} watched YAML/XML file(s) changed on disk, refreshing references", @@ -552,6 +572,14 @@ impl Backend { last_modified = modified; tracing::info!("PHPantom: global config changed, reloading configuration"); self.reload_config(&root); + let proxy_backend = self.clone_for_blocking(); + let proxy_root = root.clone(); + crate::server::run_blocking_cancel_safe("reload_php_proxies", move || { + let count = proxy_backend.rebuild_configured_proxy_index(&proxy_root); + proxy_backend.refresh_indexed_resource_symbols(); + count + }) + .await; } } } diff --git a/src/lib.rs b/src/lib.rs index 3cc00c5cf..c5443c60c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -287,6 +287,7 @@ mod phpstan; pub(crate) mod phpstan_ignore; pub(crate) mod process; pub mod progress; +mod proxy_metadata; mod reference_counts; mod reference_index; mod references; @@ -626,6 +627,12 @@ pub struct Backend { /// candidate files, then run their existing semantic checks for aliases, /// inheritance, Laravel declarations, and `self/static/parent`. pub(crate) reference_index: reference_index::ReferenceIndex, + /// Transparent proxy-to-real-class relations for metadata consumers. + /// + /// Generated proxies remain valid PHP subclasses in the type engine, + /// while events, external references, and lenses can be attributed to the + /// class the proxy represents at runtime. + pub(crate) proxy_index: Arc>, /// Skip building [`reference_index`] from `update_ast`. /// /// Set by [`Backend::new_headless`] for the `analyze`/`fix` CLI @@ -1232,6 +1239,7 @@ impl Backend { open_files: Arc::new(RwLock::new(HashMap::new())), symbol_maps: Arc::new(RwLock::new(HashMap::new())), reference_index: reference_index::new_reference_index(), + proxy_index: Arc::new(RwLock::new(proxy_metadata::ProxyIndex::default())), skip_reference_index: false, symbols: SymbolIndex::new(), workspace, @@ -2035,6 +2043,7 @@ impl Backend { open_files: Arc::clone(&self.open_files), symbol_maps: Arc::clone(&self.symbol_maps), reference_index: Arc::clone(&self.reference_index), + proxy_index: Arc::clone(&self.proxy_index), skip_reference_index: self.skip_reference_index, symbols: self.symbols.clone(), parse_errors: Arc::clone(&self.parse_errors), diff --git a/src/proxy_metadata.rs b/src/proxy_metadata.rs new file mode 100644 index 000000000..e64fa191f --- /dev/null +++ b/src/proxy_metadata.rs @@ -0,0 +1,448 @@ +//! Transparent PHP proxy relations used by project metadata. +//! +//! The type engine still sees generated proxy subclasses as the classes they +//! actually declare. Metadata consumers use this module when a proxy is only +//! a runtime wrapper and annotations, events, references, or lenses should be +//! attributed to the wrapped parent class instead. + +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; +use std::path::{Component, Path, PathBuf}; + +use globset::Glob; +use ignore::WalkBuilder; + +use crate::Backend; +use crate::config::PhpProxyConfig; + +const CONFIG_SOURCE: &str = "php-config"; +const MAX_PROXY_DEPTH: usize = 32; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ProxyRelation { + pub proxy_fqn: String, + pub target_fqn: String, +} + +#[derive(Debug, Clone, Default)] +pub(crate) struct ProxyIndex { + sources: BTreeMap>, + targets: HashMap, + families: HashMap>, +} + +impl ProxyIndex { + fn replace_source(&mut self, source: String, relations: Vec) { + if relations.is_empty() { + self.sources.remove(&source); + } else { + self.sources.insert(source, relations); + } + self.rebuild_targets(); + } + + fn rebuild_targets(&mut self) { + self.targets.clear(); + for relations in self.sources.values() { + for relation in relations { + let proxy = normalize_class_name(&relation.proxy_fqn); + let target = normalize_class_name(&relation.target_fqn); + if proxy.is_empty() || target.is_empty() || proxy.eq_ignore_ascii_case(&target) { + continue; + } + self.targets.insert( + class_key(&proxy), + ProxyRelation { + proxy_fqn: proxy, + target_fqn: target, + }, + ); + } + } + + let mut families: HashMap> = HashMap::new(); + for relation in self.targets.values() { + if let Some(target) = self.canonical_target(&relation.proxy_fqn) { + families + .entry(class_key(&target)) + .or_default() + .push(relation.proxy_fqn.clone()); + } + } + for proxies in families.values_mut() { + proxies.sort_by_key(|name| name.to_ascii_lowercase()); + proxies.dedup_by(|left, right| left.eq_ignore_ascii_case(right)); + } + self.families = families; + } + + fn canonical_target(&self, class_fqn: &str) -> Option { + let original = normalize_class_name(class_fqn); + let mut current = original.clone(); + let mut seen = HashSet::with_capacity(4); + let mut changed = false; + + for _ in 0..MAX_PROXY_DEPTH { + let key = class_key(¤t); + if !seen.insert(key.clone()) { + return None; + } + let Some(relation) = self.targets.get(&key) else { + return changed.then_some(current); + }; + current.clone_from(&relation.target_fqn); + changed = true; + } + + None + } + + fn class_family(&self, class_fqn: &str) -> Vec { + let canonical = self + .canonical_target(class_fqn) + .unwrap_or_else(|| normalize_class_name(class_fqn)); + let proxies = self.families.get(&class_key(&canonical)); + let mut family = Vec::with_capacity(proxies.map_or(1, |proxies| proxies.len() + 1)); + family.push(canonical); + if let Some(proxies) = proxies { + family.extend(proxies.iter().cloned()); + } + family + } + + fn len(&self) -> usize { + self.targets.len() + } +} + +impl Backend { + /// Replace the proxy relations contributed by one metadata adapter. + /// + /// `source` is stable adapter identity (usually a generated file URI), so + /// refreshing one adapter cannot discard relations found by another. + pub(crate) fn replace_proxy_relations( + &self, + source: impl Into, + relations: Vec, + ) { + self.proxy_index + .write() + .replace_source(source.into(), relations); + } + + /// Return the real class and every transparent proxy that represents it. + pub(crate) fn metadata_class_family(&self, class_fqn: &str) -> Vec { + self.proxy_index.read().class_family(class_fqn) + } + + /// Rebuild relations discovered from `[[php.proxies]]` rules. + pub(crate) fn rebuild_configured_proxy_index(&self, workspace_root: &Path) -> usize { + let rules = self.config().php.proxies; + let mut relations = Vec::new(); + + for rule in &rules { + if rule.marker_interface.trim().is_empty() { + continue; + } + for path in collect_rule_files(workspace_root, rule) { + relations.extend(self.proxy_relations_in_file(&path, rule)); + } + } + + self.replace_proxy_relations(CONFIG_SOURCE, relations); + self.proxy_index.read().len() + } + + fn proxy_relations_in_file(&self, path: &Path, rule: &PhpProxyConfig) -> Vec { + let Ok(content) = std::fs::read_to_string(path) else { + return Vec::new(); + }; + let marker = normalize_class_name(&rule.marker_interface); + + Self::parse_php_versioned_with_namespaces(&content, None) + .into_iter() + .filter_map(|(class, namespace)| { + let implements_marker = class.interfaces.iter().any(|interface| { + normalize_class_name(interface.as_str()).eq_ignore_ascii_case(&marker) + }); + if !implements_marker { + return None; + } + + let target = normalize_class_name(class.parent_class?.as_str()); + if target.is_empty() { + return None; + } + let proxy_fqn = match namespace { + Some(namespace) if !namespace.is_empty() => { + format!("{}\\{}", namespace, class.name) + } + _ => class.name.to_string(), + }; + Some(ProxyRelation { + proxy_fqn, + target_fqn: target, + }) + }) + .collect() + } +} + +/// Whether a changed path belongs to an opt-in proxy discovery rule. +pub(crate) fn is_configured_proxy_path( + workspace_root: &Path, + path: &Path, + rules: &[PhpProxyConfig], +) -> bool { + let Ok(relative) = path.strip_prefix(workspace_root) else { + return false; + }; + rules.iter().any(|rule| { + rule.paths + .iter() + .any(|spec| path_matches_spec(relative, spec)) + }) +} + +fn collect_rule_files(workspace_root: &Path, rule: &PhpProxyConfig) -> Vec { + let mut files = BTreeSet::new(); + for spec in &rule.paths { + let Some(relative) = safe_relative_path(spec) else { + continue; + }; + + if has_glob_meta(spec) { + let Ok(glob) = Glob::new(spec) else { + tracing::warn!("PHPantom: invalid proxy path glob: {}", spec); + continue; + }; + let matcher = glob.compile_matcher(); + let base = workspace_root.join(fixed_glob_prefix(&relative)); + collect_php_files( + &base, + |path| { + path.strip_prefix(workspace_root) + .is_ok_and(|relative| matcher.is_match(relative)) + }, + &mut files, + ); + continue; + } + + let absolute = workspace_root.join(relative); + if absolute.is_file() { + if is_php_file(&absolute) { + files.insert(absolute); + } + } else if absolute.is_dir() { + collect_php_files(&absolute, |_| true, &mut files); + } + } + files.into_iter().collect() +} + +fn collect_php_files(root: &Path, matches: impl Fn(&Path) -> bool, files: &mut BTreeSet) { + if !root.exists() { + return; + } + let walker = WalkBuilder::new(root) + .git_ignore(false) + .git_global(false) + .git_exclude(false) + .hidden(false) + .parents(false) + .ignore(false) + .follow_links(false) + .build(); + + for entry in walker.filter_map(Result::ok) { + let path = entry.path(); + if entry.file_type().is_some_and(|kind| kind.is_file()) + && is_php_file(path) + && matches(path) + { + files.insert(path.to_path_buf()); + } + } +} + +fn path_matches_spec(relative: &Path, spec: &str) -> bool { + let Some(spec_path) = safe_relative_path(spec) else { + return false; + }; + if has_glob_meta(spec) { + return Glob::new(spec) + .ok() + .is_some_and(|glob| glob.compile_matcher().is_match(relative)); + } + relative == spec_path || relative.starts_with(spec_path) +} + +fn safe_relative_path(spec: &str) -> Option { + let path = Path::new(spec.trim()); + if path.as_os_str().is_empty() + || path.is_absolute() + || path.components().any(|component| { + matches!( + component, + Component::ParentDir | Component::RootDir | Component::Prefix(_) + ) + }) + { + return None; + } + Some(path.to_path_buf()) +} + +fn fixed_glob_prefix(path: &Path) -> PathBuf { + path.components() + .take_while(|component| match component { + Component::Normal(part) => !has_glob_meta(&part.to_string_lossy()), + _ => false, + }) + .collect() +} + +fn has_glob_meta(value: &str) -> bool { + value + .bytes() + .any(|byte| matches!(byte, b'*' | b'?' | b'[' | b'{')) +} + +fn is_php_file(path: &Path) -> bool { + path.extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("php")) +} + +fn normalize_class_name(name: &str) -> String { + name.trim().trim_start_matches('\\').to_string() +} + +fn class_key(name: &str) -> String { + name.to_ascii_lowercase() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn canonicalizes_chains_and_builds_class_families() { + let mut index = ProxyIndex::default(); + index.replace_source( + "generated".to_string(), + vec![ + ProxyRelation { + proxy_fqn: "Generated\\Outer".to_string(), + target_fqn: "Generated\\Inner".to_string(), + }, + ProxyRelation { + proxy_fqn: "Generated\\Inner".to_string(), + target_fqn: "App\\Service".to_string(), + }, + ], + ); + + assert_eq!( + index.canonical_target("generated\\OUTER").as_deref(), + Some("App\\Service") + ); + assert_eq!( + index.class_family("App\\Service"), + vec![ + "App\\Service".to_string(), + "Generated\\Inner".to_string(), + "Generated\\Outer".to_string(), + ] + ); + } + + #[test] + fn isolates_adapter_sources_and_rejects_cycles() { + let mut index = ProxyIndex::default(); + index.replace_source( + "one".to_string(), + vec![ProxyRelation { + proxy_fqn: "Generated\\One".to_string(), + target_fqn: "App\\One".to_string(), + }], + ); + index.replace_source( + "two".to_string(), + vec![ProxyRelation { + proxy_fqn: "Generated\\Two".to_string(), + target_fqn: "App\\Two".to_string(), + }], + ); + index.replace_source("one".to_string(), Vec::new()); + + assert_eq!(index.canonical_target("Generated\\One"), None); + assert_eq!( + index.canonical_target("Generated\\Two").as_deref(), + Some("App\\Two") + ); + + index.replace_source( + "cycle".to_string(), + vec![ + ProxyRelation { + proxy_fqn: "Cycle\\A".to_string(), + target_fqn: "Cycle\\B".to_string(), + }, + ProxyRelation { + proxy_fqn: "Cycle\\B".to_string(), + target_fqn: "Cycle\\A".to_string(), + }, + ], + ); + assert_eq!(index.canonical_target("Cycle\\A"), None); + } + + #[test] + fn scans_only_marked_proxy_subclasses() { + let backend = Backend::new_test(); + let dir = tempfile::tempdir().unwrap(); + let proxy = dir.path().join("Proxy.php"); + std::fs::write( + &proxy, + r#" { @@ -72,8 +83,7 @@ impl Backend { for span in &symbol_map.spans { let matched = match &span.kind { SymbolKind::ClassReference { name, is_fqn, .. } => { - let resolved = fqn_resolver.fqn(name, *is_fqn, span.start); - class_names_match(strip_fqn_prefix(&resolved), target, target_short) + class_matches(&fqn_resolver.fqn(name, *is_fqn, span.start)) } SymbolKind::ClassDeclaration { name } if include_declaration => { if !name.eq_ignore_ascii_case(target_short) { diff --git a/src/resource_navigation.rs b/src/resource_navigation.rs index bf4018bd7..5da0ef98c 100644 --- a/src/resource_navigation.rs +++ b/src/resource_navigation.rs @@ -67,11 +67,17 @@ impl Backend { position: Position, ) -> Option { match symbol_at(content, position)? { - ResourceSymbol::Class(fqn) => self.class_declaration_location(&fqn), + ResourceSymbol::Class(fqn) => self + .metadata_class_family(&fqn) + .iter() + .find_map(|target| self.class_declaration_location(target)), ResourceSymbol::Member { class_fqn, member_name, - } => self.class_member_declaration_location(&class_fqn, &member_name), + } => self + .metadata_class_family(&class_fqn) + .iter() + .find_map(|target| self.class_member_declaration_location(target, &member_name)), } } @@ -107,6 +113,35 @@ impl Backend { self.reindex_references_for_symbol_maps_batch(maps); } + /// Rebuild already-indexed resource maps after proxy configuration changes. + pub(crate) fn refresh_indexed_resource_symbols(&self) { + let uris: Vec = self + .symbol_maps + .read() + .keys() + .filter(|uri| is_resource_document(uri)) + .cloned() + .collect(); + let maps: Vec<(String, Arc)> = uris + .into_iter() + .filter_map(|uri| { + let content = self.get_file_content(&uri)?; + Some((uri, 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) { @@ -121,11 +156,16 @@ impl Backend { }); if let Some((member_name, member_start, member_end)) = symbol.member { + let canonical_class = self + .metadata_class_family(&symbol.class_fqn) + .into_iter() + .next() + .unwrap_or(symbol.class_fqn); spans.push(SymbolSpan { start: member_start as u32, end: member_end as u32, kind: SymbolKind::MemberAccess { - subject_text: SubjectText::owned(symbol.class_fqn), + subject_text: SubjectText::owned(canonical_class), member_name: atom(&member_name), is_static: false, is_method_call: true, diff --git a/tests/integration/definition_resource_files.rs b/tests/integration/definition_resource_files.rs index 22d89d62d..79b4f1a0d 100644 --- a/tests/integration/definition_resource_files.rs +++ b/tests/integration/definition_resource_files.rs @@ -424,3 +424,88 @@ async fn rename_is_refused_from_inside_a_resource_file() { .is_none() ); } + +#[tokio::test] +async fn transparent_proxy_metadata_navigates_to_the_real_class() { + let php = concat!( + "