Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions config-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": []
}
}
},
Expand Down
1 change: 1 addition & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
18 changes: 18 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
15 changes: 15 additions & 0 deletions src/backend/startup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
44 changes: 44 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// 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<PhpProxyConfig>,
}

/// 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<String>,
/// Interface that proves a generated subclass is a transparent proxy.
#[serde(rename = "marker-interface")]
pub marker_interface: String,
}

/// `[diagnostics]` section — toggle individual diagnostic providers.
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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());
Expand All @@ -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());
Expand All @@ -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();
Expand Down
4 changes: 4 additions & 0 deletions src/indexing/preload.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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
Expand Down
28 changes: 28 additions & 0 deletions src/indexing/watch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,13 +56,15 @@ 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();
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();
let proxy_rules = self.config().php.proxies;
let config_path = root.join(crate::config::CONFIG_FILE_NAME);
let changes = self.spell_changes_as_indexed(&params.changes);
{
Expand Down Expand Up @@ -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) {
Expand All @@ -195,6 +207,7 @@ impl Backend {
&& resource_changes.is_empty()
&& !composer_changed
&& !config_changed
&& !proxy_index_rebuild
&& !schema_full_rebuild
&& migration_changes.is_empty()
{
Expand All @@ -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
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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;
}
}
}
Expand Down
9 changes: 9 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<RwLock<proxy_metadata::ProxyIndex>>,
/// Skip building [`reference_index`] from `update_ast`.
///
/// Set by [`Backend::new_headless`] for the `analyze`/`fix` CLI
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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),
Expand Down
Loading
Loading